Compare commits
31 Commits
f3d084e046
...
spike/modu
| Author | SHA1 | Date | |
|---|---|---|---|
| bf470c7658 | |||
| f1dda8fe66 | |||
| 4691fd6633 | |||
| 265042eaa5 | |||
| 18815f4c7a | |||
| 15cefe5ea1 | |||
| b517d7b2df | |||
| 78f994955c | |||
| 32a3ff104a | |||
| 42a403ad2e | |||
| 847cfd2d2b | |||
| 02580ebda3 | |||
| 3d6b2e23a7 | |||
| 0a2ccafff6 | |||
| ec0036ce6d | |||
| d765280e28 | |||
| 03534c8db1 | |||
| 5103b74a9d | |||
| c91fd128bf | |||
| 01a559792c | |||
| e50fab241f | |||
| 779a304173 | |||
| c6c0c257dd | |||
| 8771a1cf6c | |||
| 8da658f223 | |||
| bda031566a | |||
| b61a4d6721 | |||
| 1e1a3d67c3 | |||
| 26094459ae | |||
| bfa1db58c4 | |||
| 7c769ea8fd |
@@ -117,7 +117,10 @@ BOT_INTERNAL_KEY=change-me-to-a-long-random-string
|
||||
# token). These URLs are just defaults; the admin can override them at runtime.
|
||||
UOLINK_BASE_URL=http://127.0.0.1:8080
|
||||
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
|
||||
UOLINK_PROTOCOL=1
|
||||
# Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site
|
||||
# with nothing saved yet — the admin panel's pinned value wins — but set it lower
|
||||
# if you deliberately run an older sidecar.
|
||||
UOLINK_PROTOCOL=3
|
||||
|
||||
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
|
||||
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
|
||||
# build or failing test can't reach the deployable branch. Complements
|
||||
# Gate every pull request into `main` or `edge` on a fast, DB-free check suite so
|
||||
# a broken build or failing test can't reach either integration branch. Complements
|
||||
# build-images.yml, which runs only AFTER merge (on push to main) to publish
|
||||
# images — this one runs BEFORE merge.
|
||||
#
|
||||
# `edge` is listed as well as `main` because long workstreams land phase by phase
|
||||
# on `edge` and reach `main` as a single cutover (the module system, protocol v3).
|
||||
# With `branches: [main]` alone, every one of those phase PRs merges with NO checks
|
||||
# at all and the entire workstream runs blind until the cutover — which is exactly
|
||||
# what happened to the nine Android M12 phase PRs in that repo. A branch that
|
||||
# accumulates work for weeks needs the gate more than `main` does, not less.
|
||||
#
|
||||
# Enforcement (one-time, in the Gitea UI):
|
||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||
# • Enable Status Check
|
||||
@@ -10,6 +17,10 @@
|
||||
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
||||
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||
# without needing the dropdown.
|
||||
# The workflow now RUNS on PRs into `edge` too, but running is not enforcing:
|
||||
# blocking a red phase PR needs its own protection rule for `edge`, with the
|
||||
# same `PR Checks / *` pattern. Without one the checks report and merging stays
|
||||
# possible anyway.
|
||||
#
|
||||
# Runner: reuses the existing self-hosted `ubuntu-latest` runner. These jobs need
|
||||
# only Node (no Docker socket), and the server tests stub their models + point the
|
||||
@@ -19,7 +30,7 @@ name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, edge]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
concurrency:
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -28,6 +28,17 @@ logs/
|
||||
# See docs/website/SPAWN_ATLAS.md and db/data/spawnAtlas.art.example.json.
|
||||
server/db/data/spawnAtlas.art.json
|
||||
|
||||
# Operator-supplied cliloc table. UO's localization strings are EA's, extracted
|
||||
# from the operator's own client and converted once (docs/website/CLILOCS.md);
|
||||
# the repo ships no string table, for the same reason it ships no artwork and no
|
||||
# map snapshot. This covers the conventional in-repo location — the supported
|
||||
# arrangement is a path OUTSIDE the repo, set from Admin → Shard.
|
||||
server/db/data/cliloc*
|
||||
server/db/data/clilocs.*
|
||||
# The build output of tools/cliloc-export (a throwaway helper, not a package).
|
||||
server/tools/cliloc-export/bin/
|
||||
server/tools/cliloc-export/obj/
|
||||
|
||||
# reference material (extracted from the provided archives)
|
||||
_reference/
|
||||
|
||||
|
||||
23
README.md
23
README.md
@@ -416,6 +416,29 @@ exposes a small, authenticated HTTP + WebSocket API; this website is a *client*
|
||||
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
|
||||
|
||||
You do not build or place any of it by hand. The
|
||||
**[Runic Gateway installer](https://gitea.whitlocktech.com/RunicGateway/installer)** runs on the
|
||||
shard host, deploys the ServUO plugin and the uo-link sidecar as a matched, protocol-checked pair,
|
||||
registers the sidecar as a service, and ends by printing the four values this site needs:
|
||||
|
||||
```
|
||||
Base URL http://<shard-host>:8080
|
||||
WebSocket URL ws://<shard-host>:8080/ws
|
||||
Protocol version 3
|
||||
Auth token 4f9c…
|
||||
```
|
||||
|
||||
Paste them into **Admin → Shard** here and the bridge is live. The operator guide is
|
||||
[installer/INSTALL.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md);
|
||||
its [Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
|
||||
is the same deployment done by hand, still supported, for a host that cannot run the binary or a
|
||||
developer working from a source tree.
|
||||
|
||||
Nothing here needs the shard to exist: with no sidecar configured the site renders normally and
|
||||
shows the shard offline.
|
||||
|
||||
### How it works
|
||||
|
||||
```
|
||||
|
||||
@@ -1,13 +1,83 @@
|
||||
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
|
||||
// logs carry the instance identity. Kept minimal — the bot only needs the name
|
||||
// and the accent color (as an int for discord.js embeds).
|
||||
//
|
||||
// The accent additionally tracks ADMIN THEMING. An admin who re-themes the site
|
||||
// changes `theme_visual`, which the server resolves into the effective
|
||||
// `brand.accent` on GET /public/settings (docs/website/THEMING_AND_NAV.md
|
||||
// §4.5). This process boots from env and then follows that value, so embeds
|
||||
// don't stay the old color until someone restarts the container.
|
||||
//
|
||||
// Design constraints this satisfies:
|
||||
// • env is always a working answer — a site that is down, unconfigured or
|
||||
// mid-restart never costs the bot its accent, it just keeps the last known
|
||||
// good one;
|
||||
// • reading `brand.accentInt` never awaits and never throws, because it is
|
||||
// read inline while building an embed;
|
||||
// • at most one refresh is ever in flight.
|
||||
require('dotenv').config()
|
||||
|
||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
||||
const accentInt = (() => {
|
||||
const n = parseInt(String(accentHex).replace('#', ''), 16)
|
||||
return Number.isNaN(n) ? 0x7f99bd : n
|
||||
})()
|
||||
const siteApi = require('./site/siteApiClient')
|
||||
const createLogger = require('./utils/logger')
|
||||
|
||||
module.exports = { name, accentHex, accentInt }
|
||||
const log = createLogger('brand')
|
||||
|
||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
||||
const ENV_ACCENT = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
||||
|
||||
function toInt(hex) {
|
||||
const n = parseInt(String(hex).replace('#', ''), 16)
|
||||
return Number.isNaN(n) ? 0x7f99bd : n
|
||||
}
|
||||
|
||||
// How long a fetched accent is trusted before the next read triggers a refresh.
|
||||
// A theme change reaching Discord within ten minutes is fine; a network call per
|
||||
// embed is not.
|
||||
const TTL_MS = 10 * 60 * 1000
|
||||
|
||||
let accentHex = ENV_ACCENT
|
||||
let accentInt = toInt(ENV_ACCENT)
|
||||
let fetchedAt = 0
|
||||
let inFlight = null
|
||||
|
||||
async function fetchAccent() {
|
||||
const res = await siteApi.getPublicSettings()
|
||||
// Any failure — site down, maintenance, malformed body — leaves the current
|
||||
// value in place. Stamping fetchedAt regardless is deliberate: it stops a
|
||||
// persistently unreachable site from firing a request on every single read.
|
||||
fetchedAt = Date.now()
|
||||
const accent = res.ok ? res.data?.brand?.accent : null
|
||||
if (typeof accent !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(accent)) return
|
||||
if (accent === accentHex) return
|
||||
accentHex = accent
|
||||
accentInt = toInt(accent)
|
||||
log.info('embed accent updated from the site', { accent })
|
||||
}
|
||||
|
||||
// Kick off a refresh if the cached value is stale. Never awaited by a reader —
|
||||
// the current value is returned immediately and the next read sees the new one.
|
||||
function refreshIfStale() {
|
||||
if (inFlight || Date.now() - fetchedAt < TTL_MS) return inFlight
|
||||
inFlight = fetchAccent()
|
||||
.catch((err) => log.warn('accent refresh failed — keeping the current value', { message: err.message }))
|
||||
.finally(() => {
|
||||
inFlight = null
|
||||
})
|
||||
return inFlight
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name,
|
||||
// Getters, not values: consumers already read `brand.accentInt` inline when
|
||||
// building an embed, so this keeps the accent current with no call-site change.
|
||||
get accentHex() {
|
||||
refreshIfStale()
|
||||
return accentHex
|
||||
},
|
||||
get accentInt() {
|
||||
refreshIfStale()
|
||||
return accentInt
|
||||
},
|
||||
// Awaited once at startup so the first embed of a process is already correct.
|
||||
refreshAccent: () => refreshIfStale() || Promise.resolve(),
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ async function start() {
|
||||
log.info(`internal API listening on http://${HOST}:${PORT}`)
|
||||
})
|
||||
|
||||
// Pick up the site's effective accent before the first embed can be built.
|
||||
// Best-effort by design: it never rejects, and a site that is not up yet just
|
||||
// leaves the bot on its BRAND_ACCENT_COLOR default until the next read.
|
||||
await brand.refreshAccent()
|
||||
|
||||
await bootstrap()
|
||||
|
||||
setupShutdown(server)
|
||||
|
||||
@@ -31,6 +31,15 @@ async function call(path) {
|
||||
}
|
||||
}
|
||||
|
||||
// The site's public settings, including the brand block. Used for the embed
|
||||
// accent (see brand.js): the admin can theme the site at runtime, and the
|
||||
// server resolves the effective accent into brand.accent, so this is how the
|
||||
// bot's embeds track a theme change instead of being stuck on the value
|
||||
// BRAND_ACCENT_COLOR had when the container started.
|
||||
function getPublicSettings() {
|
||||
return call('/settings')
|
||||
}
|
||||
|
||||
function getNewsPost(idOrSlug) {
|
||||
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
||||
}
|
||||
@@ -39,4 +48,4 @@ function searchWiki(query) {
|
||||
return call(`/wiki?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
module.exports = { getNewsPost, searchWiki }
|
||||
module.exports = { getPublicSettings, getNewsPost, searchWiki }
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
<meta name="description" content="Runic Gateway — an independent private Ultima Online shard. News, screenshots, guides, and community notes." />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
<!-- The eight web families behind the admin font shortlist
|
||||
(docs/website/THEMING_AND_NAV.md §5), in one combined css2? request.
|
||||
Static and never built from admin input: the dropdown stores a full
|
||||
font-family stack from a closed set, and only the families actually
|
||||
applied have their binaries fetched. Both hosts are already in the CSP
|
||||
(server/src/config/csp.js), so this needs no policy change. -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700&family=EB+Garamond:ital,wght@0,400;0,600;0,700;1,400&family=IM+Fell+English:ital@0;1&family=Inter:wght@400;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&family=Source+Sans+3:wght@400;600;700&family=Work+Sans:wght@400;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
62
client/package-lock.json
generated
62
client/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "runic-gateway-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
@@ -306,6 +309,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
|
||||
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -2488,6 +2544,12 @@
|
||||
"@popperjs/core": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/extension-image": "^2.27.2",
|
||||
"@tiptap/extension-link": "^2.27.2",
|
||||
"@tiptap/extension-text-align": "^2.27.2",
|
||||
|
||||
@@ -5,6 +5,7 @@ import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||
import RequireAuth from './components/RequireAuth.jsx'
|
||||
import RequirePlayer from './components/RequirePlayer.jsx'
|
||||
import RoleGate from './components/RoleGate.jsx'
|
||||
import { routesFor } from './modules/registry.js'
|
||||
|
||||
// Public
|
||||
import Portal from './routes/public/Portal.jsx'
|
||||
@@ -23,6 +24,9 @@ import Guilds from './routes/public/Guilds.jsx'
|
||||
import Governors from './routes/public/Governors.jsx'
|
||||
import Houses from './routes/public/Houses.jsx'
|
||||
import Rules from './routes/public/Rules.jsx'
|
||||
import Leaderboards from './routes/public/Leaderboards.jsx'
|
||||
import Market from './routes/public/Market.jsx'
|
||||
import MarketVendor from './routes/public/MarketVendor.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
import CmsPage from './routes/public/CmsPage.jsx'
|
||||
@@ -36,12 +40,15 @@ import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
|
||||
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
|
||||
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||
import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||
import AppearanceAdmin from './routes/admin/views/AppearanceAdmin.jsx'
|
||||
import NavEditor from './routes/admin/views/NavEditor.jsx'
|
||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
|
||||
import ShardVisibility from './routes/admin/views/ShardVisibility.jsx'
|
||||
import SpawnAtlasAdmin from './routes/admin/views/SpawnAtlas.jsx'
|
||||
import ShardOps from './routes/admin/views/ShardOps.jsx'
|
||||
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
||||
@@ -100,8 +107,19 @@ export default function App() {
|
||||
<Route path="/site/governors" element={<Governors />} />
|
||||
<Route path="/site/houses" element={<Houses />} />
|
||||
<Route path="/site/rules" element={<Rules />} />
|
||||
<Route path="/site/leaderboards" element={<Leaderboards />} />
|
||||
<Route path="/site/market" element={<Market />} />
|
||||
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
{/* Installed modules' public pages, namespaced `/<id>/…` (§2.8).
|
||||
Declared BEFORE the /:slug CMS catch-all: React Router ranks
|
||||
static segments over dynamic ones so the order is not what saves
|
||||
us, but keeping them adjacent makes the relationship visible. */}
|
||||
{routesFor('public').map((r) => (
|
||||
<Route key={r.path} path={`/${r.path}`} element={r.element} />
|
||||
))}
|
||||
|
||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||
above (React Router ranks static routes over this dynamic one). */}
|
||||
<Route path="/:slug" element={<CmsPage />} />
|
||||
@@ -128,6 +146,28 @@ export default function App() {
|
||||
<Route path="pages/:id" element={<PageBuilder />} />
|
||||
<Route path="wiki" element={<WikiAdmin />} />
|
||||
<Route path="hero" element={<HeroEditor />} />
|
||||
{/* Theme editing writes an admin-only settings key; the route sits
|
||||
behind the same RoleGate as the sidebar entry that reaches it,
|
||||
and PUT/DELETE /admin/settings is admin-only server-side too. */}
|
||||
<Route
|
||||
path="appearance"
|
||||
element={
|
||||
<RoleGate roles={['admin']}>
|
||||
<AppearanceAdmin />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
{/* Same reasoning as Appearance: the nav overrides are an admin-only
|
||||
settings key, so the route carries the same RoleGate as the
|
||||
sidebar entry that reaches it. */}
|
||||
<Route
|
||||
path="navigation"
|
||||
element={
|
||||
<RoleGate roles={['admin']}>
|
||||
<NavEditor />
|
||||
</RoleGate>
|
||||
}
|
||||
/>
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
<Route
|
||||
path="moderation"
|
||||
@@ -146,6 +186,7 @@ export default function App() {
|
||||
<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={
|
||||
@@ -169,6 +210,17 @@ export default function App() {
|
||||
<Route path="users/:id" element={<UserDetail />} />
|
||||
<Route path="invites" element={<InvitesAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||
wrapper — only an optional { roles } that core applies as the
|
||||
same RoleGate its own routes use (MODULE_API.md §3.3). */}
|
||||
{routesFor('admin').map((r) => (
|
||||
<Route
|
||||
key={r.path}
|
||||
path={r.path}
|
||||
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
|
||||
/>
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -42,6 +42,16 @@ function safeParse(text) {
|
||||
}
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, exported for installed modules (window.__rg.api — see
|
||||
// docs/website/MODULE_API.md §3.5). A module owns the paths it calls, because it
|
||||
// owns the routes at the other end; core owns only the fetch semantics —
|
||||
// same-origin /api/v1, cookies included, JSON in/out, ApiError on non-2xx.
|
||||
//
|
||||
// `api` below stays core's own binding surface. Its `atlas` and `shard`
|
||||
// namespaces are module bindings that only still live here because Phase 3 has
|
||||
// not moved them yet.
|
||||
export { req as request }
|
||||
|
||||
export const api = {
|
||||
// ----- auth -----
|
||||
me: () => req('/auth/me'),
|
||||
@@ -97,6 +107,14 @@ export const api = {
|
||||
generateRecoveryCodes: (currentPassword) =>
|
||||
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
|
||||
|
||||
// ----- settings (any authenticated account) -----
|
||||
// Nav overrides for the layouts the caller's own role renders, and the theme
|
||||
// catalog the appearance form is built from. A fifth group, not part of
|
||||
// /admin, because AdminLayout renders for editors and moderators too — see
|
||||
// docs/website/THEMING_AND_NAV.md §4.2.
|
||||
navSettings: () => req('/settings/nav'),
|
||||
themeOptions: () => req('/settings/theme/options'),
|
||||
|
||||
// ----- public -----
|
||||
publicSettings: () => req('/public/settings'),
|
||||
status: () => req('/public/status'),
|
||||
@@ -150,10 +168,73 @@ export const api = {
|
||||
// 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.
|
||||
@@ -217,6 +298,20 @@ export const api = {
|
||||
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
|
||||
getSettings: () => req('/admin/settings'),
|
||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
||||
// Reset one setting to its default by deleting the row — the theming/nav
|
||||
// keys and the hero draft only (the server holds the allowlist). Idempotent,
|
||||
// so the caller need not know whether a row exists.
|
||||
resetSetting: (key) => req(`/admin/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }),
|
||||
// Upload one brand asset (logo | hero | favicon) and set it as the override
|
||||
// in the same call → { url, brand_assets }. A separate endpoint from the
|
||||
// generic upload above because the server applies per-slot rules (favicons
|
||||
// are PNG-only and capped small) and writes the settings row itself, so an
|
||||
// upload never leaves a file nothing points at.
|
||||
uploadBrandAsset: (slot, file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return req(`/admin/settings/brand-asset/${encodeURIComponent(slot)}`, { method: 'POST', body: fd, raw: true })
|
||||
},
|
||||
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
||||
botActivity: () => req('/admin/bot-activity'),
|
||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||
@@ -360,6 +455,18 @@ export const api = {
|
||||
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: {
|
||||
|
||||
33
client/src/components/BrandLogo.jsx
Normal file
33
client/src/components/BrandLogo.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
|
||||
// The instance logo, shown beside the MoonDot wherever the site says its own
|
||||
// name (docs/website/THEMING_AND_NAV.md phase 5).
|
||||
//
|
||||
// Renders NOTHING unless this instance has a logo — `brand.logo` is the uploaded
|
||||
// override or BRAND_LOGO, and its default is the empty string. That is what
|
||||
// keeps an untouched instance byte-for-byte as today: the MoonDot stands alone
|
||||
// exactly as it does now, and the logo is an addition an operator opts into.
|
||||
//
|
||||
// It sits beside the moon rather than replacing it. The moon is the app's own
|
||||
// mark and appears on surfaces (maintenance, login) that must render before the
|
||||
// settings fetch resolves; swapping it out would leave those momentarily blank.
|
||||
//
|
||||
// Deliberately not used for the footer's "powered by Runic Gateway" emblem
|
||||
// (SiteFooter.jsx) — that badge is the project's mark, not the instance's, and
|
||||
// must not follow brand_assets (§4.11).
|
||||
export default function BrandLogo({ height = 22, alt = '', style }) {
|
||||
const { brand, siteTitle } = useSite()
|
||||
if (!brand.logo) return null
|
||||
return (
|
||||
<img
|
||||
src={brand.logo}
|
||||
// Decorative by default: every call site puts the site title in text right
|
||||
// next to it, so alt text here would have a screen reader say the name
|
||||
// twice. A caller that renders the logo alone passes its own alt.
|
||||
alt={alt || ''}
|
||||
aria-hidden={alt ? undefined : true}
|
||||
title={siteTitle}
|
||||
style={{ height, width: 'auto', maxWidth: height * 6, objectFit: 'contain', display: 'block', ...style }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -10,24 +10,89 @@ 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. Without a cliloc table on the site we can only show literals, so
|
||||
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
||||
// de-duped list of human-readable title chips.
|
||||
// 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 reward = Array.isArray(titles.reward) ? titles.reward : []
|
||||
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 literal one.
|
||||
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
||||
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
||||
// 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
|
||||
@@ -75,6 +140,11 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
.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 }}>
|
||||
@@ -173,17 +243,37 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
</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) => (
|
||||
{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' }}>{it.layer || 'Item'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
|
||||
<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%' }}>
|
||||
@@ -193,7 +283,8 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
150
client/src/components/NavDropdown.jsx
Normal file
150
client/src/components/NavDropdown.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
|
||||
// One dropdown section in the public header — a menu an admin created from
|
||||
// Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
|
||||
//
|
||||
// It **opens on click, never on hover**. Hover menus are unusable on touch, and
|
||||
// the alternative (make the trigger a link too) means tapping to open navigates
|
||||
// away instead. A section is a container, not a destination, so the trigger has
|
||||
// no `to` at all.
|
||||
//
|
||||
// Everything else here is the keyboard and dismissal contract a menu needs:
|
||||
// Escape closes and returns focus to the trigger, an outside press closes,
|
||||
// navigating closes, and Arrow Up/Down walk the items. `aria-haspopup` +
|
||||
// `aria-expanded` are what let a screen reader announce it as a menu rather than
|
||||
// as a button that mysteriously changes the page.
|
||||
export default function NavDropdown({ label, items, linkStyle }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef(null)
|
||||
const triggerRef = useRef(null)
|
||||
const location = useLocation()
|
||||
|
||||
// The trigger shows the active treatment when the page you are on lives in
|
||||
// this menu — otherwise entering a section makes the header look like nothing
|
||||
// is selected.
|
||||
const holdsActive = items.some((i) => (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)))
|
||||
|
||||
// Close on navigation. The menu is rendered inside a sticky header that
|
||||
// survives route changes, so nothing else would dismiss it.
|
||||
useEffect(() => setOpen(false), [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape') return
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
// `mousedown`, not `click`: closing on the press means a press that lands on
|
||||
// another trigger opens that one in the same gesture.
|
||||
const onOutside = (e) => {
|
||||
if (!wrapRef.current?.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onOutside)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onOutside)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Roving focus with the arrow keys, wrapping at both ends.
|
||||
const onMenuKeyDown = (e) => {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
|
||||
e.preventDefault()
|
||||
const links = [...(wrapRef.current?.querySelectorAll('[data-menu-item]') || [])]
|
||||
if (links.length === 0) return
|
||||
const at = links.indexOf(document.activeElement)
|
||||
const next = e.key === 'ArrowDown' ? (at + 1) % links.length : (at - 1 + links.length) % links.length
|
||||
links[at === -1 ? 0 : next].focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative' }} onKeyDown={onMenuKeyDown}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="pill"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
...(holdsActive || open
|
||||
? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 6px)',
|
||||
left: 0,
|
||||
minWidth: 190,
|
||||
// The header wraps, so a menu near the right edge must not push the
|
||||
// page sideways on a narrow screen.
|
||||
maxWidth: 'calc(100vw - 24px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
padding: 6,
|
||||
borderRadius: 'var(--radius-card)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--panel-flat)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.kind === 'link' ? item.id : item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
role="menuitem"
|
||||
data-menu-item=""
|
||||
onClick={() => setOpen(false)}
|
||||
className="sans"
|
||||
style={({ isActive }) => ({
|
||||
padding: '7px 10px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
fontSize: '0.85rem',
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
...linkStyle({ isActive }),
|
||||
...(isActive ? {} : { color: 'var(--muted)' }),
|
||||
})}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import MoonDot from './MoonDot.jsx'
|
||||
import BrandLogo from './BrandLogo.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
||||
import NavDropdown from './NavDropdown.jsx'
|
||||
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
|
||||
import { navFor } from '../modules/registry.js'
|
||||
import { parseJsonSetting } from '../lib/settingsJson.js'
|
||||
|
||||
// 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).
|
||||
@@ -11,7 +17,11 @@ import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
||||
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
|
||||
// viewer can't reach them, so we never render a link that would 403. The gate
|
||||
// itself is server-side; this is only about not advertising a dead end.
|
||||
const NAV = [
|
||||
//
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here,
|
||||
// with this component as its owner: the editor may only relabel, reorder and
|
||||
// hide what it finds, and `to`/`feature` are never its to change (§7).
|
||||
export const NAV = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||
@@ -24,6 +34,8 @@ const NAV = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
@@ -35,9 +47,40 @@ const linkStyle = ({ isActive }) => ({
|
||||
|
||||
export default function SiteHeader() {
|
||||
const { user, loading } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const { siteTitle, settings } = useSite()
|
||||
const shardFeatures = useShardFeatures()
|
||||
const nav = NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||
|
||||
// An admin may relabel, reorder and hide these entries from Admin →
|
||||
// Navigation, and may group them into dropdown sections alongside links of
|
||||
// their own (THEMING_AND_NAV.md §7). Two things about the order here:
|
||||
//
|
||||
// • the override merge runs FIRST and the feature filter after it, so the
|
||||
// filter stays the boundary — an override cannot un-hide a shard surface
|
||||
// this viewer may not see, whatever it says. `pruneNav` applies the same
|
||||
// check inside a section and drops one it leaves empty, so a dropdown
|
||||
// never opens onto nothing;
|
||||
// • with no stored row this is the coded NAV, in code order, so an
|
||||
// untouched instance renders exactly what it renders today.
|
||||
// Installed modules' entries interleave into this list by `order` BEFORE the
|
||||
// override merge, so an admin edits one nav rather than "core's, plus whatever
|
||||
// the module appended" — and a module item is hideable and re-labelable
|
||||
// exactly like a core one. `order` defaults high, which lands module entries
|
||||
// where the UO items already sat: after the content links, before About.
|
||||
const base = useMemo(() => {
|
||||
const items = navFor('public')
|
||||
if (items.length === 0) return NAV
|
||||
const merged = [...NAV]
|
||||
for (const item of items) {
|
||||
const at = Number.isFinite(item.order) ? item.order : merged.length
|
||||
merged.splice(Math.min(at, merged.length), 0, { label: item.label, to: item.to, feature: item.feature })
|
||||
}
|
||||
return merged
|
||||
}, [])
|
||||
|
||||
const nav = useMemo(() => {
|
||||
const tree = buildPublicNav(base, parseJsonSetting(settings.nav_public))
|
||||
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||
}, [base, settings.nav_public, shardFeatures])
|
||||
|
||||
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||
let account
|
||||
@@ -65,15 +108,20 @@ export default function SiteHeader() {
|
||||
className="display"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
|
||||
>
|
||||
<BrandLogo height={22} />
|
||||
<MoonDot />
|
||||
{siteTitle}
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{nav.map((l) => (
|
||||
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{nav.map((l) =>
|
||||
l.kind === 'section' ? (
|
||||
<NavDropdown key={l.id} label={l.label} items={l.items} linkStyle={linkStyle} />
|
||||
) : (
|
||||
<NavLink key={l.kind === 'link' ? l.id : l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
),
|
||||
)}
|
||||
{!loading && (
|
||||
<NavLink
|
||||
to={account.to}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
import { applyThemeTokens } from '../lib/themeVars.js'
|
||||
|
||||
const SiteContext = createContext(null)
|
||||
|
||||
@@ -7,11 +8,16 @@ const SiteContext = createContext(null)
|
||||
export function SiteProvider({ children }) {
|
||||
const [settings, setSettings] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Whether a fetch has actually SUCCEEDED, as distinct from `loading` — which
|
||||
// also goes false when the request failed and we fell back to {}. The boot
|
||||
// theme handoff below turns on this distinction.
|
||||
const [settled, setSettled] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.publicSettings()
|
||||
setSettings(data || {})
|
||||
setSettled(true)
|
||||
} catch {
|
||||
setSettings({})
|
||||
} finally {
|
||||
@@ -25,11 +31,38 @@ export function SiteProvider({ children }) {
|
||||
|
||||
const brand = useMemo(() => settings.brand || {}, [settings])
|
||||
|
||||
// Apply the admin's theme. The whole effective token set is resolved
|
||||
// server-side, so this only writes it and takes back what it wrote before —
|
||||
// see lib/themeVars.js for why the removal half matters. No theme block means
|
||||
// the admin never themed this instance, and the shipped :root stands.
|
||||
const appliedTokens = useRef([])
|
||||
useEffect(() => {
|
||||
appliedTokens.current = applyThemeTokens(document.documentElement.style, settings.theme, appliedTokens.current)
|
||||
// Take over from the shell's boot block. The server injects the same tokens
|
||||
// into <head> so a themed instance does not paint the shipped palette for a
|
||||
// frame first (utils/htmlShell.js); from here on this effect is the
|
||||
// authority, and leaving the block behind would mean a later reset removed
|
||||
// the inline properties only to reveal the stale block underneath.
|
||||
//
|
||||
// Gated on a SUCCESSFUL fetch, not merely a finished one: a failed request
|
||||
// leaves us with no theme at all, and dropping the block then would strip a
|
||||
// themed instance back to the shipped palette for no reason.
|
||||
if (settled) document.getElementById('theme-boot')?.remove()
|
||||
}, [settings.theme, settled])
|
||||
|
||||
// Apply the instance accent color to the CSS variable the theme is built on,
|
||||
// so branding flows to every `var(--accent)` at runtime (no rebuild).
|
||||
// so branding flows to every `var(--accent)` at runtime (no rebuild). This is
|
||||
// the *effective* accent — the admin theme overrides BRAND_ACCENT_COLOR
|
||||
// server-side (docs/website/THEMING_AND_NAV.md §4.5) — so it agrees with the
|
||||
// theme block rather than fighting it.
|
||||
//
|
||||
// Deliberately ordered after the theme effect and re-run on any theme change:
|
||||
// resetting a theme removes --accent from the token map, and this has to be
|
||||
// the write that lands last or an instance with a custom BRAND_ACCENT_COLOR
|
||||
// would drop to the stylesheet's default accent until the next reload.
|
||||
useEffect(() => {
|
||||
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
|
||||
}, [brand.accent])
|
||||
}, [brand.accent, settings.theme])
|
||||
|
||||
// Memoized so consumers don't re-render on every provider render (brand is a
|
||||
// fresh object each render, which would otherwise churn the context value).
|
||||
|
||||
502
client/src/lib/navOverrides.js
Normal file
502
client/src/lib/navOverrides.js
Normal file
@@ -0,0 +1,502 @@
|
||||
// Apply an admin's stored navigation overrides to a hardcoded NAV array.
|
||||
//
|
||||
// The three navs (public header, admin sidebar, player portal) stay declared in
|
||||
// code; this layer only reorders, relabels and hides what is already there.
|
||||
// See docs/website/THEMING_AND_NAV.md §7.
|
||||
//
|
||||
// **This is presentation, never authorization.** The override can carry
|
||||
// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else.
|
||||
// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or
|
||||
// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run
|
||||
// *after* this merge, unchanged, and remain the actual boundary. An override
|
||||
// saying `hidden: false` on a role-gated item still shows nothing to a viewer
|
||||
// whose role check fails: hiding is subtractive here, never additive.
|
||||
//
|
||||
// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string
|
||||
// label, a group that does not exist — is ignored rather than rejected, so a
|
||||
// stale or hand-edited settings row degrades to the code default instead of
|
||||
// rendering a broken nav.
|
||||
|
||||
// Two shapes are supported, because two exist:
|
||||
// flat [{ to, label, ... }] — public header, player portal
|
||||
// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
|
||||
function isGrouped(nav) {
|
||||
return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
|
||||
}
|
||||
|
||||
// A stored override entry is usable only field by field: a bad `label` must not
|
||||
// discard a good `order` alongside it.
|
||||
function cleanEntry(raw, groupTitles) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const out = {}
|
||||
if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim()
|
||||
if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order
|
||||
if (raw.hidden === true) out.hidden = true
|
||||
// `group` may only name a section the base nav already declares. Anything else
|
||||
// — a renamed group, a typo, an invented category — is dropped, so an item can
|
||||
// never land in a header that does not exist.
|
||||
if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group
|
||||
return out
|
||||
}
|
||||
|
||||
// Sort by effective order, where an item the admin never reordered keeps its
|
||||
// index in the base array as its key. Two tie-breaks, in order: an explicit
|
||||
// order beats a coincidental index (the admin said "first", so first), and two
|
||||
// explicit orders stay in code order (the sort is stable).
|
||||
//
|
||||
// In practice the editor writes an order for every item in a list, the way
|
||||
// drag-and-drop reordering does, so ties are the stale-row case rather than the
|
||||
// normal one. They still have to resolve predictably.
|
||||
function byOrder(items) {
|
||||
return items
|
||||
.map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
|
||||
.map(({ item }) => {
|
||||
const { __order, ...rest } = item
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
// Apply label/hidden/order to one flat list, with the sort key parked on
|
||||
// `__order` for byOrder to consume.
|
||||
//
|
||||
// `keepHidden` is what the admin editor needs and the site must not have: the
|
||||
// editor has to render a hidden row in its right place so it can be un-hidden,
|
||||
// while a layout must simply not render it. Same merge either way, so the two
|
||||
// can never disagree about where an item sits.
|
||||
function mergeItems(items, entries, keepHidden = false) {
|
||||
const out = []
|
||||
for (const item of items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.hidden && !keepHidden) continue
|
||||
// Spread the base item first so `to`, `roles`, `feature`, `icon` and `end`
|
||||
// survive verbatim — the override only ever lands on `label`.
|
||||
out.push({
|
||||
...item,
|
||||
...(o?.label ? { label: o.label } : {}),
|
||||
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
|
||||
__order: o?.order,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The stored overrides, cleaned and keyed, plus the group titles the base nav
|
||||
// declares. Shared by the merge and the editor so both read a row the same way.
|
||||
function readOverrides(baseNav, overrides, grouped) {
|
||||
const groupTitles = new Set(
|
||||
grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [],
|
||||
)
|
||||
const entries = new Map()
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return { entries, groupTitles }
|
||||
// Keyed by `to`, and only for a `to` the base nav actually declares. An
|
||||
// override for a route that no longer exists is dropped here, so deleting a
|
||||
// route in code can never leave a dangling override that does something
|
||||
// unexpected later.
|
||||
const known = new Set(
|
||||
grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to),
|
||||
)
|
||||
for (const [to, raw] of Object.entries(overrides)) {
|
||||
if (!known.has(to)) continue
|
||||
const entry = cleanEntry(raw, groupTitles)
|
||||
if (entry && Object.keys(entry).length > 0) entries.set(to, entry)
|
||||
}
|
||||
return { entries, groupTitles }
|
||||
}
|
||||
|
||||
// Move items whose override names a different existing section. Groups keep
|
||||
// their coded order — only membership and within-group order move.
|
||||
function regroup(baseNav, entries) {
|
||||
const moved = new Map() // destination title → items pulled in from elsewhere
|
||||
const kept = baseNav.map((g) => {
|
||||
const items = []
|
||||
for (const item of g.items) {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.group && o.group !== g.title) {
|
||||
if (!moved.has(o.group)) moved.set(o.group, [])
|
||||
moved.get(o.group).push(item)
|
||||
continue
|
||||
}
|
||||
items.push(item)
|
||||
}
|
||||
return { ...g, items }
|
||||
})
|
||||
return { kept, moved }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array} baseNav the hardcoded nav — the source of truth for `to`,
|
||||
* `roles`, `feature`, `icon` and `end`
|
||||
* @param {object|null} overrides the parsed settings JSON, keyed by `to`, or
|
||||
* null when the admin never touched this nav
|
||||
* @returns {Array} a new array of the same shape, or `baseNav` itself when there
|
||||
* is nothing to apply
|
||||
*/
|
||||
export function applyNavOverrides(baseNav, overrides) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
// The untouched path, and the one that matters most: no row, a malformed row,
|
||||
// or a row with nothing usable in it all render the nav exactly as coded.
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav
|
||||
|
||||
const grouped = isGrouped(baseNav)
|
||||
const { entries } = readOverrides(baseNav, overrides, grouped)
|
||||
if (entries.size === 0) return baseNav
|
||||
|
||||
if (!grouped) return byOrder(mergeItems(baseNav, entries))
|
||||
|
||||
// Grouped: an item may also be moved into another *existing* titled section.
|
||||
const { kept, moved } = regroup(baseNav, entries)
|
||||
|
||||
return kept
|
||||
.map((g) => ({
|
||||
...g,
|
||||
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)),
|
||||
}))
|
||||
// A group whose every item was hidden must not leave an orphaned header.
|
||||
// AdminLayout drops empty groups again after its own role filter; doing it
|
||||
// here too keeps the util correct on its own.
|
||||
.filter((g) => g.items.length > 0)
|
||||
}
|
||||
|
||||
// ── The admin editor's round trip ────────────────────────────────────────
|
||||
//
|
||||
// Two functions, inverse to each other, kept in this file rather than beside the
|
||||
// editor screen so the thing that *writes* an override and the thing that
|
||||
// *applies* one can never drift: the rows the admin drags are produced by the
|
||||
// same merge the site renders, hidden ones included.
|
||||
|
||||
/**
|
||||
* The base nav plus its stored overrides, as editable rows — always in the
|
||||
* grouped shape, so one editor handles both navs.
|
||||
*
|
||||
* Unlike applyNavOverrides this keeps hidden rows (marked `hidden: true`, so
|
||||
* they can be un-hidden) and keeps empty groups (so something can be moved back
|
||||
* into one). Each row carries `defaultLabel`, which is what "reset this label"
|
||||
* restores and what the input shows as its placeholder.
|
||||
*
|
||||
* @param {Array} baseNav the hardcoded nav, flat or grouped
|
||||
* @param {object|null} overrides the parsed settings JSON
|
||||
* @returns {Array<{title: string|null, items: Array}>}
|
||||
*/
|
||||
export function buildNavRows(baseNav, overrides) {
|
||||
if (!Array.isArray(baseNav) || baseNav.length === 0) return []
|
||||
const grouped = isGrouped(baseNav)
|
||||
const { entries } = readOverrides(baseNav, overrides, grouped)
|
||||
|
||||
if (!grouped) {
|
||||
return [{ title: null, items: byOrder(mergeItems(baseNav, entries, true)) }]
|
||||
}
|
||||
const { kept, moved } = regroup(baseNav, entries)
|
||||
return kept.map((g) => ({
|
||||
...g,
|
||||
title: g.title ?? null,
|
||||
items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries, true)),
|
||||
}))
|
||||
}
|
||||
|
||||
// Did the admin actually move anything? Comparing the edited sequence with the
|
||||
// coded one is what decides whether orders are written at all: an admin who only
|
||||
// renamed an item should not pin the position of every other one, or a route
|
||||
// added in code later would land in an arbitrary place.
|
||||
//
|
||||
// The base side is restricted to the rows the editor is actually holding: §8.1
|
||||
// filters the palette to what this admin can themselves see, and an item that
|
||||
// their role or a shard feature kept off the screen is not a reorder.
|
||||
function orderMatchesBase(groups, baseNav) {
|
||||
const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
|
||||
const base = isGrouped(baseNav)
|
||||
? baseNav.map((g) => ({ title: g.title ?? null, items: g.items }))
|
||||
: [{ title: null, items: baseNav }]
|
||||
const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
|
||||
const a = flatten(groups)
|
||||
const b = flatten(base.map((g) => ({ ...g, items: g.items.filter((i) => shown.has(i.to)) })))
|
||||
return a.length === b.length && a.every((v, i) => v === b[i])
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows the admin has been editing, back as an overrides object to store.
|
||||
* Only differences from the code default are written — a field that matches the
|
||||
* default is absent, so the row stays a small statement of intent rather than a
|
||||
* snapshot of the nav.
|
||||
*
|
||||
* @param {Array} groups the editor's groups, in their current order
|
||||
* @param {Array} baseNav the hardcoded nav these rows came from
|
||||
* @param {object|null} stored the overrides as loaded, so entries for items
|
||||
* this admin could not see (role- or feature-gated out of their palette) are
|
||||
* carried through rather than silently dropped on save
|
||||
* @returns {object} the overrides to store — `{}` when nothing differs
|
||||
*/
|
||||
export function buildNavOverrides(groups, baseNav, stored = null) {
|
||||
if (!Array.isArray(groups) || !Array.isArray(baseNav)) return {}
|
||||
const grouped = isGrouped(baseNav)
|
||||
const baseItems = new Map(
|
||||
(grouped ? baseNav.flatMap((g) => g.items.map((i) => [i, g.title ?? null])) : baseNav.map((i) => [i, null])).map(
|
||||
([item, title]) => [item.to, { label: item.label, group: title }],
|
||||
),
|
||||
)
|
||||
|
||||
const out = {}
|
||||
// Carry through what this admin's palette never showed them. An entry for a
|
||||
// `to` the base nav no longer declares is NOT carried: dropping it is the
|
||||
// cleanup, and applyNavOverrides ignores it anyway.
|
||||
const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
|
||||
if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
|
||||
for (const [to, entry] of Object.entries(stored)) {
|
||||
if (!shown.has(to) && baseItems.has(to) && entry && typeof entry === 'object') out[to] = entry
|
||||
}
|
||||
}
|
||||
|
||||
const writeOrder = !orderMatchesBase(groups, baseNav)
|
||||
for (const group of groups) {
|
||||
group.items.forEach((row, index) => {
|
||||
const base = baseItems.get(row.to)
|
||||
if (!base) return
|
||||
const entry = {}
|
||||
const label = typeof row.label === 'string' ? row.label.trim() : ''
|
||||
if (label && label !== base.label) entry.label = label
|
||||
if (row.hidden === true) entry.hidden = true
|
||||
if (grouped && (group.title ?? null) !== base.group && group.title) entry.group = group.title
|
||||
if (writeOrder) entry.order = index
|
||||
if (Object.keys(entry).length > 0) out[row.to] = entry
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── The public header: dropdown sections and added links ────────────────
|
||||
//
|
||||
// Phase 10. The public nav is the one nav an admin can restructure rather than
|
||||
// only reorder: they may create dropdown **sections**, drop coded entries into
|
||||
// them, and add **links** of their own to pages on this site.
|
||||
//
|
||||
// The invariant §7 rests on survives, and it survives structurally rather than
|
||||
// by vigilance: coded entries stay keyed by a `to` the base array must declare,
|
||||
// so an override still cannot invent a route or touch a `roles`/`feature` gate,
|
||||
// while everything that CAN name an arbitrary path lives in `links` where the
|
||||
// path rule is applied. An added link carries no gate of its own and needs none
|
||||
// — the page behind it enforces its own access, so a link to somewhere the
|
||||
// viewer cannot reach 403s exactly as typing the URL would.
|
||||
//
|
||||
// Stored shape (server/src/utils/navOverrides.js is the writer):
|
||||
// { items: {"<to>": {...}}, sections: [{id,label,order}], links: [{id,label,to,order,section}] }
|
||||
// A bare map is still read as the items map — unambiguous, because every item
|
||||
// key is a path and so can never be the string `items`.
|
||||
|
||||
function unwrapPublic(overrides) {
|
||||
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
|
||||
return { items: {}, sections: [], links: [] }
|
||||
}
|
||||
const wrapped = overrides.items && typeof overrides.items === 'object' && !Array.isArray(overrides.items)
|
||||
const items = wrapped ? overrides.items : overrides
|
||||
const sections = wrapped && Array.isArray(overrides.sections) ? overrides.sections : []
|
||||
const links = wrapped && Array.isArray(overrides.links) ? overrides.links : []
|
||||
return { items, sections, links }
|
||||
}
|
||||
|
||||
// Forgiving, like every other read here: an entry that is not usable is dropped
|
||||
// and its neighbours kept.
|
||||
function readSections(sections) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const s of sections) {
|
||||
if (!s || typeof s !== 'object' || typeof s.id !== 'string' || seen.has(s.id)) continue
|
||||
if (typeof s.label !== 'string' || !s.label.trim()) continue
|
||||
seen.add(s.id)
|
||||
out.push({ id: s.id, label: s.label.trim(), order: typeof s.order === 'number' && Number.isFinite(s.order) ? s.order : undefined })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function readLinks(links, knownSections) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const l of links) {
|
||||
if (!l || typeof l !== 'object' || typeof l.id !== 'string' || seen.has(l.id)) continue
|
||||
if (typeof l.label !== 'string' || !l.label.trim()) continue
|
||||
// Same rule the server writes by. A stored value that would leave the origin
|
||||
// is dropped rather than rendered, so a hand-edited row cannot put an
|
||||
// off-site link in the header.
|
||||
if (typeof l.to !== 'string' || !l.to.startsWith('/') || l.to.startsWith('//') || /[\s<>"'\\]/.test(l.to)) continue
|
||||
seen.add(l.id)
|
||||
out.push({
|
||||
id: l.id,
|
||||
label: l.label.trim(),
|
||||
to: l.to,
|
||||
order: typeof l.order === 'number' && Number.isFinite(l.order) ? l.order : undefined,
|
||||
section: typeof l.section === 'string' && knownSections.has(l.section) ? l.section : null,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The public nav as a one-level tree of `{kind: 'item' | 'link' | 'section'}`.
|
||||
*
|
||||
* @param {Array} baseNav the hardcoded public NAV — still the only source of
|
||||
* `to`, `feature` and `end` for a coded entry
|
||||
* @param {object|null} overrides the parsed nav_public row
|
||||
* @param {{keepHidden?: boolean}} [opts] the editor keeps hidden entries so
|
||||
* they can be un-hidden, and gets `defaultLabel` for the reset affordance;
|
||||
* the header must not render them at all
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {}) {
|
||||
if (!Array.isArray(baseNav)) return []
|
||||
const { items, sections: rawSections, links: rawLinks } = unwrapPublic(overrides)
|
||||
|
||||
const sections = readSections(rawSections)
|
||||
const knownSections = new Set(sections.map((s) => s.id))
|
||||
const links = readLinks(rawLinks, knownSections)
|
||||
|
||||
// Coded entries, keyed by a `to` the base array declares. Anything else in the
|
||||
// map is dropped here, exactly as in applyNavOverrides.
|
||||
const known = new Set(baseNav.map((i) => i.to))
|
||||
const entries = new Map()
|
||||
for (const [to, raw] of Object.entries(items)) {
|
||||
if (!known.has(to)) continue
|
||||
const entry = cleanEntry(raw, new Set())
|
||||
if (!entry) continue
|
||||
if (typeof raw?.section === 'string' && knownSections.has(raw.section)) entry.section = raw.section
|
||||
entries.set(to, entry)
|
||||
}
|
||||
|
||||
const nodes = []
|
||||
baseNav.forEach((item, index) => {
|
||||
const o = entries.get(item.to)
|
||||
if (o?.hidden && !keepHidden) return
|
||||
nodes.push({
|
||||
kind: 'item',
|
||||
...item,
|
||||
...(o?.label ? { label: o.label } : {}),
|
||||
...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
|
||||
section: o?.section ?? null,
|
||||
__order: o?.order,
|
||||
__index: index,
|
||||
})
|
||||
})
|
||||
// An admin-created entity with no stored order appends after the coded ones,
|
||||
// in creation order, rather than jumping to the front on a 0 default.
|
||||
let next = baseNav.length
|
||||
for (const section of sections) {
|
||||
nodes.push({ kind: 'section', id: section.id, label: section.label, section: null, __order: section.order, __index: next++ })
|
||||
}
|
||||
for (const link of links) {
|
||||
nodes.push({ kind: 'link', id: link.id, to: link.to, label: link.label, section: link.section, __order: link.order, __index: next++ })
|
||||
}
|
||||
|
||||
const place = (list) =>
|
||||
list
|
||||
.map((n) => ({ n, key: n.__order ?? n.__index, explicit: n.__order !== undefined }))
|
||||
.sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
|
||||
.map(({ n }) => {
|
||||
const { __order, __index, section, ...rest } = n
|
||||
return rest
|
||||
})
|
||||
|
||||
const top = place(nodes.filter((n) => n.kind === 'section' || !n.section))
|
||||
return top.map((node) =>
|
||||
node.kind === 'section'
|
||||
? { ...node, items: place(nodes.filter((n) => n.section === node.id)) }
|
||||
: node,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the caller's visibility gate — and drop a section it leaves empty.
|
||||
*
|
||||
* Kept here rather than in SiteHeader because the empty-dropdown case is the one
|
||||
* with real correctness risk: a section whose every entry is hidden by shard
|
||||
* visibility must not render as a menu that opens onto nothing. The predicate
|
||||
* stays the caller's, so this module still knows nothing about shard features.
|
||||
*
|
||||
* Added links carry no gate, so they are always visible — see the note above.
|
||||
*
|
||||
* @param {Array} tree from buildPublicNav
|
||||
* @param {(item: object) => boolean} isVisible applied to coded items only
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function pruneNav(tree, isVisible) {
|
||||
if (!Array.isArray(tree)) return []
|
||||
const keep = (node) => node.kind !== 'item' || isVisible(node)
|
||||
return tree
|
||||
.map((node) => (node.kind === 'section' ? { ...node, items: (node.items || []).filter(keep) } : node))
|
||||
.filter((node) => (node.kind === 'section' ? node.items.length > 0 : keep(node)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's tree back as a nav_public value to store.
|
||||
*
|
||||
* Returns the **bare items map** when there are no sections and no added links,
|
||||
* so a nav that does not use this feature stores exactly what phases 6-8 stored.
|
||||
*
|
||||
* @param {Array} tree the editor's current tree
|
||||
* @param {Array} baseNav the hardcoded public NAV
|
||||
* @param {object|null} stored as loaded, so an entry for a feature-gated item
|
||||
* this admin could not see survives their save
|
||||
* @returns {object} `{}` when nothing differs from the code default
|
||||
*/
|
||||
export function buildPublicNavOverrides(tree, baseNav, stored = null) {
|
||||
if (!Array.isArray(tree) || !Array.isArray(baseNav)) return {}
|
||||
const baseLabels = new Map(baseNav.map((i) => [i.to, i.label]))
|
||||
const sections = []
|
||||
const links = []
|
||||
const items = {}
|
||||
|
||||
// Flatten to (node, containerId, indexInContainer), which is all the writer
|
||||
// needs: a section's own position is its index in the top-level list.
|
||||
const placed = []
|
||||
tree.forEach((node, index) => {
|
||||
placed.push({ node, section: null, index })
|
||||
if (node.kind === 'section') (node.items || []).forEach((child, i) => placed.push({ node: child, section: node.id, index: i }))
|
||||
})
|
||||
|
||||
// Orders are written whenever this nav has any structure of its own: a section
|
||||
// exists only because the admin put it somewhere, so its position is never
|
||||
// "whatever the code says". Without sections the rule is phase 6-8's — write
|
||||
// orders only if the sequence actually moved.
|
||||
const hasStructure = tree.some((n) => n.kind === 'section' || n.kind === 'link')
|
||||
const shown = new Set(tree.flatMap((n) => (n.kind === 'section' ? (n.items || []) : [n])).filter((n) => n.kind === 'item').map((n) => n.to))
|
||||
const sequence = tree.filter((n) => n.kind === 'item').map((n) => n.to)
|
||||
const baseSequence = baseNav.filter((i) => shown.has(i.to)).map((i) => i.to)
|
||||
const moved = sequence.length !== baseSequence.length || sequence.some((to, i) => to !== baseSequence[i])
|
||||
const writeOrder = hasStructure || moved
|
||||
|
||||
for (const { node, section, index } of placed) {
|
||||
if (node.kind === 'section') {
|
||||
sections.push({ id: node.id, label: (node.label || '').trim() || 'Section', ...(writeOrder ? { order: index } : {}) })
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'link') {
|
||||
links.push({
|
||||
id: node.id,
|
||||
label: (node.label || '').trim() || node.to,
|
||||
to: node.to,
|
||||
...(section ? { section } : {}),
|
||||
...(writeOrder ? { order: index } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const entry = {}
|
||||
const label = typeof node.label === 'string' ? node.label.trim() : ''
|
||||
if (label && label !== baseLabels.get(node.to)) entry.label = label
|
||||
if (node.hidden === true) entry.hidden = true
|
||||
if (section) entry.section = section
|
||||
if (writeOrder) entry.order = index
|
||||
if (Object.keys(entry).length > 0) items[node.to] = entry
|
||||
}
|
||||
|
||||
// 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.
|
||||
const { items: storedItems } = unwrapPublic(stored)
|
||||
for (const [to, entry] of Object.entries(storedItems)) {
|
||||
if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry
|
||||
}
|
||||
|
||||
if (sections.length === 0 && links.length === 0) return items
|
||||
const out = { items }
|
||||
if (sections.length) out.sections = sections
|
||||
if (links.length) out.links = links
|
||||
return out
|
||||
}
|
||||
|
||||
export default applyNavOverrides
|
||||
32
client/src/lib/settingsJson.js
Normal file
32
client/src/lib/settingsJson.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Parse a JSON-valued settings row, client side.
|
||||
//
|
||||
// The counterpart to server/src/utils/settingsJson.js, and deliberately the same
|
||||
// three lines of judgement: `settings.value` is TEXT, so theme_visual,
|
||||
// brand_assets and the three nav_* keys all arrive as strings, and a malformed
|
||||
// or wrong-shaped one must read as **absent** — the surface falls back to its
|
||||
// BRAND_* env / theme.css / hardcoded NAV default — never as an error and never
|
||||
// as a half-applied object.
|
||||
//
|
||||
// THEMING_AND_NAV.md §4.4 planned this "with its first consumer"; that consumer
|
||||
// is the public header reading nav_public. `parseLayout` in heroLayout.js keeps
|
||||
// its own version check because it validates a shape, not just a shape's kind.
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} str the raw stored value
|
||||
* @returns {object|null} the parsed object, or null when absent/malformed
|
||||
*/
|
||||
export function parseJsonSetting(str) {
|
||||
if (typeof str !== 'string' || str === '') return null
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(str)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
|
||||
// every consumer of these keys as a syntax error is.
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
export default parseJsonSetting
|
||||
47
client/src/lib/themeVars.js
Normal file
47
client/src/lib/themeVars.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// Apply the server-resolved theme to the document as CSS custom properties.
|
||||
//
|
||||
// The effective token set is resolved server-side and arrives on
|
||||
// `settings.theme` (see server/src/utils/themeResolve.js). The client's only
|
||||
// job is to write it onto <html> — and, crucially, to take back what it wrote
|
||||
// last time, which is the part with actual logic and the reason this lives in
|
||||
// its own testable module.
|
||||
//
|
||||
// Why removal matters: an admin who resets the theme, or switches from a preset
|
||||
// that sets --bg to one that does not, gets a payload that no longer mentions
|
||||
// that variable. Inline properties are not cleared by writing a smaller object
|
||||
// over them, so without an explicit removeProperty the old value would stick
|
||||
// until a reload. That would make "Reset to defaults" look broken.
|
||||
//
|
||||
// Everything written here is a value the server validated against a closed set
|
||||
// (hex color, curated font stack, bounded px length, listed shadow). The client
|
||||
// deliberately does not re-validate — it would be a second, drifting authority.
|
||||
// It does refuse anything that is not a `--custom-property`, which is the one
|
||||
// check that costs nothing and stops a token map from reaching an ordinary CSS
|
||||
// property.
|
||||
|
||||
const CUSTOM_PROPERTY = /^--[a-zA-Z0-9-_]+$/
|
||||
|
||||
/**
|
||||
* @param {CSSStyleDeclaration} style usually document.documentElement.style
|
||||
* @param {Record<string, string>|null|undefined} tokens the new theme, or
|
||||
* null/absent for "no admin theme" — which clears everything previously set
|
||||
* @param {string[]} [applied] the keys this function wrote last time
|
||||
* @returns {string[]} the keys now applied, to pass back on the next call
|
||||
*/
|
||||
export function applyThemeTokens(style, tokens, applied = []) {
|
||||
const next = []
|
||||
if (tokens && typeof tokens === 'object') {
|
||||
for (const [name, value] of Object.entries(tokens)) {
|
||||
if (!CUSTOM_PROPERTY.test(name) || typeof value !== 'string' || value === '') continue
|
||||
style.setProperty(name, value)
|
||||
next.push(name)
|
||||
}
|
||||
}
|
||||
// Take back only what we set ourselves. Anything else on the element's inline
|
||||
// style belongs to someone else (SiteContext's own --accent line, a future
|
||||
// feature) and is not ours to clear.
|
||||
for (const name of applied) {
|
||||
if (!next.includes(name)) style.removeProperty(name)
|
||||
}
|
||||
return next
|
||||
}
|
||||
56
client/src/lib/useNavOverrides.js
Normal file
56
client/src/lib/useNavOverrides.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client.js'
|
||||
import { parseJsonSetting } from './settingsJson.js'
|
||||
|
||||
// The nav overrides for the two authenticated layouts (THEMING_AND_NAV.md §4.2).
|
||||
//
|
||||
// `nav_public` rides along in the public settings payload, but `nav_admin` and
|
||||
// `nav_player` deliberately do not: an anonymous visitor has no use for either,
|
||||
// and the admin nav's labels describe the shape of the admin surface. Their
|
||||
// owners read them from GET /api/v1/settings/nav, which any signed-in account
|
||||
// may call — AdminLayout renders for editors and moderators, who cannot reach
|
||||
// GET /admin/settings at all.
|
||||
//
|
||||
// Failing quiet is the whole posture: a request that errors, a malformed row and
|
||||
// "not fetched yet" are the same state to the caller, `{}`, which
|
||||
// applyNavOverrides turns into the coded nav. A sidebar must never blink empty
|
||||
// because a settings call was slow.
|
||||
|
||||
// One module-level copy, so the second layout to mount renders the nav it
|
||||
// already knows rather than flashing the coded one, and so the nav editor can
|
||||
// push its save into the sidebar the admin is looking at without a reload.
|
||||
let cache = {}
|
||||
const subscribers = new Set()
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.navSettings()
|
||||
cache = {
|
||||
nav_admin: parseJsonSetting(data?.nav_admin),
|
||||
nav_player: parseJsonSetting(data?.nav_player),
|
||||
}
|
||||
subscribers.forEach((fn) => fn(cache))
|
||||
} catch {
|
||||
/* the coded nav is the fallback, and it is already on screen */
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
/** Re-read the rows after a save, so the live sidebar catches up at once. */
|
||||
export function refreshNavOverrides() {
|
||||
return load()
|
||||
}
|
||||
|
||||
export function useNavOverrides() {
|
||||
const [overrides, setOverrides] = useState(cache)
|
||||
|
||||
useEffect(() => {
|
||||
subscribers.add(setOverrides)
|
||||
load()
|
||||
return () => subscribers.delete(setOverrides)
|
||||
}, [])
|
||||
|
||||
return overrides
|
||||
}
|
||||
|
||||
export default useNavOverrides
|
||||
@@ -2,12 +2,37 @@ import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import './styles/theme.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||
// Installed modules are `<script type="module" src="/modules/<id>/entry.js">`
|
||||
// tags the server injects into <head> (server/src/utils/htmlShell.js); module
|
||||
// scripts are deferred, so they run after this bundle and resolve their
|
||||
// externals against the global this call sets up.
|
||||
publishSharedDependencies()
|
||||
|
||||
// Render after DOMContentLoaded rather than immediately.
|
||||
//
|
||||
// Deferred scripts execute in document order and all of them finish before
|
||||
// DOMContentLoaded fires. Waiting for that event is therefore the guarantee that
|
||||
// every installed module has finished registering its routes and nav before
|
||||
// React reads the registry — no loading state, no re-render, no ordering race
|
||||
// between core's bundle and a module's. If this bundle happens to evaluate after
|
||||
// the event has already fired (a cached, fast path), readyState is checked and
|
||||
// render runs at once.
|
||||
function mount() {
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mount, { once: true })
|
||||
} else {
|
||||
mount()
|
||||
}
|
||||
|
||||
100
client/src/modules/registry.js
Normal file
100
client/src/modules/registry.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// ── The client-side module registry ────────────────────────────────────────
|
||||
//
|
||||
// A module's prebuilt chunk registers its routes, nav entries and feature
|
||||
// provider here, and App.jsx / the nav components read them back. This is the
|
||||
// client half of docs/website/MODULE_API.md §3.3.
|
||||
//
|
||||
// Timing is the whole design. Module chunks are `<script type="module" src>`
|
||||
// tags injected into <head> by the server (utils/htmlShell.js). Module scripts
|
||||
// are deferred, so they evaluate after the SPA's own bundle has run — which is
|
||||
// where window.__rg is published — and before DOMContentLoaded. main.jsx waits
|
||||
// for that same event before calling render(), so registration is complete
|
||||
// before React reads any of this and there is no re-render to orchestrate.
|
||||
//
|
||||
// Registration is therefore a plain synchronous write with no subscribers, not
|
||||
// an observable store. If that ever changes, it changes here and not in twelve
|
||||
// consumers.
|
||||
|
||||
const routes = { public: [], admin: [], player: [] }
|
||||
const nav = { public: [], admin: [], player: [] }
|
||||
const featureProviders = new Map()
|
||||
const registered = new Set()
|
||||
|
||||
const AREAS = ['public', 'admin', 'player']
|
||||
|
||||
function assertArea(area, call) {
|
||||
if (!AREAS.includes(area)) throw new Error(`${call}: unknown area "${area}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route components for one area.
|
||||
* @param {string} id the module id, used to namespace the URL segment
|
||||
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
|
||||
* each entry `{ path, element, gate? }`; `path` is relative to the module's
|
||||
* namespace and core prefixes it (`/uo/…`, `/admin/uo/…`, `/player/uo/…`)
|
||||
*/
|
||||
export function registerRoutes(id, byArea) {
|
||||
for (const [area, list] of Object.entries(byArea || {})) {
|
||||
assertArea(area, 'registerRoutes')
|
||||
for (const route of list) {
|
||||
// Prefixed here rather than by the module, so a module cannot claim a path
|
||||
// outside its own namespace however it spells `path`.
|
||||
const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
|
||||
routes[area].push({ ...route, path, moduleId: id })
|
||||
}
|
||||
}
|
||||
registered.add(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Nav entries, interleaved into CORE groups rather than appended as a block —
|
||||
* today's UO items sit inside core's Moderation and System groups, and a "UO"
|
||||
* group at the bottom would be a visible regression (MODULE_SYSTEM.md §1.4).
|
||||
* @param {string} id
|
||||
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec
|
||||
*/
|
||||
export function registerNav(id, spec) {
|
||||
const { area, items } = spec || {}
|
||||
assertArea(area, 'registerNav')
|
||||
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
|
||||
}
|
||||
|
||||
/**
|
||||
* The hook that answers "which of this module's features may this viewer see".
|
||||
* Core keeps a generic flag context and owns none of the semantics; with no
|
||||
* module installed the nav filter is a correct no-op, because no core nav item
|
||||
* carries a `feature` today (MODULE_SYSTEM.md §1.5).
|
||||
*/
|
||||
export function registerFeatureProvider(id, namespace, hook) {
|
||||
featureProviders.set(namespace, { id, hook })
|
||||
}
|
||||
|
||||
export const routesFor = (area) => routes[area] || []
|
||||
|
||||
// Sorted by the `order` a module asked for, stable within equal orders so two
|
||||
// modules registering the same slot stay in load (alphabetical id) order.
|
||||
export const navFor = (area) =>
|
||||
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
||||
|
||||
export const featureProviderFor = (namespace) => featureProviders.get(namespace)
|
||||
export const registeredIds = () => [...registered]
|
||||
|
||||
// Test seam.
|
||||
export function _reset() {
|
||||
for (const area of AREAS) {
|
||||
routes[area].length = 0
|
||||
nav[area].length = 0
|
||||
}
|
||||
featureProviders.clear()
|
||||
registered.clear()
|
||||
}
|
||||
|
||||
export const registry = {
|
||||
registerRoutes,
|
||||
registerNav,
|
||||
registerFeatureProvider,
|
||||
routesFor,
|
||||
navFor,
|
||||
featureProviderFor,
|
||||
registeredIds,
|
||||
}
|
||||
65
client/src/modules/shared.js
Normal file
65
client/src/modules/shared.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// ── window.__rg — the shared-dependency global ─────────────────────────────
|
||||
//
|
||||
// A module's client half is a PREBUILT ESM chunk (the operator never builds
|
||||
// anything), served same-origin, and loaded under `script-src 'self'` with no
|
||||
// 'unsafe-inline'. That combination is what rules out an import map: an import
|
||||
// map has to be an inline <script type="importmap">, and CSP forbids it
|
||||
// (MODULE_SYSTEM.md §1.14). So the shared dependencies ride on a global and the
|
||||
// module's externals resolve against it — docs/website/MODULE_API.md §3.2.
|
||||
//
|
||||
// There is exactly ONE React in the page and core owns it. A module that bundled
|
||||
// its own would get a second hook dispatcher and fail at the first useState.
|
||||
|
||||
import * as react from 'react'
|
||||
import * as reactDom from 'react-dom/client'
|
||||
import * as router from 'react-router-dom'
|
||||
// The automatic JSX runtime. Without this a module would have to build with
|
||||
// `jsxRuntime: 'classic'` — its bundler emits `react/jsx-runtime` imports by
|
||||
// default, and those have to resolve to CORE's React like every other one.
|
||||
// Exposing it here is what lets a module use the modern default.
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
|
||||
import { registry } from './registry.js'
|
||||
import { MODULE_API_VERSION } from './version.js'
|
||||
|
||||
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 { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import { request, ApiError } from '../api/client.js'
|
||||
|
||||
// The kit is CURATED AND CLOSED, not a re-export of components/ — see §3.4.
|
||||
// Adding to it is a minor MODULE_API_VERSION bump; changing a member's props is
|
||||
// a major one. That is a real constraint on core, and it is the price of module
|
||||
// pages looking like the site they are installed in.
|
||||
const ui = {
|
||||
PublicLayout,
|
||||
PageHeader,
|
||||
Loading,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
}
|
||||
|
||||
// The request PRIMITIVE, not the api object: api.atlas and api.shard are module
|
||||
// bindings that live in core's client today and move out with the module (§3.5).
|
||||
// A module owns the paths it calls, which is right — it owns the routes at the
|
||||
// other end.
|
||||
const api = { request, ApiError }
|
||||
|
||||
export function publishSharedDependencies() {
|
||||
window.__rg = Object.freeze({
|
||||
version: MODULE_API_VERSION,
|
||||
react,
|
||||
reactDom,
|
||||
router,
|
||||
jsxRuntime,
|
||||
registry,
|
||||
ui: Object.freeze(ui),
|
||||
api: Object.freeze(api),
|
||||
})
|
||||
}
|
||||
8
client/src/modules/version.js
Normal file
8
client/src/modules/version.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// The client's copy of MODULE_API_VERSION. Must equal the server's
|
||||
// (server/src/modules/version.js) — they version ONE contract, and a module
|
||||
// checks whichever half it is talking to.
|
||||
//
|
||||
// Duplicated rather than fetched: the value has to be on window.__rg before the
|
||||
// first module script evaluates, and that is earlier than any network round trip.
|
||||
// A test asserts the two files agree.
|
||||
export const MODULE_API_VERSION = '1.0.0'
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||
|
||||
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
||||
// One shared frame keeps them terse; each item just supplies its path(s).
|
||||
@@ -38,13 +41,19 @@ const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><p
|
||||
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
|
||||
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
|
||||
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
|
||||
|
||||
// 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`
|
||||
// (when present) matches server-side enforcement so the sidebar never shows a
|
||||
// link that would 403; an item without `roles` is visible to everyone.
|
||||
// Moderators are further confined to just their section + account (see below).
|
||||
const NAV = [
|
||||
//
|
||||
// Exported because Admin -> Navigation edits this list. It stays declared here:
|
||||
// the editor may relabel, reorder, hide and regroup, and `roles` is never its to
|
||||
// touch (§7) — navItemVisibleTo below is the filter that still decides.
|
||||
export const NAV = [
|
||||
{
|
||||
items: [
|
||||
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
|
||||
@@ -74,11 +83,14 @@ const NAV = [
|
||||
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
||||
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
||||
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
||||
{ to: '/admin/appearance', label: 'Appearance', icon: IconPalette, roles: ['admin'] },
|
||||
{ to: '/admin/navigation', label: 'Navigation', icon: IconNav, roles: ['admin'] },
|
||||
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
||||
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] },
|
||||
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
@@ -92,6 +104,35 @@ const NAV = [
|
||||
|
||||
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
||||
|
||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||
// own account security.
|
||||
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||
|
||||
// The one row an override may never hide: the nav editor itself, which is the
|
||||
// only screen that can un-hide anything. The write path already refuses it
|
||||
// (server/src/utils/navOverrides.js) and the editor's own toggle is disabled —
|
||||
// this is the third guard, and the one that also covers a row edited straight
|
||||
// in the database. Cheap, and it makes "cannot be hidden" true without
|
||||
// qualification.
|
||||
const UNHIDEABLE = '/admin/navigation'
|
||||
|
||||
function keepEditorReachable(overrides) {
|
||||
const entry = overrides?.[UNHIDEABLE]
|
||||
if (!entry || entry.hidden !== true) return overrides
|
||||
const { hidden, ...rest } = entry
|
||||
return { ...overrides, [UNHIDEABLE]: rest }
|
||||
}
|
||||
|
||||
// Who may see a sidebar row. The single authority for that question: the layout
|
||||
// applies it after the override merge (overrides are presentation, this is the
|
||||
// boundary — §7), and Admin -> Navigation applies it to build its palette, so an
|
||||
// admin is never offered a row they cannot themselves see (§8.1).
|
||||
export function navItemVisibleTo(item, role) {
|
||||
if (item.roles && !item.roles.includes(role)) return false
|
||||
if (role === 'moderator') return MOD_PATHS.includes(item.to)
|
||||
return true
|
||||
}
|
||||
|
||||
const TITLES = {
|
||||
'/admin': 'Dashboard',
|
||||
'/admin/posts': 'Posts',
|
||||
@@ -103,11 +144,14 @@ const TITLES = {
|
||||
'/admin/shard-ops': 'In-Game Ops',
|
||||
'/admin/houses': 'House Registry',
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/appearance': 'Appearance',
|
||||
'/admin/navigation': 'Navigation',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/bot-activity': 'Web Bot Activity',
|
||||
'/admin/discord-bot': 'Discord Bot',
|
||||
'/admin/shard': 'Shard (uo-link)',
|
||||
'/admin/shard-visibility': 'Shard Visibility',
|
||||
'/admin/shard-atlas': 'Spawn Atlas',
|
||||
'/admin/characters': 'My Characters',
|
||||
'/admin/auth-providers': 'Authentication',
|
||||
'/admin/users': 'Users',
|
||||
@@ -139,6 +183,7 @@ const navBtnBase = {
|
||||
export default function AdminLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { mode, siteTitle } = useSite()
|
||||
const navOverrides = useNavOverrides()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title = TITLES[location.pathname] || sectionTitle(location.pathname)
|
||||
@@ -146,20 +191,21 @@ export default function AdminLayout() {
|
||||
const wide = location.pathname === '/admin/hero'
|
||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||
|
||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||
// own account security.
|
||||
const isModerator = user?.role === 'moderator'
|
||||
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||
const visible = (item) => {
|
||||
if (item.roles && !item.roles.includes(user?.role)) return false
|
||||
if (isModerator) return MOD_PATHS.includes(item.to)
|
||||
return true
|
||||
}
|
||||
// Drop items the current role can't see, then drop any now-empty group so an
|
||||
// empty category header never renders.
|
||||
const navGroups = NAV
|
||||
.map((g) => ({ ...g, items: g.items.filter(visible) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
|
||||
// An admin may relabel, reorder, hide and regroup these rows from Admin →
|
||||
// Navigation. The merge runs FIRST and the role filter after it, so the filter
|
||||
// stays the boundary: an override cannot show a moderator a row their role
|
||||
// gate hides, whatever it says. With no stored row applyNavOverrides returns
|
||||
// NAV itself and this is exactly the code that ran before the feature.
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
applyNavOverrides(NAV, keepEditorReachable(navOverrides.nav_admin))
|
||||
.map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) }))
|
||||
// Drop any now-empty group so an empty category header never renders.
|
||||
.filter((g) => g.items.length > 0),
|
||||
[navOverrides.nav_admin, user?.role],
|
||||
)
|
||||
|
||||
// Accordion: track which titled categories are collapsed. Persist across
|
||||
// reloads; default all-open. The group holding the active route auto-opens.
|
||||
@@ -226,6 +272,7 @@ export default function AdminLayout() {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<BrandLogo height={24} />
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
@@ -181,6 +182,10 @@ export default function AdminLogin() {
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{/* Stacked above the moon rather than beside it: this layout is
|
||||
centered text, and a flex row here would change the block's
|
||||
height on instances with no logo. */}
|
||||
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
|
||||
<MoonDot size={15} glow={0.55} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
|
||||
358
client/src/routes/admin/views/AppearanceAdmin.jsx
Normal file
358
client/src/routes/admin/views/AppearanceAdmin.jsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { parseJsonSetting } from '../../../lib/settingsJson.js'
|
||||
import BrandAssetsPanel from './BrandAssetsPanel.jsx'
|
||||
|
||||
// Admin · Appearance — the theme and brand-asset halves of
|
||||
// docs/website/THEMING_AND_NAV.md (phases 3-5). The nav builder is phase 7 and
|
||||
// gets its own screen.
|
||||
//
|
||||
// Two things shape this form:
|
||||
//
|
||||
// • Every control is a closed set. The presets, the font shortlist and the
|
||||
// shadow depths all come from GET /settings/theme/options, which is derived
|
||||
// from the same server config the save is validated against — so the form
|
||||
// can never offer a value the server would reject. Nothing here is free
|
||||
// text except the color inputs, which are <input type="color"> and so are
|
||||
// hex by construction.
|
||||
// • Saving means writing a settings row; resetting means DELETING it. Absence
|
||||
// of the row is what selects the shipped default, so "reset" cannot write a
|
||||
// copy of the defaults — see §2.
|
||||
|
||||
// Human labels for the eight editable colors and four radii. The field names
|
||||
// and the CSS variables they drive both come from the server
|
||||
// (colorFields / radiusFields); this only decorates them, and a field with no
|
||||
// label here still renders under its raw name rather than vanishing.
|
||||
const COLOR_LABELS = {
|
||||
bg: 'Background',
|
||||
bgDeep: 'Background (deep)',
|
||||
panelA: 'Panel (top)',
|
||||
panelB: 'Panel (bottom)',
|
||||
accent: 'Accent',
|
||||
accentBright: 'Accent (bright)',
|
||||
ink: 'Ink / headings',
|
||||
text: 'Body text',
|
||||
}
|
||||
const RADIUS_LABELS = {
|
||||
radiusPill: 'Pills & buttons',
|
||||
radiusPanel: 'Flat panels',
|
||||
radiusCard: 'Cards & panels',
|
||||
radiusInput: 'Inputs & notes',
|
||||
}
|
||||
const FONT_LABELS = {
|
||||
serif: 'Body serif',
|
||||
display: 'Display / headings',
|
||||
sans: 'Interface sans',
|
||||
}
|
||||
|
||||
// Strip empty groups so a theme the admin cleared back out is stored as a bare
|
||||
// preset rather than as `{colors:{}, fonts:{}, structure:{}}`. Never null a
|
||||
// field out to "clear" it — remove it (§6.1).
|
||||
function compactCustom(custom) {
|
||||
const out = {}
|
||||
for (const [group, fields] of Object.entries(custom)) {
|
||||
const kept = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== '' && v != null))
|
||||
if (Object.keys(kept).length) out[group] = kept
|
||||
}
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
|
||||
export default function AppearanceAdmin() {
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const [options, setOptions] = useState(null)
|
||||
const [preset, setPreset] = useState('runic-gateway')
|
||||
const [custom, setCustom] = useState({ colors: {}, fonts: {}, structure: {} })
|
||||
// Whether a theme_visual row exists at all. Drives the "reset" button and the
|
||||
// "this instance is using the shipped theme" note — an admin needs to be able
|
||||
// to tell "never themed" from "themed to look like the default".
|
||||
const [stored, setStored] = useState(false)
|
||||
// The brand-asset overrides, read in the same settings fetch and then owned by
|
||||
// the panel below (its uploads save on their own, so it does not share this
|
||||
// screen's Save button).
|
||||
const [assets, setAssets] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
Promise.all([api.themeOptions(), api.admin.getSettings()])
|
||||
.then(([opts, all]) => {
|
||||
if (!active) return
|
||||
setOptions(opts)
|
||||
// The stored values are JSON strings (settings.value is TEXT), and a
|
||||
// malformed one reads as absent exactly as the server treats it — the
|
||||
// form then shows the shipped default rather than an error.
|
||||
const parsed = parseJsonSetting(all.theme_visual)
|
||||
setStored(Boolean(all.theme_visual))
|
||||
setAssets(parseJsonSetting(all.brand_assets) || {})
|
||||
if (parsed) {
|
||||
setPreset(parsed.preset || 'runic-gateway')
|
||||
setCustom({
|
||||
colors: parsed.custom?.colors || {},
|
||||
fonts: parsed.custom?.fonts || {},
|
||||
structure: parsed.custom?.structure || {},
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => active && setError('Could not load the appearance settings.'))
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// What an unset field currently resolves to: the selected preset's palette,
|
||||
// or the shipped theme when the preset is Custom (which has no base). Lets a
|
||||
// color picker open on the value the admin is actually looking at.
|
||||
const baseTokens = useMemo(() => {
|
||||
if (!options) return {}
|
||||
return options.presets.find((p) => p.id === preset)?.tokens || options.shippedTokens
|
||||
}, [options, preset])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !options) return <ErrorState message={error} />
|
||||
|
||||
const setField = (group, field) => (value) => {
|
||||
setCustom((c) => ({ ...c, [group]: { ...c[group], [field]: value } }))
|
||||
setSaved(false)
|
||||
}
|
||||
const clearField = (group, field) => () => {
|
||||
setCustom((c) => {
|
||||
const next = { ...c[group] }
|
||||
delete next[field]
|
||||
return { ...c, [group]: next }
|
||||
})
|
||||
setSaved(false)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.updateSettings({ theme_visual: { preset, custom: compactCustom(custom) } })
|
||||
setStored(true)
|
||||
setSaved(true)
|
||||
// Repull the public settings so the surrounding admin UI re-themes itself
|
||||
// immediately — the admin sees the change they just made.
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save the theme.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAll() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.resetSetting('theme_visual')
|
||||
setPreset('runic-gateway')
|
||||
setCustom({ colors: {}, fonts: {}, structure: {} })
|
||||
setStored(false)
|
||||
setSaved(false)
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not reset the theme.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
||||
Colors, fonts and corner radius for the public site, this admin panel and the player portal.
|
||||
{' '}
|
||||
{stored ? (
|
||||
<>This instance has a saved theme. <strong style={{ color: 'var(--muted)' }}>Reset to default</strong> deletes it and returns to the shipped look.</>
|
||||
) : (
|
||||
<>This instance has never been themed, so it uses the shipped look and its <code>BRAND_*</code> accent.</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* ── Preset ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Preset</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 8 }}>
|
||||
{options.presets.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPreset(p.id)
|
||||
setSaved(false)
|
||||
}}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: `1px solid ${preset === p.id ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: preset === p.id ? 'var(--blue)' : 'transparent',
|
||||
color: preset === p.id ? 'var(--ink)' : 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
aria-pressed={preset === p.id}
|
||||
>
|
||||
{p.tokens ? (
|
||||
<span style={{ display: 'flex', borderRadius: 4, overflow: 'hidden', border: '1px solid var(--line)' }}>
|
||||
{['--bg', '--panel-a', '--accent', '--ink'].map((t) => (
|
||||
<span key={t} style={{ width: 11, height: 18, background: p.tokens[t] }} />
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ width: 44, height: 18, borderRadius: 4, border: '1px dashed var(--line)' }} />
|
||||
)}
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
{preset === 'custom'
|
||||
? 'Custom starts from the shipped theme — only the fields you set below change.'
|
||||
: 'A preset sets the whole palette. Anything you set below overrides it, field by field.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Colors ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Colors</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
|
||||
{options.colorFields.map(({ name, token }) => {
|
||||
const set = custom.colors[name] !== undefined
|
||||
return (
|
||||
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{/* <input type="color"> has no empty state, so an unset field
|
||||
shows what it currently resolves to rather than black. */}
|
||||
<input
|
||||
type="color"
|
||||
value={custom.colors[name] || baseTokens[token] || '#000000'}
|
||||
onChange={(e) => setField('colors', name)(e.target.value)}
|
||||
aria-label={COLOR_LABELS[name] || name}
|
||||
style={{ width: 34, height: 30, padding: 0, border: '1px solid var(--line)', borderRadius: 6, background: 'transparent', cursor: 'pointer' }}
|
||||
/>
|
||||
<span className="sans" style={{ flex: 1, fontSize: '0.82rem', color: set ? 'var(--ink)' : 'var(--dim)' }}>
|
||||
{COLOR_LABELS[name] || name}
|
||||
</span>
|
||||
{set && (
|
||||
<button type="button" onClick={clearField('colors', name)} className="sans" title="Follow the preset again" style={linkBtn}>
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
A color you have not set follows the preset. “Live” and “maintenance” status colors are never themed — green has to keep meaning live.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Fonts ──────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Fonts</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 8 }}>
|
||||
{Object.keys(options.fonts).map((role) => (
|
||||
<label key={role} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
{FONT_LABELS[role] || role}
|
||||
</span>
|
||||
<select
|
||||
className="select"
|
||||
value={custom.fonts[role] || ''}
|
||||
onChange={(e) => (e.target.value ? setField('fonts', role)(e.target.value) : clearField('fonts', role)())}
|
||||
>
|
||||
<option value="">Follow the preset</option>
|
||||
{options.fonts[role].map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Structure ──────────────────────────────────────────── */}
|
||||
<div>
|
||||
<span className="field-label">Corners & depth</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12, marginTop: 8 }}>
|
||||
{options.radiusFields.map(({ name, token }) => (
|
||||
<label key={name} style={{ display: 'block' }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
{RADIUS_LABELS[name] || name}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
max={options.radiusMaxPx}
|
||||
placeholder={(baseTokens[token] || '').replace('px', '')}
|
||||
value={(custom.structure[name] || '').replace('px', '')}
|
||||
onChange={(e) =>
|
||||
e.target.value === ''
|
||||
? clearField('structure', name)()
|
||||
: setField('structure', name)(`${Math.min(Math.max(parseInt(e.target.value, 10) || 0, 0), options.radiusMaxPx)}px`)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label style={{ display: 'block', marginTop: 12 }}>
|
||||
<span className="sans dim" style={{ display: 'block', fontSize: '0.76rem', marginBottom: 4 }}>
|
||||
Card shadow
|
||||
</span>
|
||||
<select
|
||||
className="select"
|
||||
value={custom.structure.shadowDepth || ''}
|
||||
onChange={(e) => (e.target.value ? setField('structure', 'shadowDepth')(e.target.value) : clearField('structure', 'shadowDepth')())}
|
||||
>
|
||||
<option value="">Follow the preset</option>
|
||||
{options.shadows.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save theme'}
|
||||
</button>
|
||||
<button onClick={resetAll} disabled={busy || !stored} className="pill" title={stored ? 'Delete the saved theme' : 'Nothing to reset'}>
|
||||
Reset to default
|
||||
</button>
|
||||
{saved && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
||||
The accent reaches the mobile app and the Discord bot too — both theme themselves from this
|
||||
site’s public branding.
|
||||
</p>
|
||||
|
||||
{/* ── Brand assets ───────────────────────────────────────── */}
|
||||
<BrandAssetsPanel initial={assets || {}} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const linkBtn = {
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}
|
||||
213
client/src/routes/admin/views/BrandAssetsPanel.jsx
Normal file
213
client/src/routes/admin/views/BrandAssetsPanel.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
|
||||
// Admin · Appearance → Brand assets (docs/website/THEMING_AND_NAV.md §6.3).
|
||||
//
|
||||
// Three slots, each an override layer over the matching BRAND_* env value. An
|
||||
// empty slot is not "no image" — it is "whatever this instance was deployed
|
||||
// with", which is why every row shows what it currently resolves to rather than
|
||||
// an empty box.
|
||||
//
|
||||
// Unlike the theme form above, an upload SAVES IMMEDIATELY: the file and the
|
||||
// settings row are written by one request, because an upload that stored a file
|
||||
// and then waited for a Save press would leave litter in /uploads whenever the
|
||||
// admin changed their mind. Clearing a slot is the same deal in reverse.
|
||||
const SLOTS = [
|
||||
{
|
||||
id: 'logo',
|
||||
label: 'Logo',
|
||||
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
|
||||
limit: '1 MB',
|
||||
envVar: 'BRAND_LOGO',
|
||||
help: 'Shown beside the moon in the site header, the admin sidebar and the player portal, and used as the link preview image when a page is shared.',
|
||||
},
|
||||
{
|
||||
id: 'hero',
|
||||
label: 'Hero image',
|
||||
accept: 'image/png,image/jpeg,image/webp,image/avif,image/gif',
|
||||
limit: '8 MB',
|
||||
envVar: 'BRAND_HERO',
|
||||
// §4.9: the hero editor's own background beats this, and an admin who does
|
||||
// not know that files a bug against a working system.
|
||||
help: 'The image behind the portal hero. If the hero editor has its own background image set, that wins over this one.',
|
||||
},
|
||||
{
|
||||
id: 'favicon',
|
||||
label: 'Favicon',
|
||||
accept: 'image/png',
|
||||
limit: '512 KB',
|
||||
envVar: 'BRAND_FAVICON',
|
||||
// §4.10: .ico would mean adding a type to the upload allowlist, and the
|
||||
// stored extension coming from that allowlist is what makes uploads safe.
|
||||
help: 'The browser tab icon. PNG only — a 32×32 or 64×64 square works everywhere.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function BrandAssetsPanel({ initial }) {
|
||||
const { brand, refresh: refreshSite } = useSite()
|
||||
const [assets, setAssets] = useState(initial || {})
|
||||
const [busySlot, setBusySlot] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const inputs = useRef({})
|
||||
|
||||
async function upload(slot, file) {
|
||||
if (!file) return
|
||||
setBusySlot(slot)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.admin.uploadBrandAsset(slot, file)
|
||||
setAssets(res.brand_assets || {})
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not upload that image.')
|
||||
} finally {
|
||||
setBusySlot('')
|
||||
// Let the same file be picked again after a failure — a file input holds
|
||||
// its value, so re-choosing it would fire no change event.
|
||||
if (inputs.current[slot]) inputs.current[slot].value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function clear(slot) {
|
||||
setBusySlot(slot)
|
||||
setError('')
|
||||
try {
|
||||
const next = { ...assets }
|
||||
delete next[slot]
|
||||
// Clearing the last override deletes the row rather than storing `{}` —
|
||||
// absence of the row is what selects the env defaults (§2), and a stored
|
||||
// empty object would be a different state that means the same thing.
|
||||
if (Object.keys(next).length) await api.admin.updateSettings({ brand_assets: next })
|
||||
else await api.admin.resetSetting('brand_assets')
|
||||
setAssets(next)
|
||||
await refreshSite()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not clear that asset.')
|
||||
} finally {
|
||||
setBusySlot('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="field-label">Brand assets</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 8 }}>
|
||||
{SLOTS.map((slot) => {
|
||||
const overridden = Boolean(assets[slot.id])
|
||||
// What the site actually uses right now: the override, or the env
|
||||
// value the brand block already resolved for us.
|
||||
const effective = assets[slot.id] || brand[slot.id] || ''
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 14,
|
||||
padding: 12,
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 76,
|
||||
height: 48,
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid var(--line-soft)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--bg-deep)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{effective ? (
|
||||
<img src={effective} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<span className="sans dim" style={{ fontSize: '0.68rem' }}>
|
||||
none
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ fontSize: '0.86rem', color: 'var(--ink)' }}>
|
||||
{slot.label}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', lineHeight: 1.6, marginTop: 2 }}>
|
||||
{slot.help}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 6 }}>
|
||||
{overridden ? (
|
||||
<>
|
||||
Uploaded override — <code>{assets[slot.id]}</code>
|
||||
</>
|
||||
) : effective ? (
|
||||
<>
|
||||
Using the deployed default from <code>{slot.envVar}</code>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Not set — <code>{slot.envVar}</code> is empty, so nothing is rendered
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
ref={(el) => {
|
||||
inputs.current[slot.id] = el
|
||||
}}
|
||||
type="file"
|
||||
accept={slot.accept}
|
||||
disabled={Boolean(busySlot)}
|
||||
onChange={(e) => upload(slot.id, e.target.files?.[0])}
|
||||
className="sans"
|
||||
style={{ fontSize: '0.74rem', maxWidth: 240 }}
|
||||
aria-label={`Upload a ${slot.label.toLowerCase()}`}
|
||||
/>
|
||||
<span className="sans dim" style={{ fontSize: '0.7rem' }}>
|
||||
max {slot.limit}
|
||||
</span>
|
||||
{overridden && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clear(slot.id)}
|
||||
disabled={Boolean(busySlot)}
|
||||
className="sans"
|
||||
title={`Go back to ${slot.envVar}`}
|
||||
style={linkBtn}
|
||||
>
|
||||
{busySlot === slot.id ? 'working…' : 'clear'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<span className="sans" style={{ display: 'block', marginTop: 8, color: '#d98b84', fontSize: '0.85rem' }}>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 8, fontSize: '0.76rem' }}>
|
||||
Uploads apply as soon as they finish — there is nothing to save here. The footer’s “powered by
|
||||
Runic Gateway” mark is the project’s badge, not this instance’s, and never changes.
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const linkBtn = {
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.72rem',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}
|
||||
545
client/src/routes/admin/views/NavEditor.jsx
Normal file
545
client/src/routes/admin/views/NavEditor.jsx
Normal file
@@ -0,0 +1,545 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
import { useAuth } from '../../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../../contexts/SiteContext.jsx'
|
||||
import { useShardFeatures, canSee } from '../../../lib/useShardFeatures.js'
|
||||
import { buildNavRows, buildNavOverrides, buildPublicNav, buildPublicNavOverrides } from '../../../lib/navOverrides.js'
|
||||
import PublicNavTree from './PublicNavTree.jsx'
|
||||
import { parseJsonSetting } from '../../../lib/settingsJson.js'
|
||||
import { refreshNavOverrides } from '../../../lib/useNavOverrides.js'
|
||||
import { NAV as PUBLIC_NAV } from '../../../components/SiteHeader.jsx'
|
||||
import { NAV as ADMIN_NAV, navItemVisibleTo } from '../AdminLayout.jsx'
|
||||
import { NAV as PLAYER_NAV } from '../../player/PlayerPortalLayout.jsx'
|
||||
|
||||
// Admin · Navigation — phases 6-8 of docs/website/THEMING_AND_NAV.md.
|
||||
//
|
||||
// The three navs stay declared in code, each in the component that renders it;
|
||||
// this screen writes an override *layer* over them (§7). It can relabel,
|
||||
// reorder, hide and — on the admin sidebar — move a row into another existing
|
||||
// section, and nothing else. It cannot introduce a route and it cannot touch a
|
||||
// `roles` or `feature` gate, so the filters in the layouts still decide who sees
|
||||
// what, and they run after the merge.
|
||||
//
|
||||
// Three things shape the screen:
|
||||
//
|
||||
// • The palette is filtered to the editing admin's OWN visible rows (§8.1) —
|
||||
// the base array run through their role and this shard's feature gates. An
|
||||
// admin cannot drag in, and so can never accidentally advertise, something
|
||||
// they cannot see themselves. An override on a row they cannot see is
|
||||
// carried through their save untouched rather than quietly reset.
|
||||
// • The rows come from the same merge the site renders (buildNavRows), hidden
|
||||
// ones included, so the editor cannot show an order the nav does not use.
|
||||
// • Saving writes a settings row; "reset" DELETES it. Absence of the row is
|
||||
// what selects the coded default, so reset cannot store a copy of it — and a
|
||||
// save whose result is empty deletes the row for the same reason (§4.1).
|
||||
|
||||
// The nav editor's own row. Hiding it would remove the only screen that can
|
||||
// un-hide it, so its eye toggle is disabled here and the server drops `hidden`
|
||||
// on it as well (server/src/utils/navOverrides.js) — a hand-written row cannot
|
||||
// do what the UI refuses.
|
||||
const SELF = '/admin/navigation'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'nav_public', label: 'Public site', hint: 'The header on every public page.' },
|
||||
{ key: 'nav_admin', label: 'Admin', hint: 'This sidebar. Rows can also move between sections.' },
|
||||
{ key: 'nav_player', label: 'Player portal', hint: 'The sidebar a signed-in player sees.' },
|
||||
]
|
||||
|
||||
function DragHandle({ attributes, listeners, disabled }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
aria-label="Reorder"
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--dim)',
|
||||
cursor: disabled ? 'default' : 'grab',
|
||||
padding: '2px 4px',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
||||
<circle cx="9" cy="6" r="1.6" />
|
||||
<circle cx="15" cy="6" r="1.6" />
|
||||
<circle cx="9" cy="12" r="1.6" />
|
||||
<circle cx="15" cy="12" r="1.6" />
|
||||
<circle cx="9" cy="18" r="1.6" />
|
||||
<circle cx="15" cy="18" r="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function EyeIcon({ off }) {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
{off && <path d="M3 3l18 18" />}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One editable nav row, shared by all three tabs.
|
||||
*
|
||||
* The destination control is generic because the two navs that have one mean
|
||||
* different things by it: the admin sidebar moves rows between the four coded
|
||||
* sections, the public header between admin-created dropdowns. Both are "pick a
|
||||
* container", so both get one `<select>` rather than cross-container dragging —
|
||||
* which is a lot of interaction surface for something an admin does once.
|
||||
*
|
||||
* @param {Array<{value: string, label: string}>} [destinations] omit for a nav
|
||||
* with no containers (the player portal)
|
||||
* @param {() => void} [onDelete] only an admin-authored link can be deleted;
|
||||
* a coded row is hidden, never removed
|
||||
*/
|
||||
export function Row({ row, id, destinations, destination, onDestination, onChange, onDelete }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
|
||||
const renamed = row.defaultLabel !== undefined && row.label !== row.defaultLabel
|
||||
const locked = row.to === SELF
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '7px 10px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: '1px solid var(--line)',
|
||||
background: isDragging ? 'var(--blue)' : 'var(--panel-flat)',
|
||||
opacity: row.hidden ? 0.55 : 1,
|
||||
listStyle: 'none',
|
||||
}}
|
||||
>
|
||||
<DragHandle attributes={attributes} listeners={listeners} />
|
||||
<input
|
||||
className="input"
|
||||
value={row.label}
|
||||
placeholder={row.defaultLabel || row.to}
|
||||
maxLength={64}
|
||||
onChange={(e) => onChange({ ...row, label: e.target.value })}
|
||||
aria-label={`Label for ${row.defaultLabel || row.to}`}
|
||||
style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem' }}
|
||||
/>
|
||||
{/* The route, for orientation — it is what the override is keyed by. Fixed
|
||||
and truncating rather than flexible: /admin/moderation/appeals would
|
||||
otherwise wrap and squeeze the label input it sits beside. */}
|
||||
<code
|
||||
className="sans dim"
|
||||
title={row.to}
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
width: 130,
|
||||
fontSize: '0.7rem',
|
||||
opacity: 0.75,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{row.to}
|
||||
</code>
|
||||
{renamed && (
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
title="Use the coded label again"
|
||||
onClick={() => onChange({ ...row, label: row.defaultLabel })}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--accent)', fontSize: '0.72rem', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
)}
|
||||
{destinations && destinations.length > 0 && (
|
||||
<select
|
||||
className="select"
|
||||
value={destination ?? ''}
|
||||
onChange={(e) => onDestination(e.target.value || null)}
|
||||
aria-label={`Section for ${row.defaultLabel || row.to}`}
|
||||
style={{ flex: '0 0 auto', width: 130, padding: '4px 6px', fontSize: '0.76rem' }}
|
||||
>
|
||||
{destinations.map((d) => (
|
||||
<option key={d.value} value={d.value}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
title="Remove this link"
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
background: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.76rem',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{/* A coded row is hidden, never removed — the route still exists. An
|
||||
admin-authored link is the opposite: there is nothing to fall back to,
|
||||
so it is deleted instead (the × above). */}
|
||||
{!onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
disabled={locked}
|
||||
title={
|
||||
locked
|
||||
? 'This screen is the only way back — it cannot be hidden'
|
||||
: row.hidden
|
||||
? 'Currently hidden. Show it again'
|
||||
: 'Hide from this nav'
|
||||
}
|
||||
aria-pressed={row.hidden}
|
||||
onClick={() => onChange({ ...row, hidden: !row.hidden })}
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
background: 'transparent',
|
||||
color: locked ? 'var(--dim)' : row.hidden ? 'var(--accent)' : 'var(--muted)',
|
||||
cursor: locked ? 'not-allowed' : 'pointer',
|
||||
padding: '4px 6px',
|
||||
display: 'flex',
|
||||
opacity: locked ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<EyeIcon off={row.hidden} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NavEditor() {
|
||||
const { user } = useAuth()
|
||||
const { refresh: refreshSite } = useSite()
|
||||
const shardFeatures = useShardFeatures()
|
||||
const [tab, setTab] = useState('nav_public')
|
||||
// Per nav: the editable groups, the overrides as loaded (so a row this admin
|
||||
// cannot see survives their save), and whether a settings row exists at all.
|
||||
const [state, setState] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saved, setSaved] = useState('')
|
||||
const [dirty, setDirty] = useState({})
|
||||
|
||||
// The palette: each base nav, filtered to what THIS admin can see (§8.1). The
|
||||
// public nav's gates are the shard-feature ones; the admin nav's are roles.
|
||||
// The player portal has no gates at all.
|
||||
// The nav as coded, unfiltered. The palette below is what this admin may EDIT;
|
||||
// this is what still EXISTS, and the two are different questions. Saving needs
|
||||
// both: an entry for a row their palette filtered out must be carried through
|
||||
// rather than reset, and only an entry for a route the code no longer declares
|
||||
// at all should be dropped.
|
||||
const fullNavs = { nav_public: PUBLIC_NAV, nav_admin: ADMIN_NAV, nav_player: PLAYER_NAV }
|
||||
|
||||
const palettes = useMemo(
|
||||
() => ({
|
||||
nav_public: PUBLIC_NAV.filter((item) => !item.feature || canSee(shardFeatures, item.feature)),
|
||||
nav_admin: ADMIN_NAV.map((g) => ({ ...g, items: g.items.filter((i) => navItemVisibleTo(i, user?.role)) })).filter(
|
||||
(g) => g.items.length > 0,
|
||||
),
|
||||
nav_player: PLAYER_NAV,
|
||||
}),
|
||||
[shardFeatures, user?.role],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.admin
|
||||
.getSettings()
|
||||
.then((all) => {
|
||||
if (!active) return
|
||||
const next = {}
|
||||
for (const { key } of TABS) {
|
||||
const stored = parseJsonSetting(all[key])
|
||||
// The public header is a tree (sections are entries in the top-level
|
||||
// order); the other two are the fixed-frame grouped/flat shape.
|
||||
next[key] =
|
||||
key === 'nav_public'
|
||||
? { stored, hasRow: Boolean(all[key]), tree: buildPublicNav(palettes[key], stored, { keepHidden: true }) }
|
||||
: { stored, hasRow: Boolean(all[key]), groups: buildNavRows(palettes[key], stored) }
|
||||
}
|
||||
setState(next)
|
||||
})
|
||||
.catch(() => active && setError('Could not load the navigation settings.'))
|
||||
.finally(() => active && setLoading(false))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
// Loaded once; the palettes settle before the fetch resolves in practice, and
|
||||
// re-running on a feature flip would discard the admin's unsaved edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !state) return <ErrorState message={error} />
|
||||
|
||||
const current = state[tab]
|
||||
const isPublic = tab === 'nav_public'
|
||||
const groupTitles = isPublic ? [] : current.groups.map((g) => g.title).filter(Boolean)
|
||||
// Where each row is declared in code, so the section dropdown can offer only
|
||||
// the destinations an override is able to express.
|
||||
const baseGroups = new Map(
|
||||
(!isPublic && Array.isArray(palettes[tab]) && palettes[tab][0]?.items
|
||||
? palettes[tab].flatMap((g) => g.items.map((i) => [i.to, g.title ?? null]))
|
||||
: []),
|
||||
)
|
||||
// The admin sidebar can only move a row between the four coded sections, and
|
||||
// "(no section)" only for a row coded into an untitled one — for anything else
|
||||
// it is a move an override cannot express (§6.4), so offering it would
|
||||
// silently do nothing.
|
||||
const groupDestinations = (baseGroup) => [
|
||||
...(baseGroup === null ? [{ value: '', label: '(no section)' }] : []),
|
||||
...groupTitles.map((t) => ({ value: t, label: t })),
|
||||
]
|
||||
|
||||
function mutate(updater) {
|
||||
setState((s) => ({ ...s, [tab]: { ...s[tab], groups: updater(s[tab].groups) } }))
|
||||
setDirty((d) => ({ ...d, [tab]: true }))
|
||||
setSaved('')
|
||||
}
|
||||
|
||||
function setTree(tree) {
|
||||
setState((s) => ({ ...s, [tab]: { ...s[tab], tree } }))
|
||||
setDirty((d) => ({ ...d, [tab]: true }))
|
||||
setSaved('')
|
||||
}
|
||||
|
||||
const onRowChange = (next) =>
|
||||
mutate((groups) => groups.map((g) => ({ ...g, items: g.items.map((i) => (i.to === next.to ? next : i)) })))
|
||||
|
||||
// Sections change by dropdown, not by dragging: a drag that could land in
|
||||
// another list is a lot of interaction surface for something an admin does
|
||||
// once, and this keeps every drag a simple reorder. The row goes to the end of
|
||||
// its new section, where it is visible and can then be dragged into place.
|
||||
const onMoveGroup = (to, title) =>
|
||||
mutate((groups) => {
|
||||
const moving = groups.flatMap((g) => g.items).find((i) => i.to === to)
|
||||
if (!moving) return groups
|
||||
return groups.map((g) => {
|
||||
if ((g.title ?? null) === title) return { ...g, items: [...g.items.filter((i) => i.to !== to), moving] }
|
||||
return { ...g, items: g.items.filter((i) => i.to !== to) }
|
||||
})
|
||||
})
|
||||
|
||||
const onDragEnd = (groupIndex) => (event) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
mutate((groups) =>
|
||||
groups.map((g, i) => {
|
||||
if (i !== groupIndex) return g
|
||||
const from = g.items.findIndex((it) => it.to === active.id)
|
||||
const to = g.items.findIndex((it) => it.to === over.id)
|
||||
if (from < 0 || to < 0) return g
|
||||
return { ...g, items: arrayMove(g.items, from, to) }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Push a save into whatever is rendering that nav right now, so the admin sees
|
||||
// what they just did: the header re-reads the public settings, the two
|
||||
// authenticated sidebars re-read /settings/nav.
|
||||
async function propagate(key) {
|
||||
if (key === 'nav_public') await refreshSite()
|
||||
else await refreshNavOverrides()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const overrides = isPublic
|
||||
? buildPublicNavOverrides(current.tree, fullNavs[tab], current.stored)
|
||||
: buildNavOverrides(current.groups, fullNavs[tab], current.stored)
|
||||
// A wrapper with an empty `items` and no sections/links says nothing
|
||||
// either, so "empty" is about the whole value, not just its key count.
|
||||
const empty =
|
||||
Object.keys(overrides).length === 0 ||
|
||||
(overrides.items !== undefined &&
|
||||
Object.keys(overrides.items).length === 0 &&
|
||||
!overrides.sections?.length &&
|
||||
!overrides.links?.length)
|
||||
// Nothing differs from the code default, so there is nothing to store —
|
||||
// and a row that says nothing would still read as "this nav was
|
||||
// customised". Delete it instead (§2, §4.1).
|
||||
if (empty) await api.admin.resetSetting(tab)
|
||||
else await api.admin.updateSettings({ [tab]: overrides })
|
||||
setState((s) => ({
|
||||
...s,
|
||||
[tab]: { ...s[tab], stored: empty ? null : overrides, hasRow: !empty },
|
||||
}))
|
||||
setDirty((d) => ({ ...d, [tab]: false }))
|
||||
setSaved(tab)
|
||||
await propagate(tab)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save this navigation.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetNav() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.resetSetting(tab)
|
||||
setState((s) => ({
|
||||
...s,
|
||||
[tab]: isPublic
|
||||
? { stored: null, hasRow: false, tree: buildPublicNav(palettes[tab], null, { keepHidden: true }) }
|
||||
: { stored: null, hasRow: false, groups: buildNavRows(palettes[tab], null) },
|
||||
}))
|
||||
setDirty((d) => ({ ...d, [tab]: false }))
|
||||
setSaved('')
|
||||
await propagate(tab)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not reset this navigation.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const activeTab = TABS.find((t) => t.key === tab)
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem', lineHeight: 1.7 }}>
|
||||
Rename, reorder and hide the entries in each navigation. The pages themselves are unchanged — this
|
||||
only decides what is advertised, and it can never show anyone a link their role or this shard’s
|
||||
visibility settings would hide.
|
||||
</p>
|
||||
|
||||
{/* ── Tabs ───────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => {
|
||||
setTab(t.key)
|
||||
setSaved('')
|
||||
}}
|
||||
aria-pressed={tab === t.key}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: `1px solid ${tab === t.key ? 'var(--accent)' : 'var(--line)'}`,
|
||||
background: tab === t.key ? 'var(--blue)' : 'transparent',
|
||||
color: tab === t.key ? 'var(--ink)' : 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
{dirty[t.key] && <span style={{ color: 'var(--accent)' }}> •</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||||
{activeTab.hint}{' '}
|
||||
{current.hasRow
|
||||
? 'This nav has saved overrides.'
|
||||
: 'This nav has never been customised, so it renders exactly as coded.'}
|
||||
</span>
|
||||
|
||||
{/* ── Rows ───────────────────────────────────────────────── */}
|
||||
{/* The public header gets its own editor: a section there is an entry in
|
||||
the top-level order that an admin created, not a fixed frame the code
|
||||
declares, so it is a tree rather than a list of groups. */}
|
||||
{isPublic ? (
|
||||
<PublicNavTree tree={current.tree} onChange={setTree} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{current.groups.map((group, groupIndex) => (
|
||||
<div key={group.title ?? `group-${groupIndex}`}>
|
||||
{group.title && <span className="field-label">{group.title}</span>}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(groupIndex)}>
|
||||
<SortableContext items={group.items.map((i) => i.to)} strategy={verticalListSortingStrategy}>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '8px 0 0', padding: 0 }}>
|
||||
{group.items.map((row) => (
|
||||
<Row
|
||||
key={row.to}
|
||||
id={row.to}
|
||||
row={row}
|
||||
destinations={groupTitles.length > 0 ? groupDestinations(baseGroups.get(row.to) ?? null) : null}
|
||||
destination={group.title ?? ''}
|
||||
onDestination={(value) => onMoveGroup(row.to, value)}
|
||||
onChange={onRowChange}
|
||||
/>
|
||||
))}
|
||||
{group.items.length === 0 && (
|
||||
<li className="sans dim" style={{ fontSize: '0.76rem', listStyle: 'none', padding: '6px 2px' }}>
|
||||
Empty — this section is not rendered until something is moved into it.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save navigation'}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetNav}
|
||||
disabled={busy || !current.hasRow}
|
||||
className="pill"
|
||||
title={current.hasRow ? 'Delete the saved overrides for this nav' : 'Nothing to reset'}
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
{saved === tab && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>Saved.</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
||||
Only entries you can see yourself are listed. Anything hidden from you by your role or by Shard
|
||||
Visibility keeps whatever it was already set to.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
310
client/src/routes/admin/views/PublicNavTree.jsx
Normal file
310
client/src/routes/admin/views/PublicNavTree.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useState } from 'react'
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
import Modal from '../../../components/Modal.jsx'
|
||||
import { Row } from './NavEditor.jsx'
|
||||
|
||||
// The Public tab of Admin → Navigation (THEMING_AND_NAV.md §7, Phase 10).
|
||||
//
|
||||
// The public header is the one nav an admin can restructure rather than only
|
||||
// reorder, so it needs its own editor: a **section is itself an entry in the
|
||||
// top-level order**, which the fixed coded sections of the admin sidebar never
|
||||
// are. That is the whole reason this is not the grouped editor with a different
|
||||
// label — there, groups are a fixed frame and only membership moves.
|
||||
//
|
||||
// The tree is `[{kind: 'item' | 'link' | 'section', ...}]`, one level deep, and
|
||||
// comes from the same `buildPublicNav` the header renders, so what an admin
|
||||
// drags is what visitors get.
|
||||
|
||||
const uid = (prefix) => `${prefix}_${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
// A path on this site, matching what the server will accept. Checked here so the
|
||||
// admin gets the message while the field is in front of them; the server's 400
|
||||
// stays the backstop, not the first feedback.
|
||||
export function badLinkPath(value) {
|
||||
const v = (value || '').trim()
|
||||
if (!v) return 'Enter a path.'
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(v) || v.startsWith('//')) {
|
||||
return 'Links must point somewhere on this site — start with “/”.'
|
||||
}
|
||||
if (!v.startsWith('/')) return 'Start the path with “/”, for example /wiki/new-player-guide.'
|
||||
if (/[\s<>"'\\]/.test(v)) return 'A path cannot contain spaces or quotes.'
|
||||
if (v.length > 128) return 'That path is too long.'
|
||||
return null
|
||||
}
|
||||
|
||||
function SectionCard({ section, index, children, onChange, onDelete }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
|
||||
return (
|
||||
<li
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
listStyle: 'none',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: isDragging ? 'var(--blue)' : 'transparent',
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
aria-label={`Reorder ${section.label}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--dim)', cursor: 'grab', padding: '2px 4px', touchAction: 'none' }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
|
||||
<circle cx="9" cy="6" r="1.6" /><circle cx="15" cy="6" r="1.6" />
|
||||
<circle cx="9" cy="12" r="1.6" /><circle cx="15" cy="12" r="1.6" />
|
||||
<circle cx="9" cy="18" r="1.6" /><circle cx="15" cy="18" r="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
className="input"
|
||||
value={section.label}
|
||||
maxLength={64}
|
||||
placeholder="Section name"
|
||||
onChange={(e) => onChange({ ...section, label: e.target.value })}
|
||||
aria-label={`Name for section ${index + 1}`}
|
||||
style={{ flex: '1 1 auto', minWidth: 120, padding: '5px 8px', fontSize: '0.84rem', fontWeight: 600 }}
|
||||
/>
|
||||
<span className="sans dim" style={{ fontSize: '0.7rem' }}>dropdown</span>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
title="Delete this section — the entries inside move back out, they are not removed"
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
background: 'transparent',
|
||||
color: 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.76rem',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PublicNavTree({ tree, onChange }) {
|
||||
const [adding, setAdding] = useState(null) // {label, to, error} while the modal is open
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
const sections = tree.filter((n) => n.kind === 'section')
|
||||
const destinations = [{ value: '', label: 'Top level' }, ...sections.map((s) => ({ value: s.id, label: s.label || 'Section' }))]
|
||||
const keyOf = (node) => (node.kind === 'item' ? node.to : node.id)
|
||||
|
||||
// Every mutation rebuilds the tree; there is no partial in-place editing, which
|
||||
// keeps "what will be saved" exactly "what is on screen".
|
||||
const replace = (nextTree) => onChange(nextTree)
|
||||
|
||||
const updateNode = (key, next) =>
|
||||
replace(
|
||||
tree.map((node) => {
|
||||
if (keyOf(node) === key) return next
|
||||
if (node.kind !== 'section') return node
|
||||
return { ...node, items: node.items.map((child) => (keyOf(child) === key ? next : child)) }
|
||||
}),
|
||||
)
|
||||
|
||||
// Moving between containers is the dropdown, not a drag. The entry lands at the
|
||||
// end of its destination, where it is visible and can then be dragged home.
|
||||
const moveTo = (key, sectionId) => {
|
||||
let moving = null
|
||||
const stripped = tree
|
||||
.map((node) => {
|
||||
if (node.kind === 'section') {
|
||||
const items = node.items.filter((child) => {
|
||||
if (keyOf(child) !== key) return true
|
||||
moving = child
|
||||
return false
|
||||
})
|
||||
return { ...node, items }
|
||||
}
|
||||
if (keyOf(node) === key) {
|
||||
moving = node
|
||||
return null
|
||||
}
|
||||
return node
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (!moving) return
|
||||
if (!sectionId) return replace([...stripped, moving])
|
||||
return replace(
|
||||
stripped.map((node) => (node.kind === 'section' && node.id === sectionId ? { ...node, items: [...node.items, moving] } : node)),
|
||||
)
|
||||
}
|
||||
|
||||
const addSection = () => replace([...tree, { kind: 'section', id: uid('sec'), label: 'New section', items: [] }])
|
||||
|
||||
// Deleting a section must NOT delete what is inside it: those are coded pages
|
||||
// and the admin's own links, and losing them to a mis-click would be the one
|
||||
// destructive act this screen could commit. They move back to the top level.
|
||||
const deleteSection = (id) => {
|
||||
const section = tree.find((n) => n.kind === 'section' && n.id === id)
|
||||
if (!section) return
|
||||
replace([...tree.filter((n) => keyOf(n) !== id), ...(section.items || [])])
|
||||
}
|
||||
|
||||
const deleteLink = (id) =>
|
||||
replace(
|
||||
tree
|
||||
.filter((n) => keyOf(n) !== id)
|
||||
.map((n) => (n.kind === 'section' ? { ...n, items: n.items.filter((c) => keyOf(c) !== id) } : n)),
|
||||
)
|
||||
|
||||
const submitLink = () => {
|
||||
const error = badLinkPath(adding.to)
|
||||
if (error) return setAdding({ ...adding, error })
|
||||
const label = adding.label.trim()
|
||||
if (!label) return setAdding({ ...adding, error: 'Give the link a name.' })
|
||||
replace([...tree, { kind: 'link', id: uid('lnk'), label, to: adding.to.trim() }])
|
||||
return setAdding(null)
|
||||
}
|
||||
|
||||
const onDragEnd = (containerId) => (event) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
if (containerId === null) {
|
||||
const from = tree.findIndex((n) => keyOf(n) === active.id)
|
||||
const to = tree.findIndex((n) => keyOf(n) === over.id)
|
||||
if (from < 0 || to < 0) return
|
||||
return replace(arrayMove(tree, from, to))
|
||||
}
|
||||
return replace(
|
||||
tree.map((node) => {
|
||||
if (node.kind !== 'section' || node.id !== containerId) return node
|
||||
const from = node.items.findIndex((c) => keyOf(c) === active.id)
|
||||
const to = node.items.findIndex((c) => keyOf(c) === over.id)
|
||||
if (from < 0 || to < 0) return node
|
||||
return { ...node, items: arrayMove(node.items, from, to) }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const renderRow = (node, sectionId) => (
|
||||
<Row
|
||||
key={keyOf(node)}
|
||||
id={keyOf(node)}
|
||||
row={node}
|
||||
destinations={destinations}
|
||||
destination={sectionId ?? ''}
|
||||
onDestination={(value) => moveTo(keyOf(node), value)}
|
||||
onChange={(next) => updateNode(keyOf(node), next)}
|
||||
onDelete={node.kind === 'link' ? () => deleteLink(node.id) : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(null)}>
|
||||
<SortableContext items={tree.map(keyOf)} strategy={verticalListSortingStrategy}>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: 0, padding: 0 }}>
|
||||
{tree.map((node, index) =>
|
||||
node.kind === 'section' ? (
|
||||
<SectionCard
|
||||
key={node.id}
|
||||
section={node}
|
||||
index={index}
|
||||
onChange={(next) => updateNode(node.id, next)}
|
||||
onDelete={() => deleteSection(node.id)}
|
||||
>
|
||||
{/* A nested context, so a drag inside a dropdown reorders that
|
||||
dropdown rather than escaping into the header. */}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd(node.id)}>
|
||||
<SortableContext items={(node.items || []).map(keyOf)} strategy={verticalListSortingStrategy}>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '10px 0 0', padding: '0 0 0 22px' }}>
|
||||
{(node.items || []).map((child) => renderRow(child, node.id))}
|
||||
{(node.items || []).length === 0 && (
|
||||
<li className="sans dim" style={{ fontSize: '0.76rem', listStyle: 'none', padding: '4px 2px' }}>
|
||||
Empty — an empty dropdown is not shown on the site.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</SectionCard>
|
||||
) : (
|
||||
renderRow(node, null)
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="pill" onClick={addSection}>
|
||||
+ Add dropdown section
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => setAdding({ label: '', to: '', error: null })}>
|
||||
+ Add link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<Modal
|
||||
title="Add a link"
|
||||
onClose={() => setAdding(null)}
|
||||
width={480}
|
||||
footer={
|
||||
<>
|
||||
<button className="pill" onClick={() => setAdding(null)}>Cancel</button>
|
||||
<button className="btn btn-primary btn-sq" onClick={submitLink}>Add link</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={adding.label}
|
||||
maxLength={64}
|
||||
placeholder="Player Guide"
|
||||
onChange={(e) => setAdding({ ...adding, label: e.target.value, error: null })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Path on this site</span>
|
||||
<input
|
||||
className="input"
|
||||
value={adding.to}
|
||||
maxLength={128}
|
||||
placeholder="/wiki/new-player-guide"
|
||||
onChange={(e) => setAdding({ ...adding, to: e.target.value, error: null })}
|
||||
/>
|
||||
</label>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.76rem', lineHeight: 1.7 }}>
|
||||
Links point somewhere on this site — a wiki page, a custom page, any section of the site.
|
||||
They are not gated: the page itself still decides who may open it, so a link to something
|
||||
restricted behaves exactly as typing its address would.
|
||||
</p>
|
||||
{adding.error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{adding.error}</span>}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export default function ShardAdmin() {
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [wsUrl, setWsUrl] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [protocol, setProtocol] = useState(1)
|
||||
const [protocol, setProtocol] = useState(3)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
@@ -172,7 +172,7 @@ export default function ShardAdmin() {
|
||||
if (!initializedRef.current) {
|
||||
setBaseUrl(c.baseUrl || '')
|
||||
setWsUrl(c.wsUrl || '')
|
||||
setProtocol(c.protocol || 1)
|
||||
setProtocol(c.protocol || 3)
|
||||
setEnabled(c.enabled)
|
||||
initializedRef.current = true
|
||||
}
|
||||
|
||||
@@ -65,8 +65,15 @@ const FIELD_LABEL = {
|
||||
price: 'House price',
|
||||
location: 'In-game location (map + coordinates)',
|
||||
connect: 'Server connect address',
|
||||
characterName: 'Character names',
|
||||
// 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 }) {
|
||||
|
||||
285
client/src/routes/admin/views/SpawnAtlas.jsx
Normal file
285
client/src/routes/admin/views/SpawnAtlas.jsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||
import { api } from '../../../api/client.js'
|
||||
|
||||
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
|
||||
//
|
||||
// The atlas re-derives itself from the shard's ServUO tree on every boot, so
|
||||
// this panel exists for the three things a restart cannot do:
|
||||
//
|
||||
// • point it at a different tree,
|
||||
// • apply a map change without restarting, and
|
||||
// • answer a refresh that was parsed but deliberately NOT applied because it
|
||||
// would remove a facet.
|
||||
//
|
||||
// That last one is the reason the panel is worth building. Losing a facet looks
|
||||
// exactly like a half-copied or mid-update tree, and boot cannot tell them
|
||||
// apart — so it stages the decision for a human instead of guessing. Until
|
||||
// someone decides here, the site keeps serving the atlas it already had.
|
||||
|
||||
// A refresh reports its outcome rather than throwing (the boot path must never
|
||||
// be stopped by a bad tree), so these are answers, not errors — the panel says
|
||||
// what happened in the shard's terms instead of showing a failure box.
|
||||
const OUTCOME = {
|
||||
imported: (r) =>
|
||||
`Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`,
|
||||
unchanged: (r) =>
|
||||
r.reason === 'refresh previously rejected'
|
||||
? 'Unchanged — this exact tree was already reviewed and declined.'
|
||||
: 'Unchanged — the tree matches what is already loaded.',
|
||||
needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.',
|
||||
unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`,
|
||||
skipped: () => 'No ServUO path is configured, so there is nothing to import.',
|
||||
failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`,
|
||||
rejected: () => 'Declined. It will not be offered again until the tree changes.',
|
||||
}
|
||||
|
||||
const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result)
|
||||
|
||||
function Row({ label, children }) {
|
||||
return (
|
||||
<div
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
padding: '7px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span className="dim">{label}</span>
|
||||
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingReview({ pending, busy, onApprove, onReject }) {
|
||||
const declined = pending.status === 'rejected'
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
|
||||
}}
|
||||
>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
{declined ? (
|
||||
<>
|
||||
This tree was reviewed and declined, so it is not offered again until the files change.
|
||||
Approving now applies it anyway.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
The tree parses cleanly but would <strong>remove {pending.removedFacets?.length || 0} facet
|
||||
</strong>
|
||||
{(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That
|
||||
is what a half-copied or mid-update tree looks like as well as a real map change, so it was
|
||||
not applied. Approving re-parses the tree as it is right now — if you have since fixed the
|
||||
mount, what lands is the corrected import.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
|
||||
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
|
||||
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
|
||||
Approve and import
|
||||
</button>
|
||||
{!declined && (
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
|
||||
Keep the current atlas
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SpawnAtlas() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [path, setPath] = useState('')
|
||||
const [force, setForce] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.admin.atlas.status()
|
||||
setStatus(data)
|
||||
setPath(data.path || '')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load atlas status.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Every mutating action shares this: run it, report what it said, then reload
|
||||
// status so the panel reflects the world rather than what we assumed happened.
|
||||
async function run(action, fn) {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const result = await fn()
|
||||
setMsg(describe(result))
|
||||
const fresh = await api.admin.atlas.status()
|
||||
setStatus(fresh)
|
||||
setPath(fresh.path || '')
|
||||
} catch (err) {
|
||||
setError(err.message || `Could not ${action}.`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function savePath() {
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setError('')
|
||||
try {
|
||||
const fresh = await api.admin.atlas.setPath(path.trim())
|
||||
setStatus(fresh)
|
||||
setPath(fresh.path || '')
|
||||
setMsg(
|
||||
fresh.path === ''
|
||||
? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.'
|
||||
: fresh.treeReadable
|
||||
? 'Saved. The tree is readable — import when you are ready.'
|
||||
: 'Saved, but the tree could not be read from here. Check the mount and permissions.',
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not save the path.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error && !status) return <ErrorState message={error} />
|
||||
|
||||
const counts = status?.counts || null
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<header>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Spawn atlas
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files.
|
||||
It refreshes itself on every server start; everything here is for the times you don’t want
|
||||
to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not
|
||||
shard state, and stays complete while the shard is down.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{status?.pending && (
|
||||
<PendingReview
|
||||
pending={status.pending}
|
||||
busy={busy}
|
||||
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
|
||||
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
What is loaded
|
||||
</h3>
|
||||
<Row label="Imported">
|
||||
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
|
||||
</Row>
|
||||
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
|
||||
{counts && (
|
||||
<>
|
||||
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
|
||||
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
|
||||
<Row label="Regions / landmarks">
|
||||
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
|
||||
</Row>
|
||||
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
|
||||
</>
|
||||
)}
|
||||
<Row label="Tree readable">
|
||||
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
|
||||
</Row>
|
||||
<Row label="Tree changed since import">
|
||||
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
ServUO tree
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
Where the website reads the shard’s spawn files from — the same host, a bind mount or a
|
||||
shared volume. This setting wins over the <code>SERVUO_PATH</code> deploy default, so the
|
||||
mount can move without a redeploy. Leave it blank to turn the atlas off.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="/srv/servuo"
|
||||
style={{ flex: '1 1 320px', minWidth: 0 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
|
||||
Save path
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Re-import
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
Applies a map change without restarting. An unchanged tree costs nothing — the source files
|
||||
are hashed first and skipped when they match. A refresh that would remove a facet still
|
||||
comes back here for approval rather than being applied.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy || !status?.configured}
|
||||
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
|
||||
>
|
||||
{busy ? 'Working…' : 'Import now'}
|
||||
</button>
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
|
||||
Re-import even if the tree is unchanged
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(msg || error) && (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||
|
||||
// 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
|
||||
@@ -30,7 +34,11 @@ const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0
|
||||
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 NAV = [
|
||||
// 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
|
||||
// carries a gate — every player sees all three — so the merged result is what
|
||||
// renders, with no filter after it.
|
||||
export const NAV = [
|
||||
{ to: '/player', label: 'Characters', end: true, icon: IconUser },
|
||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||
@@ -60,6 +68,8 @@ const navBtnBase = {
|
||||
export default function PlayerPortalLayout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { siteTitle } = useSite()
|
||||
const navOverrides = useNavOverrides()
|
||||
const nav = useMemo(() => applyNavOverrides(NAV, navOverrides.nav_player), [navOverrides.nav_player])
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const title =
|
||||
@@ -86,6 +96,7 @@ export default function PlayerPortalLayout() {
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<BrandLogo height={24} />
|
||||
<MoonDot />
|
||||
<div>
|
||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||
@@ -98,7 +109,7 @@ export default function PlayerPortalLayout() {
|
||||
</div>
|
||||
|
||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||
{NAV.map((n) => (
|
||||
{nav.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
// Centered card layout shared by the player login / register pages. `subtitle`
|
||||
@@ -25,6 +26,10 @@ export default function PlayerShell({ subtitle, children, footer }) {
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
{/* Stacked above the moon rather than beside it: this layout is
|
||||
centered text, and a flex row here would change the block's
|
||||
height on instances with no logo. */}
|
||||
<BrandLogo height={34} style={{ margin: '0 auto 12px' }} />
|
||||
<MoonDot size={15} glow={0.55} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
|
||||
240
client/src/routes/public/Leaderboards.jsx
Normal file
240
client/src/routes/public/Leaderboards.jsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { api } from '../../api/client.js'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
|
||||
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
|
||||
// loyalties, the Doom/Khaldun/Kotl treasure systems — every one of them a standing
|
||||
// players build over months, and none of them visible anywhere but an in-game gump
|
||||
// until now.
|
||||
//
|
||||
// Loaded from /public/shard/points, then kept current from the live feed. Unlike
|
||||
// the ruleset (one frame = the whole thing), a points.board frame describes ONE
|
||||
// system, so live frames are merged over the fetched set by system key rather than
|
||||
// replacing it.
|
||||
const POINTS_KINDS = new Set(['points.board'])
|
||||
|
||||
// A board's display name may arrive as a literal (`nameString`), a cliloc id
|
||||
// (`nameNumber`), or both — Name is a ServUO TextDefinition. We have no cliloc
|
||||
// table on the site, so a cliloc-only board falls back to humanising its own
|
||||
// PointsType key, which is already close to a display name ("CleanUpBritannia" →
|
||||
// "Clean Up Britannia"). Better than showing a bare number.
|
||||
const humanise = (key) =>
|
||||
String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
const boardTitle = (b) => b.nameString || humanise(b.system)
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Merge live frames over the fetched boards. Newest frame per system wins; a
|
||||
// system that has never appeared in either is simply absent.
|
||||
function mergeBoards(fetched, events) {
|
||||
const bySystem = new Map()
|
||||
for (const b of Array.isArray(fetched) ? fetched : []) {
|
||||
if (b && b.system) bySystem.set(b.system, b)
|
||||
}
|
||||
// Events arrive newest-first, so walk backwards and let the newest land last.
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const ev = events[i]
|
||||
if (ev && ev.system) bySystem.set(ev.system, ev)
|
||||
}
|
||||
return [...bySystem.values()].sort((a, b) => boardTitle(a).localeCompare(boardTitle(b)))
|
||||
}
|
||||
|
||||
function Medal({ rank }) {
|
||||
// Gold / silver / bronze for the podium, plain for the rest.
|
||||
const tone = rank === 1 ? '#c9a24b' : rank === 2 ? '#b6bcc6' : rank === 3 ? '#b3805a' : 'var(--muted)'
|
||||
return (
|
||||
<span
|
||||
className="display"
|
||||
style={{
|
||||
flex: 'none', width: 26, textAlign: 'right', color: tone,
|
||||
fontSize: rank <= 3 ? '1rem' : '0.86rem',
|
||||
}}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// One ranked player. `name` is absent rather than empty when an admin has gated
|
||||
// the leaderboards `name` field above this viewer's rung — the row still renders,
|
||||
// because the standing itself is the point.
|
||||
function Entry({ entry, best }) {
|
||||
const pct = best > 0 ? Math.max(2, Math.round((entry.points / best) * 100)) : 0
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
||||
<Medal rank={entry.rank} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: entry.name ? 'var(--ink)' : 'var(--muted)',
|
||||
fontSize: '0.86rem', fontStyle: entry.name ? 'normal' : 'italic',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{entry.name || 'Name hidden'}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{num(entry.points)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden', marginTop: 3 }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Board({ board }) {
|
||||
const { siteTitle } = useSite()
|
||||
const top = Array.isArray(board.top) ? board.top : []
|
||||
// Bars are relative to the board leader, not to maxPoints: most systems have no
|
||||
// cap (maxPoints 0), and where there is one the leader is often nowhere near it,
|
||||
// which would render every bar as a stub.
|
||||
const best = top.reduce((m, e) => Math.max(m, e.points || 0), 0)
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{boardTitle(board)}
|
||||
</h2>
|
||||
{Number.isFinite(board.players) && (
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem', flex: 'none' }}>
|
||||
{num(board.players)} ranked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{top.length === 0 ? (
|
||||
// A board nobody has scored on still gets a row, so the page reads as a set
|
||||
// of standings waiting to be filled rather than a stack of blanks. It is
|
||||
// deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
|
||||
// a score goes — because a placeholder that looked like a real standing would
|
||||
// be a fabricated one. The first real entry replaces it.
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
color: 'var(--muted)', fontSize: '0.86rem',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{siteTitle}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>—</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,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||
|
||||
export default function Maintenance() {
|
||||
@@ -28,6 +29,7 @@ export default function Maintenance() {
|
||||
>
|
||||
<div style={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
|
||||
<div style={{ marginBottom: 26 }}>
|
||||
<BrandLogo height={40} style={{ margin: '0 auto 16px' }} />
|
||||
<MoonDot size={18} glow={0.6} />
|
||||
</div>
|
||||
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}>
|
||||
|
||||
325
client/src/routes/public/Market.jsx
Normal file
325
client/src/routes/public/Market.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// ── The player-vendor marketplace ───────────────────────────────────────────
|
||||
//
|
||||
// What every player vendor on the shard is selling, for how much, and where it
|
||||
// is standing — the same index the in-game Vendor Search gump reads, honouring
|
||||
// the same per-vendor opt-out, reachable without logging in to the game.
|
||||
//
|
||||
// Three things this page must be honest about, all of them consequences of how
|
||||
// the data is gathered (docs/link/v3.md §8):
|
||||
//
|
||||
// • **The prices are not live.** The shard sweeps vendors round-robin, so a
|
||||
// shop can be a full cycle behind. The banner says how far, from `staleAt`.
|
||||
// A page that implied live prices would send people across the world to a
|
||||
// vendor whose item sold twenty minutes ago.
|
||||
// • **A shop can be truncated.** A commodity reseller with thousands of stacks
|
||||
// publishes only the first N, and saying so beats presenting a partial shop
|
||||
// as complete.
|
||||
// • **An item may have no name.** On a shard whose operator has not converted
|
||||
// a cliloc table, `displayName` is null and the honest render is the item id
|
||||
// — not an invented name.
|
||||
//
|
||||
// There is deliberately no live feed here. The market feature's SSE stream ships
|
||||
// disabled: a firehose of whole vendor inventories would be the site's single
|
||||
// biggest bandwidth consumer, and nothing on this page needs it.
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||
|
||||
const SORTS = [
|
||||
{ key: 'price_asc', label: 'Cheapest' },
|
||||
{ key: 'price_desc', label: 'Priciest' },
|
||||
{ key: 'recent', label: 'Recently seen' },
|
||||
]
|
||||
|
||||
// How old the index may be, in words. `staleAt` is the OLDEST vendor row, so
|
||||
// this is a worst case rather than an average — which is the number worth
|
||||
// showing, because the one stale shop is the one that wastes a trip.
|
||||
function staleness(staleAt) {
|
||||
if (!staleAt) return null
|
||||
const ms = Date.now() - new Date(staleAt).getTime()
|
||||
if (!Number.isFinite(ms) || ms < 0) return null
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`
|
||||
const hours = Math.round(mins / 60)
|
||||
if (hours < 48) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||
return `${Math.round(hours / 24)} days ago`
|
||||
}
|
||||
|
||||
// The item's name, or an honest statement that we do not have one. Never a
|
||||
// fabricated label — "Item 3922" would be indistinguishable from a real name.
|
||||
const itemLabel = (l) => l.displayName || l.name || `id ${l.itemId}`
|
||||
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ListingRow({ listing }) {
|
||||
const v = listing.vendor || {}
|
||||
// `location` is one field the admin can gate away wholesale, so everything
|
||||
// that reads from it has to tolerate its absence rather than assuming a map.
|
||||
const loc = v.location || null
|
||||
const where = loc ? [loc.region, loc.map].filter(Boolean).join(', ') : null
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
style={{ fontSize: '0.98rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{listing.amount > 1 ? `${num(listing.amount)} × ` : ''}
|
||||
{itemLabel(listing)}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{v.serial ? (
|
||||
<Link to={`/site/market/vendors/${encodeURIComponent(v.serial)}`} style={{ color: 'inherit' }}>
|
||||
{v.shopName || 'an unnamed shop'}
|
||||
</Link>
|
||||
) : (
|
||||
v.shopName || 'an unnamed shop'
|
||||
)}
|
||||
{v.ownerName ? ` · ${v.ownerName}` : ''}
|
||||
{where ? ` · ${where}` : ''}
|
||||
{/* Priced by the container it sits in, exactly as the in-game search
|
||||
reports it — the price buys the whole container, not this item. */}
|
||||
{listing.child ? ' · sold with its container' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(listing.price)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>gold</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Market() {
|
||||
const [input, setInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [map, setMap] = useState('')
|
||||
const [region, setRegion] = useState('')
|
||||
const [sort, setSort] = useState('price_asc')
|
||||
const [minPrice, setMinPrice] = useState('')
|
||||
const [maxPrice, setMaxPrice] = useState('')
|
||||
// Applied prices are separate from the typed ones so the search fires when the
|
||||
// user is done, not on every digit of "250000".
|
||||
const [prices, setPrices] = useState({ min: '', max: '' })
|
||||
|
||||
const [state, setState] = useState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||
const [more, setMore] = useState(false)
|
||||
|
||||
const meta = useAsync(() => api.shard.marketMeta())
|
||||
|
||||
// Debounced: typing "vanquishing" should be one request, not eleven — and the
|
||||
// endpoint is rate-limited, so an undebounced box would 429 a fast typist.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setQ(input.trim()), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [input])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setPrices({ min: minPrice, max: maxPrice }), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [minPrice, maxPrice])
|
||||
|
||||
const load = useCallback(
|
||||
(offset) =>
|
||||
api.shard.market({
|
||||
q,
|
||||
map,
|
||||
region,
|
||||
sort,
|
||||
minPrice: prices.min,
|
||||
maxPrice: prices.max,
|
||||
limit: PAGE,
|
||||
offset,
|
||||
}),
|
||||
[q, map, region, sort, prices],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ loading: true, error: null, listings: [], total: 0, staleAt: null })
|
||||
load(0)
|
||||
.then((page) => {
|
||||
if (!alive) return
|
||||
setState({
|
||||
loading: false,
|
||||
error: null,
|
||||
listings: page.listings || [],
|
||||
total: page.total || 0,
|
||||
staleAt: page.staleAt || null,
|
||||
})
|
||||
})
|
||||
.catch((error) => alive && setState({ loading: false, error, listings: [], total: 0, staleAt: null }))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const loadMore = async () => {
|
||||
setMore(true)
|
||||
try {
|
||||
const page = await load(state.listings.length)
|
||||
setState((s) => ({
|
||||
...s,
|
||||
listings: [...s.listings, ...(page.listings || [])],
|
||||
total: page.total ?? s.total,
|
||||
staleAt: page.staleAt ?? s.staleAt,
|
||||
}))
|
||||
} catch {
|
||||
// A failed "load more" leaves what is on screen alone; the button stays
|
||||
// available to retry.
|
||||
} finally {
|
||||
setMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const maps = meta.data?.maps || []
|
||||
const regions = meta.data?.regions || []
|
||||
const age = staleness(state.staleAt)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow="Marketplace"
|
||||
title="Player vendors"
|
||||
lead="Every shop on the shard, searchable from here — the same index the in-game vendor search reads, and it honours the same per-vendor opt-out."
|
||||
/>
|
||||
|
||||
{/* Not decoration. The sweep is round-robin, so the index is inherently
|
||||
up to one full cycle old and the page has to say so. */}
|
||||
{age && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||
Prices last refreshed {age}
|
||||
{meta.data?.vendors ? ` · ${num(meta.data.vendors)} shops` : ''}
|
||||
{meta.data?.items ? ` · ${num(meta.data.items)} listings` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Search listings…"
|
||||
style={{ width: '100%', marginBottom: 10 }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(e.target.value)}
|
||||
placeholder="Min price"
|
||||
style={{ maxWidth: 140 }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min="0"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
placeholder="Max price"
|
||||
style={{ maxWidth: 140 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
{SORTS.map((s) => (
|
||||
<Chip key={s.key} active={sort === s.key} onClick={() => setSort(s.key)}>
|
||||
{s.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Facet and region names come from the shard's own data, never a list in
|
||||
this file — a shard running custom maps gets its own names here with
|
||||
no code change (docs/link/v3.md §6.1 R2). */}
|
||||
{maps.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
<Chip active={map === ''} onClick={() => setMap('')}>All facets</Chip>
|
||||
{maps.map((m) => (
|
||||
<Chip key={m} active={map === m} onClick={() => setMap(m)}>{m}</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{regions.length > 0 && (
|
||||
<select
|
||||
className="input"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
style={{ width: '100%', marginBottom: 18 }}
|
||||
>
|
||||
<option value="">Anywhere</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{state.loading && <Loading />}
|
||||
{state.error && <ErrorState message="Could not load the marketplace right now." />}
|
||||
|
||||
{!state.loading && !state.error && state.listings.length === 0 && (
|
||||
<EmptyState>
|
||||
{meta.data?.vendors
|
||||
? 'Nothing on the shard matches that.'
|
||||
: 'No player vendors have been indexed yet.'}
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{!state.loading && !state.error && state.listings.length > 0 && (
|
||||
<>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
Showing {num(state.listings.length)} of {num(state.total)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.listings.map((l) => (
|
||||
<ListingRow key={`${l.vendor?.serial}:${l.serial}`} listing={l} />
|
||||
))}
|
||||
</div>
|
||||
{state.listings.length < state.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||
{more ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
102
client/src/routes/public/MarketVendor.jsx
Normal file
102
client/src/routes/public/MarketVendor.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||
import { useAsync } from '../../lib/useAsync.js'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// One player vendor: where to find it and everything it is selling.
|
||||
//
|
||||
// The page a search result points at. Two states it has to render honestly and
|
||||
// which the search list cannot (docs/link/v3.md §8):
|
||||
//
|
||||
// • `truncated` — the shop holds more than the shard publishes per frame. A
|
||||
// commodity reseller with thousands of stacks is a real thing, and showing
|
||||
// 250 of 3,104 as if it were the whole shop would be a lie about the shard.
|
||||
// • a gated `location` — an admin may put vendor whereabouts behind a rung, in
|
||||
// which case there is nothing to render and the page says so rather than
|
||||
// showing an empty coordinate.
|
||||
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v).toLocaleString() : '—')
|
||||
|
||||
const itemLabel = (i) => i.displayName || i.name || `id ${i.itemId}`
|
||||
|
||||
export default function MarketVendor() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.shard.marketVendor(serial), [serial])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body"><Loading /></div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<ErrorState message="That shop is not in the index — it may have been dismissed or hidden." />
|
||||
<p style={{ marginTop: 16 }}>
|
||||
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const loc = data.location || null
|
||||
const items = data.items || []
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow={data.ownerName ? `Run by ${data.ownerName}` : 'Player vendor'}
|
||||
title={data.shopName || 'An unnamed shop'}
|
||||
lead={
|
||||
loc
|
||||
? [loc.house, loc.region, loc.map].filter(Boolean).join(' · ') +
|
||||
(Number.isFinite(loc.x) ? ` — ${loc.x}, ${loc.y}` : '')
|
||||
: 'This shard does not publish vendor locations.'
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '-12px 0 18px' }}>
|
||||
{data.truncated
|
||||
? `Showing ${num(data.count)} of ${num(data.total)} listings — this shop holds more than the shard publishes.`
|
||||
: `${num(data.total)} listing${data.total === 1 ? '' : 's'}`}
|
||||
{data.updatedAt ? ` · last seen ${new Date(data.updatedAt).toLocaleString()}` : ''}
|
||||
</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState>This shop has nothing priced for sale.</EmptyState>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{items.map((i) => (
|
||||
<div
|
||||
key={i.serial}
|
||||
className="panel"
|
||||
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}
|
||||
>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{i.amount > 1 ? `${num(i.amount)} × ` : ''}
|
||||
{itemLabel(i)}
|
||||
{i.child ? <span className="dim"> · sold with its container</span> : null}
|
||||
</span>
|
||||
<span className="sans" style={{ flex: 'none', color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{num(i.price)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ marginTop: 20 }}>
|
||||
<Link to="/site/market" className="sans">← Back to the marketplace</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,22 @@
|
||||
|
||||
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
|
||||
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
||||
|
||||
/* Corner radius, by the kind of surface rather than by the pixel value, so a
|
||||
theme preset can restyle all of them at once (see
|
||||
docs/website/THEMING_AND_NAV.md §4.7). Seeded at the values already in use
|
||||
— this promotion is a no-op, and every existing instance must keep looking
|
||||
exactly as it does today.
|
||||
|
||||
Deliberately four tokens, not three: .card/.panel are 10px and .panel-flat
|
||||
is 12px, so collapsing them would have restyled every card on every
|
||||
install. The 7px (.rte-btn) and 6px (.rte-linkmenu-item) values stay
|
||||
literals — interior editor chrome, not brand surface — as do the 50%
|
||||
circles, which are shapes rather than radii. */
|
||||
--radius-pill: 999px;
|
||||
--radius-panel: 12px;
|
||||
--radius-card: 10px;
|
||||
--radius-input: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -99,7 +115,7 @@ a {
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
background: var(--panel-grad);
|
||||
@@ -123,19 +139,19 @@ a.card:focus-visible {
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--panel-grad);
|
||||
}
|
||||
.panel-flat {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-panel);
|
||||
overflow: hidden;
|
||||
background: var(--panel-flat);
|
||||
}
|
||||
.note {
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: rgba(19, 36, 60, 0.4);
|
||||
padding: 18px 22px;
|
||||
color: var(--muted);
|
||||
@@ -168,12 +184,20 @@ a.card:focus-visible {
|
||||
/* ===== Pills / buttons ===== */
|
||||
.pill {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 7px 14px;
|
||||
color: var(--muted);
|
||||
background: rgba(11, 22, 48, 0.5);
|
||||
font-family: var(--sans);
|
||||
font-size: 0.86rem;
|
||||
/* Stated, not inherited. A <button class="pill"> would otherwise take the UA
|
||||
stylesheet's `line-height: normal` — form controls do not inherit it from
|
||||
body — and come out ~7px shorter than an <a class="pill"> beside it. Every
|
||||
other property here is already explicit for the same reason; this was the
|
||||
one gap, and it only became visible once the public header put a button
|
||||
pill (a dropdown trigger) on the same row as the link pills. Matches
|
||||
body's 1.6, so no link pill changes. */
|
||||
line-height: 1.6;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
@@ -186,7 +210,7 @@ a.card:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.btn {
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 12px 26px;
|
||||
font-family: var(--sans);
|
||||
font-size: 0.92rem;
|
||||
@@ -214,7 +238,7 @@ a.card:focus-visible {
|
||||
background: var(--blue);
|
||||
}
|
||||
.btn-sq {
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 10px 18px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -230,7 +254,7 @@ button[disabled] {
|
||||
.select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 11px 14px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
@@ -312,7 +336,7 @@ button[disabled] {
|
||||
}
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -320,7 +344,7 @@ button[disabled] {
|
||||
.rte {
|
||||
position: relative;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--bg);
|
||||
}
|
||||
.rte:focus-within {
|
||||
@@ -407,7 +431,7 @@ button[disabled] {
|
||||
width: min(360px, calc(100% - 20px));
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--panel-a);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
@@ -449,7 +473,7 @@ button[disabled] {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(127, 153, 189, 0.1);
|
||||
color: var(--accent);
|
||||
font-family: var(--sans);
|
||||
@@ -494,7 +518,7 @@ button[disabled] {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--panel-flat);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
@@ -532,7 +556,7 @@ button[disabled] {
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--bg);
|
||||
}
|
||||
.diff-add {
|
||||
@@ -603,7 +627,7 @@ button[disabled] {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge {
|
||||
border-radius: 999px;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 3px 11px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
@@ -780,7 +804,7 @@ button[disabled] {
|
||||
}
|
||||
.page-image img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
border: 1px solid var(--line);
|
||||
display: block;
|
||||
}
|
||||
@@ -863,7 +887,7 @@ button[disabled] {
|
||||
}
|
||||
.pb-column-editor {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 12px;
|
||||
background: var(--panel-flat, transparent);
|
||||
}
|
||||
@@ -881,7 +905,7 @@ button[disabled] {
|
||||
}
|
||||
.pb-subblock {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background: var(--bg);
|
||||
@@ -919,7 +943,7 @@ button[disabled] {
|
||||
border: 1px solid #6e3b38;
|
||||
background: rgba(110, 59, 56, 0.16);
|
||||
color: #e6a9a3;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 10px 14px;
|
||||
margin-top: 14px;
|
||||
font-size: 0.86rem;
|
||||
@@ -928,7 +952,7 @@ button[disabled] {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--blue);
|
||||
color: var(--accent-bright);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 8px 14px;
|
||||
margin-top: 14px;
|
||||
font-size: 0.86rem;
|
||||
@@ -960,7 +984,7 @@ button[disabled] {
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pb-canvas {
|
||||
@@ -970,7 +994,7 @@ button[disabled] {
|
||||
}
|
||||
.pb-block-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--panel-flat, transparent);
|
||||
}
|
||||
.pb-block-card.is-dragging {
|
||||
@@ -1082,7 +1106,7 @@ button[disabled] {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--blue);
|
||||
color: var(--accent-bright);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-input);
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.85rem;
|
||||
|
||||
@@ -140,3 +140,44 @@ test('DELETE self-service session revoke encodes the id and uses the DELETE meth
|
||||
assert.equal(calls[0].opts.method, 'DELETE')
|
||||
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||
})
|
||||
|
||||
// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────
|
||||
// The atlas lives at /public/atlas, NOT under /public/shard: it is static shard
|
||||
// content parsed from the shard's own files, so it must not look sidecar-backed.
|
||||
// Asserted here because the split is a design decision, not an accident of
|
||||
// spelling.
|
||||
test('atlas reads hit /public/atlas, not /public/shard', async () => {
|
||||
willReply({ body: { creatures: [] } })
|
||||
await api.atlas.creatures()
|
||||
assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
|
||||
})
|
||||
|
||||
test('atlas.creatures() sends only the filters that are set', async () => {
|
||||
willReply({ body: { creatures: [] } })
|
||||
await api.atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 })
|
||||
const url = new URL(calls[0].url, 'http://x')
|
||||
assert.equal(url.pathname, '/api/v1/public/atlas/creatures')
|
||||
assert.equal(url.searchParams.get('q'), 'lizard man')
|
||||
assert.equal(url.searchParams.get('facet'), 'Ter Mur')
|
||||
assert.equal(url.searchParams.get('limit'), '25')
|
||||
assert.equal(url.searchParams.get('offset'), null) // 0 is not sent
|
||||
})
|
||||
|
||||
test('atlas.creature() encodes the slug and carries the facet filter through', async () => {
|
||||
willReply({ body: {} })
|
||||
await api.atlas.creature('lizardman/rare', { facet: 'Felucca' })
|
||||
assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/)
|
||||
})
|
||||
|
||||
test('admin atlas actions use the right methods and bodies', async () => {
|
||||
willReply({ body: {} })
|
||||
await api.admin.atlas.import(true)
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.equal(calls[0].opts.body, JSON.stringify({ force: true }))
|
||||
|
||||
willReply({ body: {} })
|
||||
await api.admin.atlas.setPath('/srv/servuo')
|
||||
assert.equal(calls[1].opts.method, 'PUT')
|
||||
assert.equal(calls[1].opts.body, JSON.stringify({ path: '/srv/servuo' }))
|
||||
})
|
||||
|
||||
544
client/test/navOverrides.test.js
Normal file
544
client/test/navOverrides.test.js
Normal file
@@ -0,0 +1,544 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
applyNavOverrides,
|
||||
buildNavRows,
|
||||
buildNavOverrides,
|
||||
buildPublicNav,
|
||||
pruneNav,
|
||||
buildPublicNavOverrides,
|
||||
} from '../src/lib/navOverrides.js'
|
||||
|
||||
// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece
|
||||
// of this feature with real correctness risk, so it is tested in isolation from
|
||||
// React. Two properties matter above all others:
|
||||
//
|
||||
// 1. No override, or a useless one, renders the coded nav untouched.
|
||||
// 2. The override cannot add a route, cannot touch a role/feature gate, and
|
||||
// cannot un-hide anything. It is presentation only.
|
||||
|
||||
const FLAT = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Wiki', to: '/wiki' },
|
||||
{ label: 'Shard', to: '/site/shard', feature: 'status' },
|
||||
]
|
||||
|
||||
const GROUPED = [
|
||||
{ items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
|
||||
{
|
||||
title: 'Content',
|
||||
items: [
|
||||
{ to: '/admin/posts', label: 'Posts', roles: ['admin', 'editor'] },
|
||||
{ to: '/admin/wiki', label: 'Wiki', roles: ['admin', 'editor'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ to: '/admin/settings', label: 'Settings', roles: ['admin'] },
|
||||
{ to: '/admin/users', label: 'Users', roles: ['admin'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const labels = (nav) => nav.map((i) => i.label)
|
||||
const groupLabels = (nav) => nav.map((g) => [g.title ?? null, g.items.map((i) => i.label)])
|
||||
|
||||
// ── The untouched path ────────────────────────────────────────────────────
|
||||
|
||||
// Most instances will never set these keys. Absence must be a true no-op, and
|
||||
// cheap: the same array reference back means no needless re-render either.
|
||||
test('no override returns the base nav unchanged', () => {
|
||||
for (const overrides of [null, undefined, '', 0, [], 'not an object']) {
|
||||
assert.equal(applyNavOverrides(FLAT, overrides), FLAT)
|
||||
}
|
||||
})
|
||||
|
||||
test('an override with nothing usable in it returns the base nav unchanged', () => {
|
||||
assert.equal(applyNavOverrides(FLAT, {}), FLAT)
|
||||
// Every field here is unusable: unknown route, blank label, non-numeric order,
|
||||
// hidden as a string rather than the boolean true.
|
||||
assert.equal(
|
||||
applyNavOverrides(FLAT, {
|
||||
'/does/not/exist': { label: 'Ghost', hidden: true },
|
||||
'/wiki': { label: ' ', order: 'first', hidden: 'yes' },
|
||||
}),
|
||||
FLAT,
|
||||
)
|
||||
})
|
||||
|
||||
// ── The security boundary ─────────────────────────────────────────────────
|
||||
|
||||
// The single most important negative case: the override layer must never be a
|
||||
// way to introduce a route into a nav.
|
||||
test('an unknown `to` is ignored, never added', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/admin/secret': { label: 'Secret', order: 0 } })
|
||||
assert.equal(out.length, FLAT.length)
|
||||
assert.ok(!out.some((i) => i.to === '/admin/secret'))
|
||||
})
|
||||
|
||||
test('roles, feature, icon, end and to survive the merge verbatim', () => {
|
||||
const out = applyNavOverrides(FLAT, {
|
||||
'/site/shard': { label: 'Server Status', roles: ['player'], feature: null, to: '/evil' },
|
||||
})
|
||||
const shard = out.find((i) => i.to === '/site/shard')
|
||||
assert.equal(shard.label, 'Server Status') // the one thing an override may set
|
||||
assert.equal(shard.feature, 'status') // gate untouched
|
||||
assert.equal(shard.roles, undefined) // and not invented
|
||||
assert.ok(!out.some((i) => i.to === '/evil'))
|
||||
})
|
||||
|
||||
test('hidden:false cannot un-hide anything — hiding is subtractive only', () => {
|
||||
// The item is still present after the merge; whether it renders is decided by
|
||||
// the caller's own role/feature filter, which this layer cannot reach.
|
||||
const out = applyNavOverrides(GROUPED, { '/admin/settings': { hidden: false } })
|
||||
assert.equal(out, GROUPED, 'a no-op override leaves the base nav alone')
|
||||
})
|
||||
|
||||
// ── Flat navs: label, order, hidden ───────────────────────────────────────
|
||||
|
||||
test('label overrides only the labelled item', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/site/news': { label: 'Announcements' } })
|
||||
assert.deepEqual(labels(out), ['Home', 'Announcements', 'Wiki', 'Shard'])
|
||||
})
|
||||
|
||||
test('hidden drops the item', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/wiki': { hidden: true } })
|
||||
assert.deepEqual(labels(out), ['Home', 'News', 'Shard'])
|
||||
})
|
||||
|
||||
// An item the admin never reordered keeps its position in the coded array, so
|
||||
// setting one order does not scramble the rest.
|
||||
test('order moves one item and leaves the others in code order', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/wiki': { order: -1 } })
|
||||
assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
|
||||
})
|
||||
|
||||
test('two items given the same order keep their code order (stable sort)', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/site/news': { order: 0 }, '/wiki': { order: 0 } })
|
||||
// News before Wiki — the tie resolves to the coded order, not to insertion
|
||||
// order in the settings JSON. Both precede Home, whose 0 is only its index.
|
||||
assert.deepEqual(labels(out), ['News', 'Wiki', 'Home', 'Shard'])
|
||||
})
|
||||
|
||||
// An explicit order and an untouched item's index share one number line, so
|
||||
// they can collide. "Put this first" has to actually mean first.
|
||||
test('an explicit order beats an untouched item that merely sits at that index', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/wiki': { order: 0 } })
|
||||
assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
|
||||
})
|
||||
|
||||
test('the merge does not mutate the base nav', () => {
|
||||
const before = JSON.stringify(FLAT)
|
||||
applyNavOverrides(FLAT, { '/wiki': { label: 'Library', order: 0, hidden: false } })
|
||||
assert.equal(JSON.stringify(FLAT), before)
|
||||
})
|
||||
|
||||
test('no internal sort key leaks into the returned items', () => {
|
||||
const out = applyNavOverrides(FLAT, { '/wiki': { order: 1 } })
|
||||
for (const item of out) assert.ok(!('__order' in item), 'sort key must not be rendered')
|
||||
})
|
||||
|
||||
// ── Grouped (admin) navs ──────────────────────────────────────────────────
|
||||
|
||||
test('label and order apply within a group', () => {
|
||||
const out = applyNavOverrides(GROUPED, {
|
||||
'/admin/wiki': { label: 'Knowledge Base', order: 0 },
|
||||
})
|
||||
assert.deepEqual(groupLabels(out), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Knowledge Base', 'Posts']],
|
||||
['System', ['Settings', 'Users']],
|
||||
])
|
||||
})
|
||||
|
||||
test('group moves an item into another existing section', () => {
|
||||
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content' } })
|
||||
assert.deepEqual(groupLabels(out), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Posts', 'Wiki', 'Users']],
|
||||
['System', ['Settings']],
|
||||
])
|
||||
})
|
||||
|
||||
// A group that does not exist must not conjure a header. Groups are chosen from
|
||||
// a dropdown of existing titles in the editor; this is the stale-row guard.
|
||||
test('a group that is not an existing title is ignored', () => {
|
||||
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Danger Zone' } })
|
||||
assert.deepEqual(groupLabels(out), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Posts', 'Wiki']],
|
||||
['System', ['Settings', 'Users']],
|
||||
])
|
||||
})
|
||||
|
||||
test('a moved item can be ordered in its new group', () => {
|
||||
const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content', order: -1 } })
|
||||
assert.deepEqual(groupLabels(out)[1], ['Content', ['Users', 'Posts', 'Wiki']])
|
||||
})
|
||||
|
||||
test('hiding every item in a group leaves no orphaned header', () => {
|
||||
const out = applyNavOverrides(GROUPED, {
|
||||
'/admin/settings': { hidden: true },
|
||||
'/admin/users': { hidden: true },
|
||||
})
|
||||
assert.deepEqual(groupLabels(out), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Posts', 'Wiki']],
|
||||
])
|
||||
})
|
||||
|
||||
test('group ordering itself is not overridable — sections stay in code order', () => {
|
||||
const out = applyNavOverrides(GROUPED, { '/admin/settings': { order: -99 } })
|
||||
assert.deepEqual(
|
||||
out.map((g) => g.title ?? null),
|
||||
[null, 'Content', 'System'],
|
||||
)
|
||||
})
|
||||
|
||||
// ── Degenerate input ──────────────────────────────────────────────────────
|
||||
|
||||
test('a non-array base nav yields an empty nav rather than throwing', () => {
|
||||
assert.deepEqual(applyNavOverrides(null, { '/': { hidden: true } }), [])
|
||||
assert.deepEqual(applyNavOverrides(undefined, null), [])
|
||||
})
|
||||
|
||||
test('an empty base nav stays empty', () => {
|
||||
assert.deepEqual(applyNavOverrides([], { '/': { label: 'Home' } }), [])
|
||||
})
|
||||
|
||||
// ── The editor's round trip (phase 7) ─────────────────────────────────────
|
||||
//
|
||||
// buildNavRows and buildNavOverrides are inverse, and the property that matters
|
||||
// is that the editor and the site agree: the rows an admin drags come out of the
|
||||
// same merge the layouts render, hidden ones included.
|
||||
|
||||
const rowLabels = (groups) => groups.map((g) => [g.title, g.items.map((i) => i.label)])
|
||||
|
||||
test('rows with no override are the coded nav, in code order', () => {
|
||||
const rows = buildNavRows(FLAT, null)
|
||||
assert.deepEqual(rowLabels(rows), [[null, ['Home', 'News', 'Wiki', 'Shard']]])
|
||||
assert.equal(rows[0].items.every((i) => i.hidden === false), true)
|
||||
})
|
||||
|
||||
test('a flat nav becomes one untitled group, so one editor handles both shapes', () => {
|
||||
assert.equal(buildNavRows(FLAT, null).length, 1)
|
||||
assert.equal(buildNavRows(GROUPED, null).length, 3)
|
||||
})
|
||||
|
||||
test('rows keep hidden items, in place and marked — the site drops them', () => {
|
||||
const overrides = { '/site/news': { hidden: true } }
|
||||
// The layout must not render it...
|
||||
assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Home', 'Wiki', 'Shard'])
|
||||
// ...while the editor must, or there is no way to un-hide it.
|
||||
const rows = buildNavRows(FLAT, overrides)[0].items
|
||||
assert.deepEqual(rows.map((i) => i.label), ['Home', 'News', 'Wiki', 'Shard'])
|
||||
assert.equal(rows[1].hidden, true)
|
||||
assert.equal(rows[0].hidden, false)
|
||||
})
|
||||
|
||||
test('rows carry the coded label alongside the overridden one', () => {
|
||||
const rows = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })[0].items
|
||||
assert.equal(rows[1].label, 'Announcements')
|
||||
assert.equal(rows[1].defaultLabel, 'News')
|
||||
})
|
||||
|
||||
test('rows show the same order the site renders', () => {
|
||||
const overrides = { '/wiki': { order: 0 }, '/': { order: 1 } }
|
||||
assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Wiki', 'Home', 'News', 'Shard'])
|
||||
assert.deepEqual(rowLabels(buildNavRows(FLAT, overrides)), [[null, ['Wiki', 'Home', 'News', 'Shard']]])
|
||||
})
|
||||
|
||||
test('rows keep an emptied group so something can be moved back into it', () => {
|
||||
// applyNavOverrides drops a group whose every item is hidden; the editor must
|
||||
// still show the header, or the section is unreachable forever.
|
||||
const overrides = { '/admin/posts': { hidden: true }, '/admin/wiki': { hidden: true } }
|
||||
assert.equal(applyNavOverrides(GROUPED, overrides).some((g) => g.title === 'Content'), false)
|
||||
assert.equal(buildNavRows(GROUPED, overrides).some((g) => g.title === 'Content'), true)
|
||||
})
|
||||
|
||||
test('an untouched editor saves nothing at all', () => {
|
||||
// Opening the screen and pressing Save must not pin the position of every
|
||||
// item — the caller deletes the row when this comes back empty.
|
||||
assert.deepEqual(buildNavOverrides(buildNavRows(FLAT, null), FLAT), {})
|
||||
assert.deepEqual(buildNavOverrides(buildNavRows(GROUPED, null), GROUPED), {})
|
||||
})
|
||||
|
||||
test('a rename alone writes a label and no orders', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
groups[0].items[1].label = 'Announcements'
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/news': { label: 'Announcements' } })
|
||||
})
|
||||
|
||||
test('a label typed back to the coded one is not stored as an override', () => {
|
||||
const groups = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })
|
||||
groups[0].items[1].label = 'News'
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), {})
|
||||
// Whitespace-only reads as "use the default" too.
|
||||
groups[0].items[1].label = ' '
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), {})
|
||||
})
|
||||
|
||||
test('hiding alone writes hidden and no orders', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
groups[0].items[3].hidden = true
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/shard': { hidden: true } })
|
||||
})
|
||||
|
||||
test('reordering writes an order for every row in the list', () => {
|
||||
// §7.1: explicit and implicit sort keys share one number line, so a partial
|
||||
// set of orders is the stale-row case rather than something the editor makes.
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
const [home] = groups[0].items.splice(0, 1)
|
||||
groups[0].items.push(home)
|
||||
assert.deepEqual(buildNavOverrides(groups, FLAT), {
|
||||
'/site/news': { order: 0 },
|
||||
'/wiki': { order: 1 },
|
||||
'/site/shard': { order: 2 },
|
||||
'/': { order: 3 },
|
||||
})
|
||||
})
|
||||
|
||||
test('the round trip is stable: save, reload, save again yields the same thing', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
groups[0].items.reverse()
|
||||
groups[0].items[0].label = 'The Shard'
|
||||
const first = buildNavOverrides(groups, FLAT)
|
||||
const second = buildNavOverrides(buildNavRows(FLAT, first), FLAT)
|
||||
assert.deepEqual(second, first)
|
||||
// And it renders what the editor showed.
|
||||
assert.deepEqual(labels(applyNavOverrides(FLAT, first)), ['The Shard', 'Wiki', 'News', 'Home'])
|
||||
})
|
||||
|
||||
test('moving an item to another section writes group, and moving it back clears it', () => {
|
||||
const groups = buildNavRows(GROUPED, null)
|
||||
const [posts] = groups[1].items.splice(0, 1)
|
||||
groups[2].items.push(posts)
|
||||
const saved = buildNavOverrides(groups, GROUPED)
|
||||
assert.equal(saved['/admin/posts'].group, 'System')
|
||||
assert.deepEqual(groupLabels(applyNavOverrides(GROUPED, saved)), [
|
||||
[null, ['Dashboard']],
|
||||
['Content', ['Wiki']],
|
||||
['System', ['Settings', 'Users', 'Posts']],
|
||||
])
|
||||
const back = buildNavRows(GROUPED, saved)
|
||||
const [moved] = back[2].items.splice(2, 1)
|
||||
back[1].items.unshift(moved)
|
||||
assert.equal(buildNavOverrides(back, GROUPED)['/admin/posts'], undefined)
|
||||
})
|
||||
|
||||
test('an override for an item outside this admin’s palette survives a save', () => {
|
||||
// §8.1 filters the editor to what the editing admin can themselves see. An
|
||||
// item filtered out has no row, and must not be quietly reset by their save.
|
||||
const visible = buildNavRows(FLAT, { '/site/shard': { hidden: true } }).map((g) => ({
|
||||
...g,
|
||||
items: g.items.filter((i) => !i.feature),
|
||||
}))
|
||||
const stored = { '/site/shard': { hidden: true }, '/site/news': { label: 'Old' } }
|
||||
const out = buildNavOverrides(visible, FLAT, stored)
|
||||
assert.deepEqual(out['/site/shard'], { hidden: true })
|
||||
// The rows they *could* see still win over what was stored.
|
||||
assert.equal(out['/site/news'], undefined)
|
||||
})
|
||||
|
||||
test('a stored entry for a route the code no longer declares is dropped on save', () => {
|
||||
const groups = buildNavRows(FLAT, null)
|
||||
const out = buildNavOverrides(groups, FLAT, { '/site/gone': { label: 'Ghost' } })
|
||||
assert.deepEqual(out, {})
|
||||
})
|
||||
|
||||
test('degenerate input yields an empty result rather than throwing', () => {
|
||||
assert.deepEqual(buildNavRows(null, {}), [])
|
||||
assert.deepEqual(buildNavRows([], {}), [])
|
||||
assert.deepEqual(buildNavOverrides(null, FLAT), {})
|
||||
assert.deepEqual(buildNavOverrides([], null), {})
|
||||
})
|
||||
|
||||
// ── The public header: sections and added links (phase 10) ────────────────
|
||||
//
|
||||
// The one nav an admin can restructure rather than only reorder. The invariant
|
||||
// that has to survive is §7's, in its narrower form: a CODED entry still cannot
|
||||
// have its `to` or `feature` touched, and everything that can name an arbitrary
|
||||
// path lives in `links`, where the path rule applies.
|
||||
|
||||
const PUB = [
|
||||
{ label: 'Home', to: '/', end: true },
|
||||
{ label: 'News', to: '/site/news' },
|
||||
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
|
||||
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||
{ label: 'About', to: '/site/about' },
|
||||
]
|
||||
const shape = (tree) =>
|
||||
tree.map((n) => (n.kind === 'section' ? { [n.label]: n.items.map((i) => i.label) } : n.label))
|
||||
|
||||
test('no override yields the coded header, in code order', () => {
|
||||
assert.deepEqual(shape(buildPublicNav(PUB, null)), ['Home', 'News', 'Champions', 'Guilds', 'About'])
|
||||
assert.deepEqual(shape(buildPublicNav(PUB, {})), ['Home', 'News', 'Champions', 'Guilds', 'About'])
|
||||
})
|
||||
|
||||
test('a phase 6-8 bare map still reads as the items map', () => {
|
||||
// Nothing has shipped, but a row written during review must not become
|
||||
// unreadable just because the wrapper arrived.
|
||||
assert.deepEqual(shape(buildPublicNav(PUB, { '/site/news': { label: 'Announcements' } })), [
|
||||
'Home',
|
||||
'Announcements',
|
||||
'Champions',
|
||||
'Guilds',
|
||||
'About',
|
||||
])
|
||||
})
|
||||
|
||||
const SECTIONED = {
|
||||
items: { '/site/champs': { section: 'sec_aaaa', order: 0 }, '/site/guilds': { section: 'sec_aaaa', order: 1 } },
|
||||
sections: [{ id: 'sec_aaaa', label: 'The World', order: 2 }],
|
||||
links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_aaaa', order: 2 }],
|
||||
}
|
||||
|
||||
test('a section collects its members and sits in the top-level order', () => {
|
||||
assert.deepEqual(shape(buildPublicNav(PUB, SECTIONED)), [
|
||||
'Home',
|
||||
'News',
|
||||
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
|
||||
'About',
|
||||
])
|
||||
})
|
||||
|
||||
test('an added link is kept apart from the coded items', () => {
|
||||
const tree = buildPublicNav(PUB, SECTIONED)
|
||||
const link = tree.find((n) => n.kind === 'section').items.find((i) => i.kind === 'link')
|
||||
assert.equal(link.to, '/wiki/new-player-guide')
|
||||
assert.equal(link.id, 'lnk_bbbb')
|
||||
// It carries no gate of its own — that is the documented contract, and the
|
||||
// page behind it is what actually enforces access.
|
||||
assert.equal(link.feature, undefined)
|
||||
assert.equal(link.roles, undefined)
|
||||
})
|
||||
|
||||
test('an off-origin link is dropped rather than rendered', () => {
|
||||
for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', '/x y', '/a"b']) {
|
||||
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Bad', to }] })
|
||||
assert.equal(
|
||||
tree.some((n) => n.kind === 'link'),
|
||||
false,
|
||||
`${to} should be dropped`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('an item naming a section that does not exist stays at the top level', () => {
|
||||
const tree = buildPublicNav(PUB, { items: { '/site/champs': { section: 'sec_gone' } } })
|
||||
assert.deepEqual(shape(tree), ['Home', 'News', 'Champions', 'Guilds', 'About'])
|
||||
})
|
||||
|
||||
test('an override still cannot introduce a coded route', () => {
|
||||
const tree = buildPublicNav(PUB, { items: { '/site/secret': { label: 'Secret' } } })
|
||||
assert.equal(
|
||||
tree.some((n) => n.to === '/site/secret'),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('hidden entries are dropped for the site and kept for the editor', () => {
|
||||
const overrides = { items: { '/site/news': { hidden: true } } }
|
||||
assert.equal(shape(buildPublicNav(PUB, overrides)).includes('News'), false)
|
||||
const rows = buildPublicNav(PUB, overrides, { keepHidden: true })
|
||||
assert.equal(rows.find((n) => n.to === '/site/news').hidden, true)
|
||||
})
|
||||
|
||||
// ── pruneNav: the empty dropdown ──────────────────────────────────────────
|
||||
|
||||
test('a section keeps the entries the viewer may see', () => {
|
||||
const tree = buildPublicNav(PUB, SECTIONED)
|
||||
const out = pruneNav(tree, (i) => i.feature !== 'guilds')
|
||||
assert.deepEqual(shape(out), ['Home', 'News', { 'The World': ['Champions', 'Guide'] }, 'About'])
|
||||
})
|
||||
|
||||
test('a section whose every entry is gated out does not render at all', () => {
|
||||
// The case that matters: a dropdown that opens onto nothing is worse than no
|
||||
// dropdown, and shard visibility can empty one at any time.
|
||||
const overrides = {
|
||||
items: { '/site/champs': { section: 'sec_aaaa' }, '/site/guilds': { section: 'sec_aaaa' } },
|
||||
sections: [{ id: 'sec_aaaa', label: 'The World' }],
|
||||
}
|
||||
const tree = buildPublicNav(PUB, overrides)
|
||||
assert.deepEqual(shape(pruneNav(tree, () => true)), [
|
||||
'Home',
|
||||
'News',
|
||||
'About',
|
||||
{ 'The World': ['Champions', 'Guilds'] },
|
||||
])
|
||||
assert.deepEqual(shape(pruneNav(tree, (i) => !i.feature)), ['Home', 'News', 'About'])
|
||||
})
|
||||
|
||||
test('an added link is never pruned — it carries no gate', () => {
|
||||
const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/g' }] })
|
||||
assert.equal(
|
||||
pruneNav(tree, () => false).some((n) => n.kind === 'link'),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
// ── The editor round trip ─────────────────────────────────────────────────
|
||||
|
||||
test('an untouched public editor saves nothing', () => {
|
||||
assert.deepEqual(buildPublicNavOverrides(buildPublicNav(PUB, null, { keepHidden: true }), PUB), {})
|
||||
})
|
||||
|
||||
test('a nav with no sections still stores the plain items map', () => {
|
||||
// Adding this feature changed nothing for a nav that does not use it.
|
||||
const tree = buildPublicNav(PUB, null, { keepHidden: true })
|
||||
tree[1].label = 'Announcements'
|
||||
const out = buildPublicNavOverrides(tree, PUB)
|
||||
assert.deepEqual(out, { '/site/news': { label: 'Announcements' } })
|
||||
assert.equal(out.items, undefined)
|
||||
})
|
||||
|
||||
test('the sectioned round trip is stable and renders what the editor showed', () => {
|
||||
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
|
||||
const first = buildPublicNavOverrides(tree, PUB)
|
||||
const second = buildPublicNavOverrides(buildPublicNav(PUB, first, { keepHidden: true }), PUB)
|
||||
assert.deepEqual(second, first)
|
||||
assert.deepEqual(shape(buildPublicNav(PUB, first)), [
|
||||
'Home',
|
||||
'News',
|
||||
{ 'The World': ['Champions', 'Guilds', 'Guide'] },
|
||||
'About',
|
||||
])
|
||||
})
|
||||
|
||||
test('deleting a section returns its entries to the top level, never deletes them', () => {
|
||||
// The one destructive act this screen could commit, so it is locked here.
|
||||
const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
|
||||
const section = tree.find((n) => n.kind === 'section')
|
||||
const flattened = [...tree.filter((n) => n.kind !== 'section'), ...section.items]
|
||||
const out = buildPublicNavOverrides(flattened, PUB)
|
||||
const rendered = buildPublicNav(PUB, out)
|
||||
assert.equal(
|
||||
rendered.some((n) => n.kind === 'section'),
|
||||
false,
|
||||
)
|
||||
assert.deepEqual(shape(rendered), ['Home', 'News', 'About', 'Champions', 'Guilds', 'Guide'])
|
||||
})
|
||||
|
||||
test('an override for a feature-gated item outside the palette survives a save', () => {
|
||||
// §8.1 filters the editor to what this admin can see. The rows come from their
|
||||
// palette, but membership is judged against the FULL coded nav — otherwise a
|
||||
// row a shard feature hid from them is indistinguishable from a deleted route,
|
||||
// and their save would silently reset it.
|
||||
const palette = PUB.filter((i) => i.feature !== 'champs')
|
||||
// The editor was opened on a nav that only hides champs — which their palette
|
||||
// does not show them. `stored` additionally carries a label for a row they CAN
|
||||
// see, and which they have since reset.
|
||||
const tree = buildPublicNav(palette, { items: { '/site/champs': { hidden: true } } }, { keepHidden: true })
|
||||
const stored = { items: { '/site/champs': { hidden: true }, '/site/news': { label: 'Old' } } }
|
||||
const out = buildPublicNavOverrides(tree, PUB, stored)
|
||||
assert.deepEqual(out['/site/champs'], { hidden: true }, 'carried: they could not see it')
|
||||
assert.equal(out['/site/news'], undefined, 'not carried: their row is the authority for what they can see')
|
||||
})
|
||||
|
||||
test('a stored entry for a route the code no longer declares is dropped on save', () => {
|
||||
const tree = buildPublicNav(PUB, null, { keepHidden: true })
|
||||
assert.deepEqual(buildPublicNavOverrides(tree, PUB, { items: { '/site/gone': { label: 'Ghost' } } }), {})
|
||||
})
|
||||
28
client/test/settingsJson.test.js
Normal file
28
client/test/settingsJson.test.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { parseJsonSetting } from '../src/lib/settingsJson.js'
|
||||
|
||||
// The client counterpart to the server's parseJsonSetting. The property that
|
||||
// matters is the fail-safe one: anything unusable reads as **absent**, so the
|
||||
// consumer falls back to its coded default rather than rendering an error or a
|
||||
// half-applied object (THEMING_AND_NAV.md §4.4).
|
||||
|
||||
test('absent, empty and malformed values read as absent', () => {
|
||||
for (const bad of [undefined, null, '', '{', 'not json', 4, {}, []]) {
|
||||
assert.equal(parseJsonSetting(bad), null, `${JSON.stringify(bad)} should read as absent`)
|
||||
}
|
||||
})
|
||||
|
||||
test('valid JSON that is not a plain object reads as absent', () => {
|
||||
// A stored `null`, number, string or array is as unusable to every consumer of
|
||||
// these keys as a syntax error is.
|
||||
for (const bad of ['null', '4', '"x"', '[]', '[{"to":"/"}]', 'true']) {
|
||||
assert.equal(parseJsonSetting(bad), null, `${bad} should read as absent`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a well-formed object is returned as parsed', () => {
|
||||
assert.deepEqual(parseJsonSetting('{"/site/news":{"order":2}}'), { '/site/news': { order: 2 } })
|
||||
assert.deepEqual(parseJsonSetting('{}'), {})
|
||||
})
|
||||
100
client/test/themeVars.test.js
Normal file
100
client/test/themeVars.test.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// applyThemeTokens — writing the server-resolved theme onto the document, and
|
||||
// (the part with real logic) taking back exactly what it wrote last time.
|
||||
//
|
||||
// Pure module, exercised against a fake CSSStyleDeclaration: node --test has no
|
||||
// DOM, and the function only ever needs setProperty/removeProperty.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { applyThemeTokens } from '../src/lib/themeVars.js'
|
||||
|
||||
// Minimal stand-in for element.style, plus a log of the calls so a test can
|
||||
// assert that a property was *removed* rather than merely absent.
|
||||
function fakeStyle() {
|
||||
const props = new Map()
|
||||
const removed = []
|
||||
return {
|
||||
props,
|
||||
removed,
|
||||
setProperty: (name, value) => props.set(name, value),
|
||||
removeProperty: (name) => {
|
||||
props.delete(name)
|
||||
removed.push(name)
|
||||
},
|
||||
get: (name) => props.get(name),
|
||||
}
|
||||
}
|
||||
|
||||
test('writes each token and reports the keys it applied', () => {
|
||||
const style = fakeStyle()
|
||||
const applied = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' })
|
||||
assert.equal(style.get('--accent'), '#c9973f')
|
||||
assert.equal(style.get('--bg'), '#1a120b')
|
||||
assert.deepEqual(applied.sort(), ['--accent', '--bg'])
|
||||
})
|
||||
|
||||
// The untouched-instance case: no theme block means the stylesheet's :root
|
||||
// stands and nothing is written at all.
|
||||
test('no theme writes nothing', () => {
|
||||
for (const empty of [null, undefined, {}]) {
|
||||
const style = fakeStyle()
|
||||
const applied = applyThemeTokens(style, empty)
|
||||
assert.equal(style.props.size, 0)
|
||||
assert.deepEqual(applied, [])
|
||||
}
|
||||
})
|
||||
|
||||
test('removes a token that is no longer in the theme', () => {
|
||||
const style = fakeStyle()
|
||||
const first = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' })
|
||||
const second = applyThemeTokens(style, { '--accent': '#c9973f' }, first)
|
||||
assert.equal(style.get('--accent'), '#c9973f')
|
||||
assert.equal(style.get('--bg'), undefined)
|
||||
assert.deepEqual(style.removed, ['--bg'])
|
||||
assert.deepEqual(second, ['--accent'])
|
||||
})
|
||||
|
||||
// "Reset to defaults" — the case that would look broken without the removal
|
||||
// half: the payload stops mentioning the variables, and the inline values have
|
||||
// to come off for :root to show through again.
|
||||
test('resetting to no theme clears everything previously applied', () => {
|
||||
const style = fakeStyle()
|
||||
const first = applyThemeTokens(style, { '--accent': '#c9973f', '--radius-card': '2px' })
|
||||
const second = applyThemeTokens(style, null, first)
|
||||
assert.equal(style.props.size, 0)
|
||||
assert.deepEqual(style.removed.sort(), ['--accent', '--radius-card'])
|
||||
assert.deepEqual(second, [])
|
||||
})
|
||||
|
||||
// Only ever clears its own keys. SiteContext writes --accent itself from
|
||||
// brand.accent, and a future feature may write others; those are not ours.
|
||||
test('never removes a property it did not apply', () => {
|
||||
const style = fakeStyle()
|
||||
style.setProperty('--accent', '#ff0000') // someone else's write
|
||||
applyThemeTokens(style, { '--bg': '#000000' }, [])
|
||||
assert.equal(style.get('--accent'), '#ff0000')
|
||||
assert.deepEqual(style.removed, [])
|
||||
})
|
||||
|
||||
test('ignores anything that is not a custom property', () => {
|
||||
const style = fakeStyle()
|
||||
const applied = applyThemeTokens(style, { background: 'url(http://evil.example/x)', '--bg': '#000000' })
|
||||
assert.equal(style.get('background'), undefined)
|
||||
assert.deepEqual(applied, ['--bg'])
|
||||
})
|
||||
|
||||
test('ignores non-string and empty values', () => {
|
||||
const style = fakeStyle()
|
||||
const applied = applyThemeTokens(style, { '--a': 4, '--b': null, '--c': '', '--d': '#fff' })
|
||||
assert.deepEqual(applied, ['--d'])
|
||||
})
|
||||
|
||||
// A stale key list must not survive a call that could not write: the next call
|
||||
// still has to know what is actually on the element.
|
||||
test('a token dropped as invalid is removed if it was applied before', () => {
|
||||
const style = fakeStyle()
|
||||
const first = applyThemeTokens(style, { '--bg': '#000000' })
|
||||
const second = applyThemeTokens(style, { '--bg': '' }, first)
|
||||
assert.equal(style.get('--bg'), undefined)
|
||||
assert.deepEqual(second, [])
|
||||
})
|
||||
50
modules/uo/SPIKE.md
Normal file
50
modules/uo/SPIKE.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# module-uo — the Phase 1 spike
|
||||
|
||||
**This branch is evidence, not implementation.** `spike/module-atlas` is cut from `edge` and is
|
||||
never merged. Phase 2 rebuilds the loader properly, with the `installed_modules` table, the full
|
||||
state machine and the admin panel behind it; Phase 3 does the real extraction.
|
||||
|
||||
What it demonstrates, and the results, are written up in
|
||||
[`docs/website/MODULE_API.md`](../../../docs/website/MODULE_API.md) Part 7. In one line: the six
|
||||
public spawn-atlas routes now live in a module, at byte-identical URLs, with the client half loading
|
||||
as a prebuilt ESM chunk under `script-src 'self'`.
|
||||
|
||||
## Reproducing it
|
||||
|
||||
```bash
|
||||
# 1. build the module's client chunk (its CI would do this and ship the result)
|
||||
cd modules/uo/client && npm install && npm run build # → dist/entry.js
|
||||
|
||||
# 2. build core's client
|
||||
cd ../../../client && npm install && npm run build
|
||||
|
||||
# 3. run the server against the local MariaDB
|
||||
cd ../server && npm start
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
- `/uo/atlas` and `/uo/atlas/lizardman` render from the module's chunk.
|
||||
- `GET /api/v1/public/atlas/*` answers exactly as before — `npm run routes:manifest -- --check`
|
||||
reports the surface unchanged.
|
||||
- `npm test` in `server/` (core, 729) and `node --test` in `modules/uo/server/` (module, 81).
|
||||
|
||||
`dist/entry.js` is committed here **only** because this branch is the evidence for a design
|
||||
decision and a reviewer should be able to inspect the built artifact without a toolchain. A real
|
||||
module publishes it from CI into its release bundle and never commits it.
|
||||
|
||||
## What in here is not design
|
||||
|
||||
Three things are consequences of stopping at six routes, spelled out in MODULE_API.md §7.5:
|
||||
|
||||
1. **Core reaches into this module twice** — `server/src/router/v1/admin/shardAtlas.controller.js`
|
||||
and `server/test/atlasController.test.js`. The five admin atlas routes sit inside the `/shard`
|
||||
admin prefix core still owns, so they cannot move until the whole prefix does.
|
||||
2. **`server/utils/visibility.js` is a copy** of core's `utils/shardVisibility.js`, which core still
|
||||
needs for the shard routes not yet extracted. Two caches over one table, briefly.
|
||||
3. **There is no `swagger-fragment.json`** — it needs core's merge helper on the other side, which
|
||||
is Phase 2.
|
||||
|
||||
Also note the table names here are `shard_*`, not `uo_*`. That is deliberate and grandfathered by an
|
||||
allowlist in the loader: renaming twenty-seven live tables is a data migration this workstream does
|
||||
not do. Every module written after this one carries its id as a table prefix.
|
||||
409
modules/uo/client/dist/entry.js
vendored
Normal file
409
modules/uo/client/dist/entry.js
vendored
Normal file
@@ -0,0 +1,409 @@
|
||||
const B = window.__rg.jsxRuntime, { jsx: t, jsxs: l, Fragment: A } = B, U = window.__rg.react, {
|
||||
useState: h,
|
||||
useEffect: I,
|
||||
useMemo: j,
|
||||
useCallback: W,
|
||||
useRef: ne,
|
||||
useContext: se,
|
||||
useReducer: re,
|
||||
createElement: ie,
|
||||
cloneElement: le,
|
||||
createContext: oe,
|
||||
forwardRef: ce,
|
||||
memo: de,
|
||||
Fragment: me,
|
||||
Children: pe,
|
||||
isValidElement: ue,
|
||||
StrictMode: he,
|
||||
Suspense: ge,
|
||||
lazy: fe
|
||||
} = U, E = window.__rg.router, {
|
||||
Link: z,
|
||||
NavLink: ye,
|
||||
Navigate: xe,
|
||||
Outlet: we,
|
||||
Route: be,
|
||||
Routes: ve,
|
||||
useParams: M,
|
||||
useNavigate: Se,
|
||||
useLocation: Ne,
|
||||
useSearchParams: $e,
|
||||
createBrowserRouter: Ce,
|
||||
RouterProvider: ke
|
||||
} = E, { PublicLayout: F, PageHeader: D, Loading: C, ErrorState: v, EmptyState: S, useAsync: k, useAuth: Re, useSite: ze } = window.__rg.ui, { request: f } = window.__rg.api, w = (e) => e ? `?${e}` : "", O = {
|
||||
creatures: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.q && a.set("q", e.q), e.facet && a.set("facet", e.facet), e.limit && a.set("limit", e.limit), e.offset && a.set("offset", e.offset), f(`/public/atlas/creatures${w(a.toString())}`);
|
||||
},
|
||||
creature: (e, a = {}) => {
|
||||
const s = new URLSearchParams();
|
||||
return a.facet && s.set("facet", a.facet), a.points && s.set("points", a.points), f(`/public/atlas/creatures/${encodeURIComponent(e)}${w(s.toString())}`);
|
||||
},
|
||||
regions: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/regions${w(a.toString())}`);
|
||||
},
|
||||
landmarks: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.facet && a.set("facet", e.facet), e.q && a.set("q", e.q), f(`/public/atlas/landmarks${w(a.toString())}`);
|
||||
},
|
||||
champions: (e = {}) => {
|
||||
const a = new URLSearchParams();
|
||||
return e.facet && a.set("facet", e.facet), f(`/public/atlas/champions${w(a.toString())}`);
|
||||
},
|
||||
meta: () => f("/public/atlas/meta")
|
||||
}, y = { atlas: O }, H = 50, x = (e) => Number.isFinite(e) ? e.toLocaleString() : "—", Q = [
|
||||
{ key: "creatures", label: "Creatures" },
|
||||
{ key: "champions", label: "Champion altars" },
|
||||
{ key: "places", label: "Places" }
|
||||
];
|
||||
function R({ active: e, onClick: a, children: s }) {
|
||||
return /* @__PURE__ */ t(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
onClick: a,
|
||||
className: "sans",
|
||||
style: {
|
||||
fontSize: "0.78rem",
|
||||
padding: "5px 12px",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
color: e ? "var(--bg-deep)" : "var(--muted)",
|
||||
background: e ? "var(--accent)" : "transparent",
|
||||
border: `1px solid ${e ? "var(--accent)" : "var(--line)"}`
|
||||
},
|
||||
children: s
|
||||
}
|
||||
);
|
||||
}
|
||||
function G({ creature: e }) {
|
||||
const a = Object.entries(e.facets || {}).sort((s, r) => r[1] - s[1]);
|
||||
return /* @__PURE__ */ l(
|
||||
z,
|
||||
{
|
||||
to: `/uo/atlas/${encodeURIComponent(e.slug)}`,
|
||||
className: "panel",
|
||||
style: {
|
||||
padding: "13px 15px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
textDecoration: "none",
|
||||
color: "inherit"
|
||||
},
|
||||
children: [
|
||||
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
|
||||
/* @__PURE__ */ t(
|
||||
"div",
|
||||
{
|
||||
className: "display",
|
||||
style: {
|
||||
fontSize: "0.98rem",
|
||||
color: "var(--head)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
},
|
||||
children: e.name
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ t("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: a.length === 0 ? "—" : a.map(([s, r]) => `${s} (${r})`).join(" · ") })
|
||||
] }),
|
||||
/* @__PURE__ */ l("div", { className: "sans", style: { flex: "none", textAlign: "right" }, children: [
|
||||
/* @__PURE__ */ t("div", { style: { color: "var(--head)", fontSize: "0.92rem" }, children: x(e.total) }),
|
||||
/* @__PURE__ */ l("div", { className: "dim", style: { fontSize: "0.68rem", letterSpacing: "0.05em" }, children: [
|
||||
x(e.points),
|
||||
" spawners"
|
||||
] })
|
||||
] })
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
function V({ q: e, facet: a }) {
|
||||
const [s, r] = h({ loading: !0, error: null, items: [], total: 0 }), [i, p] = h(!1), o = W(
|
||||
async (n) => await y.atlas.creatures({ q: e, facet: a, limit: H, offset: n }),
|
||||
[e, a]
|
||||
);
|
||||
I(() => {
|
||||
let n = !0;
|
||||
return r({ loading: !0, error: null, items: [], total: 0 }), o(0).then((d) => {
|
||||
n && r({ loading: !1, error: null, items: d.creatures || [], total: d.total || 0 });
|
||||
}).catch((d) => n && r({ loading: !1, error: d, items: [], total: 0 })), () => {
|
||||
n = !1;
|
||||
};
|
||||
}, [o]);
|
||||
const c = async () => {
|
||||
p(!0);
|
||||
try {
|
||||
const n = await o(s.items.length);
|
||||
r((d) => ({ ...d, items: [...d.items, ...n.creatures || []], total: n.total ?? d.total }));
|
||||
} catch {
|
||||
} finally {
|
||||
p(!1);
|
||||
}
|
||||
};
|
||||
return s.loading ? /* @__PURE__ */ t(C, {}) : s.error ? /* @__PURE__ */ t(v, { message: "Could not load the bestiary right now." }) : s.items.length === 0 ? /* @__PURE__ */ t(S, { children: "Nothing in the atlas matches that." }) : /* @__PURE__ */ l(A, { children: [
|
||||
/* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.78rem", margin: "0 0 12px" }, children: [
|
||||
"Showing ",
|
||||
x(s.items.length),
|
||||
" of ",
|
||||
x(s.total)
|
||||
] }),
|
||||
/* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: s.items.map((n) => /* @__PURE__ */ t(G, { creature: n }, n.slug)) }),
|
||||
s.items.length < s.total && /* @__PURE__ */ t("div", { style: { textAlign: "center", marginTop: 16 }, children: /* @__PURE__ */ t("button", { type: "button", className: "btn", onClick: c, disabled: i, children: i ? "Loading…" : "Load more" }) })
|
||||
] });
|
||||
}
|
||||
function X({ facet: e }) {
|
||||
const { loading: a, error: s, data: r } = k(() => y.atlas.champions(e), [e]);
|
||||
return a ? /* @__PURE__ */ t(C, {}) : s ? /* @__PURE__ */ t(v, { message: "Could not load the champion altars right now." }) : !r || r.length === 0 ? /* @__PURE__ */ t(S, { children: "No champion altars are configured." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 8 }, children: r.map((i) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "13px 15px", display: "flex", gap: 14, alignItems: "center" }, children: [
|
||||
/* @__PURE__ */ l("div", { style: { minWidth: 0, flex: 1 }, children: [
|
||||
/* @__PURE__ */ t("div", { className: "display", style: { fontSize: "0.98rem", color: "var(--head)" }, children: i.label || i.name }),
|
||||
/* @__PURE__ */ l("div", { className: "sans dim", style: { fontSize: "0.74rem", marginTop: 3 }, children: [
|
||||
i.facet,
|
||||
i.group ? ` · ${i.group}` : "",
|
||||
" · ",
|
||||
i.x,
|
||||
", ",
|
||||
i.y
|
||||
] })
|
||||
] }),
|
||||
/* @__PURE__ */ t("span", { className: "sans", style: { flex: "none", fontSize: "0.76rem", color: "var(--muted)" }, children: i.randomType ? "Random champion" : i.type || "—" })
|
||||
] }, i.slug)) });
|
||||
}
|
||||
function J({ q: e, facet: a }) {
|
||||
const { loading: s, error: r, data: i } = k(
|
||||
() => Promise.all([y.atlas.regions({ q: e, facet: a }), y.atlas.landmarks({ q: e, facet: a })]),
|
||||
[e, a]
|
||||
), p = j(() => {
|
||||
if (!i) return [];
|
||||
const [o, c] = i;
|
||||
return [
|
||||
...o.map((n) => ({ key: `r:${n.facet}:${n.name}`, name: n.name, facet: n.facet, detail: n.parent || n.type || "Region", kind: "Region" })),
|
||||
...c.map((n) => ({ key: `l:${n.facet}:${n.group || ""}:${n.name}:${n.x}:${n.y}`, name: n.group ? `${n.group} — ${n.name}` : n.name, facet: n.facet, detail: `${n.x}, ${n.y}`, kind: "Landmark" }))
|
||||
].sort((n, d) => n.name.localeCompare(d.name));
|
||||
}, [i]);
|
||||
return s ? /* @__PURE__ */ t(C, {}) : r ? /* @__PURE__ */ t(v, { message: "Could not load places right now." }) : p.length === 0 ? /* @__PURE__ */ t(S, { children: "No regions or landmarks match that." }) : /* @__PURE__ */ t("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: p.map((o) => /* @__PURE__ */ l("div", { className: "panel", style: { padding: "10px 14px", display: "flex", gap: 12, alignItems: "baseline" }, children: [
|
||||
/* @__PURE__ */ t("span", { className: "sans", style: { flex: 1, minWidth: 0, color: "var(--head)", fontSize: "0.88rem" }, children: o.name }),
|
||||
/* @__PURE__ */ l("span", { className: "sans dim", style: { fontSize: "0.72rem" }, children: [
|
||||
o.facet,
|
||||
" · ",
|
||||
o.detail
|
||||
] }),
|
||||
/* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.66rem", letterSpacing: "0.06em", flex: "none" }, children: o.kind })
|
||||
] }, o.key)) });
|
||||
}
|
||||
function K() {
|
||||
var L, _, q;
|
||||
const [e, a] = h("creatures"), [s, r] = h(""), [i, p] = h(""), [o, c] = h(""), n = k(() => y.atlas.meta());
|
||||
I(() => {
|
||||
const m = setTimeout(() => p(s.trim()), 250);
|
||||
return () => clearTimeout(m);
|
||||
}, [s]);
|
||||
const d = ((L = n.data) == null ? void 0 : L.facets) || [], u = ((_ = n.data) == null ? void 0 : _.counts) || null, N = (q = n.data) != null && q.importedAt ? new Date(n.data.importedAt) : null;
|
||||
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
|
||||
/* @__PURE__ */ t(
|
||||
D,
|
||||
{
|
||||
eyebrow: "Bestiary",
|
||||
title: "Spawn atlas",
|
||||
lead: "Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||
}
|
||||
),
|
||||
u && /* @__PURE__ */ l("p", { className: "sans dim", style: { fontSize: "0.76rem", margin: "-12px 0 18px" }, children: [
|
||||
x(u.creatures),
|
||||
" creatures across ",
|
||||
x(u.points),
|
||||
" spawners",
|
||||
Number.isFinite(u.unresolvedPoints) && u.points ? ` · ${Math.round((u.points - u.unresolvedPoints) / u.points * 100)}% placed to a named region or landmark` : "",
|
||||
N ? ` · parsed ${N.toLocaleDateString()}` : ""
|
||||
] }),
|
||||
/* @__PURE__ */ t("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }, children: Q.map((m) => /* @__PURE__ */ t(R, { active: e === m.key, onClick: () => a(m.key), children: m.label }, m.key)) }),
|
||||
e !== "champions" && /* @__PURE__ */ t(
|
||||
"input",
|
||||
{
|
||||
className: "input",
|
||||
type: "search",
|
||||
value: s,
|
||||
onChange: (m) => r(m.target.value),
|
||||
placeholder: e === "creatures" ? "Search creatures…" : "Search regions and landmarks…",
|
||||
style: { width: "100%", marginBottom: 12 }
|
||||
}
|
||||
),
|
||||
d.length > 0 && /* @__PURE__ */ l("div", { style: { display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 18 }, children: [
|
||||
/* @__PURE__ */ t(R, { active: o === "", onClick: () => c(""), children: "All facets" }),
|
||||
d.map((m) => /* @__PURE__ */ t(R, { active: o === m, onClick: () => c(m), children: m }, m))
|
||||
] }),
|
||||
n.error && /* @__PURE__ */ t(v, { message: "Could not load the atlas right now." }),
|
||||
!n.error && !n.loading && !N && /* @__PURE__ */ t(S, { children: "The spawn atlas has not been imported yet." }),
|
||||
!n.error && N && /* @__PURE__ */ l(A, { children: [
|
||||
e === "creatures" && /* @__PURE__ */ t(V, { q: i, facet: o }),
|
||||
e === "champions" && /* @__PURE__ */ t(X, { facet: o }),
|
||||
e === "places" && /* @__PURE__ */ t(J, { q: i, facet: o })
|
||||
] })
|
||||
] }) });
|
||||
}
|
||||
const g = (e) => Number.isFinite(e) ? e.toLocaleString() : "—";
|
||||
function Y(e, a) {
|
||||
const s = (r) => r >= 60 ? `${Math.round(r / 60)}m` : `${r}s`;
|
||||
return !Number.isFinite(e) || !Number.isFinite(a) ? null : e === a ? s(e) : `${s(e)}–${s(a)}`;
|
||||
}
|
||||
function P({ title: e, right: a, children: s }) {
|
||||
return /* @__PURE__ */ l("section", { className: "panel", style: { padding: 18 }, children: [
|
||||
/* @__PURE__ */ l("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12 }, children: [
|
||||
/* @__PURE__ */ t("h2", { className: "display", style: { margin: "0 0 12px", fontSize: "1.02rem", color: "var(--head)" }, children: e }),
|
||||
a
|
||||
] }),
|
||||
s
|
||||
] });
|
||||
}
|
||||
function Z({ places: e }) {
|
||||
return e.length === 0 ? /* @__PURE__ */ t("p", { className: "sans dim", style: { margin: 0 }, children: "No placed spawners." }) : /* @__PURE__ */ t("div", { children: e.map((a) => /* @__PURE__ */ l(
|
||||
"div",
|
||||
{
|
||||
className: "sans",
|
||||
style: {
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: "6px 0",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
fontSize: "0.86rem"
|
||||
},
|
||||
children: [
|
||||
/* @__PURE__ */ t("span", { style: { minWidth: 0, color: "var(--head)" }, children: a.label }),
|
||||
/* @__PURE__ */ l("span", { className: "dim", style: { flex: "none" }, children: [
|
||||
a.facet,
|
||||
" · ",
|
||||
g(a.spawners),
|
||||
" spawner",
|
||||
a.spawners === 1 ? "" : "s",
|
||||
" · up to",
|
||||
" ",
|
||||
g(a.maxAlive),
|
||||
" at once"
|
||||
] })
|
||||
]
|
||||
},
|
||||
`${a.facet}:${a.label}`
|
||||
)) });
|
||||
}
|
||||
function ee({ spawners: e, truncated: a }) {
|
||||
const [s, r] = h(!1);
|
||||
return e.length === 0 ? null : /* @__PURE__ */ t(
|
||||
P,
|
||||
{
|
||||
title: "Individual spawners",
|
||||
right: /* @__PURE__ */ t(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
className: "sans",
|
||||
onClick: () => r((i) => !i),
|
||||
style: { background: "none", border: "none", color: "var(--accent)", cursor: "pointer", fontSize: "0.78rem" },
|
||||
children: s ? "Hide" : `Show ${g(e.length)}`
|
||||
}
|
||||
),
|
||||
children: s && /* @__PURE__ */ l("div", { style: { overflowX: "auto" }, children: [
|
||||
/* @__PURE__ */ l("table", { className: "sans", style: { width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }, children: [
|
||||
/* @__PURE__ */ t("thead", { children: /* @__PURE__ */ l("tr", { style: { textAlign: "left", color: "var(--muted)" }, children: [
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Place" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Facet" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Coords" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 8px 8px 0" }, children: "Max" }),
|
||||
/* @__PURE__ */ t("th", { style: { padding: "4px 0 8px 0" }, children: "Respawn" })
|
||||
] }) }),
|
||||
/* @__PURE__ */ t("tbody", { children: e.map((i) => /* @__PURE__ */ l("tr", { style: { borderTop: "1px solid var(--line)" }, children: [
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0", color: "var(--head)" }, children: i.label }),
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: i.facet }),
|
||||
/* @__PURE__ */ l("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: [
|
||||
i.x,
|
||||
", ",
|
||||
i.y
|
||||
] }),
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 8px 6px 0" }, className: "dim", children: g(i.maxCount) }),
|
||||
/* @__PURE__ */ t("td", { style: { padding: "6px 0" }, className: "dim", children: Y(i.minDelay, i.maxDelay) || "—" })
|
||||
] }, i.id)) })
|
||||
] }),
|
||||
a && /* @__PURE__ */ t("p", { className: "sans dim", style: { fontSize: "0.74rem", margin: "10px 0 0" }, children: "Only the largest spawners are listed." })
|
||||
] })
|
||||
}
|
||||
);
|
||||
}
|
||||
function te() {
|
||||
var o;
|
||||
const { slug: e } = M(), { loading: a, error: s, data: r } = k(() => y.atlas.creature(e), [e]), i = (s == null ? void 0 : s.status) === 404 || (s == null ? void 0 : s.message) === "Not Found", p = j(
|
||||
() => Object.entries((r == null ? void 0 : r.facets) || {}).sort((c, n) => n[1] - c[1]),
|
||||
[r]
|
||||
);
|
||||
return /* @__PURE__ */ t(F, { section: "website", children: /* @__PURE__ */ l("div", { className: "shell-narrow page-body", children: [
|
||||
/* @__PURE__ */ t("p", { className: "sans", style: { marginBottom: 8 }, children: /* @__PURE__ */ t(z, { to: "/uo/atlas", style: { color: "var(--accent)", fontSize: "0.78rem" }, children: "← Spawn atlas" }) }),
|
||||
a && /* @__PURE__ */ t(C, {}),
|
||||
s && !i && /* @__PURE__ */ t(v, { message: "Could not load that creature right now." }),
|
||||
i && /* @__PURE__ */ t(S, { children: "Nothing by that name spawns on this shard." }),
|
||||
!a && !s && r && /* @__PURE__ */ l(A, { children: [
|
||||
/* @__PURE__ */ t(
|
||||
D,
|
||||
{
|
||||
eyebrow: "Bestiary",
|
||||
title: r.name,
|
||||
lead: `Up to ${g(r.total)} alive at once across ${g(r.points)} spawner${r.points === 1 ? "" : "s"}.`
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ l("div", { style: { display: "flex", flexDirection: "column", gap: 12 }, children: [
|
||||
/* @__PURE__ */ t(
|
||||
P,
|
||||
{
|
||||
title: "Where it spawns",
|
||||
right: /* @__PURE__ */ t("span", { className: "sans dim", style: { fontSize: "0.74rem" }, children: p.map(([c, n]) => `${c} (${n})`).join(" · ") }),
|
||||
children: /* @__PURE__ */ t(Z, { places: r.places || [] })
|
||||
}
|
||||
),
|
||||
/* @__PURE__ */ t(ee, { spawners: r.spawners || [], truncated: !!r.spawnersTruncated }),
|
||||
((o = r.alsoHere) == null ? void 0 : o.length) > 0 && /* @__PURE__ */ t(P, { title: "Shares a spawner with", children: /* @__PURE__ */ t("div", { style: { display: "flex", flexWrap: "wrap", gap: 8 }, children: r.alsoHere.map((c) => /* @__PURE__ */ l(
|
||||
z,
|
||||
{
|
||||
to: `/uo/atlas/${encodeURIComponent(c.slug)}`,
|
||||
className: "sans",
|
||||
style: {
|
||||
fontSize: "0.78rem",
|
||||
padding: "4px 11px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--muted)",
|
||||
textDecoration: "none"
|
||||
},
|
||||
children: [
|
||||
c.name,
|
||||
" ",
|
||||
/* @__PURE__ */ l("span", { className: "dim", children: [
|
||||
"×",
|
||||
g(c.shared)
|
||||
] })
|
||||
]
|
||||
},
|
||||
c.slug
|
||||
)) }) })
|
||||
] })
|
||||
] })
|
||||
] }) });
|
||||
}
|
||||
const $ = "uo", T = "^1.0.0";
|
||||
function ae(e) {
|
||||
const [a] = String(e || "").split(".");
|
||||
return a === T.replace(/^\^/, "").split(".")[0];
|
||||
}
|
||||
const b = window.__rg;
|
||||
b ? ae(b.version) ? (b.registry.registerRoutes($, {
|
||||
// Paths are relative to the module's namespace; core prefixes them, so these
|
||||
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
|
||||
public: [
|
||||
{ path: "atlas", element: /* @__PURE__ */ t(K, {}) },
|
||||
{ path: "atlas/:slug", element: /* @__PURE__ */ t(te, {}) }
|
||||
]
|
||||
}), b.registry.registerNav($, {
|
||||
area: "public",
|
||||
items: [{ label: "Atlas", to: "/uo/atlas", feature: "atlas", order: 12 }]
|
||||
})) : console.error(`[module-${$}] needs core API ${T}, this core is ${b.version} — not registering`) : console.error(`[module-${$}] window.__rg is missing — core did not publish its shared dependencies`);
|
||||
1691
modules/uo/client/package-lock.json
generated
Normal file
1691
modules/uo/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
modules/uo/client/package.json
Normal file
13
modules/uo/client/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "module-uo-client",
|
||||
"private": true,
|
||||
"version": "0.1.0-spike",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
47
modules/uo/client/src/api.js
Normal file
47
modules/uo/client/src/api.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// module-uo's API bindings.
|
||||
//
|
||||
// These used to be `api.atlas` inside core's client/src/api/client.js — a module
|
||||
// namespace living in core (MODULE_API.md §3.5). The module owns the paths
|
||||
// because it owns the routes at the other end; core hands over only the request
|
||||
// primitive: same-origin /api/v1, cookies included, JSON in/out, ApiError on a
|
||||
// non-2xx.
|
||||
const { request } = window.__rg.api
|
||||
|
||||
const withQs = (s) => (s ? `?${s}` : '')
|
||||
|
||||
export const atlas = {
|
||||
creatures: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
if (opts.offset) qs.set('offset', opts.offset)
|
||||
return request(`/public/atlas/creatures${withQs(qs.toString())}`)
|
||||
},
|
||||
creature: (slug, opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.points) qs.set('points', opts.points)
|
||||
return request(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
||||
},
|
||||
regions: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
return request(`/public/atlas/regions${withQs(qs.toString())}`)
|
||||
},
|
||||
landmarks: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
return request(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
||||
},
|
||||
champions: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
return request(`/public/atlas/champions${withQs(qs.toString())}`)
|
||||
},
|
||||
meta: () => request('/public/atlas/meta'),
|
||||
}
|
||||
|
||||
export const api = { atlas }
|
||||
53
modules/uo/client/src/entry.jsx
Normal file
53
modules/uo/client/src/entry.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// ── module-uo · client entry point ─────────────────────────────────────────
|
||||
//
|
||||
// The prebuilt ESM chunk core loads as
|
||||
// `<script type="module" src="/modules/uo/entry.js">`. Same-origin, so
|
||||
// `script-src 'self'` admits it with no nonce and no inline — which is the
|
||||
// entire reason the client half is shaped this way (MODULE_SYSTEM.md §1.14).
|
||||
//
|
||||
// It evaluates AFTER core's bundle (deferred module scripts run in document
|
||||
// order) and BEFORE core renders (main.jsx waits for DOMContentLoaded), so
|
||||
// registering synchronously here is enough — there is no loading state to
|
||||
// coordinate and no re-render to trigger.
|
||||
|
||||
import Atlas from './pages/Atlas.jsx'
|
||||
import AtlasCreature from './pages/AtlasCreature.jsx'
|
||||
|
||||
const ID = 'uo'
|
||||
const CORE_API = '^1.0.0'
|
||||
|
||||
// The client-side twin of the server's coreApi check. A module built against a
|
||||
// contract this core does not implement must refuse to register rather than
|
||||
// half-work: a missing kit member is a blank page three clicks in, and the
|
||||
// version is knowable now.
|
||||
function compatible(version) {
|
||||
const [major] = String(version || '').split('.')
|
||||
return major === CORE_API.replace(/^\^/, '').split('.')[0]
|
||||
}
|
||||
|
||||
const rg = window.__rg
|
||||
|
||||
if (!rg) {
|
||||
// Not an exception: throwing from a module script is an uncaught error in the
|
||||
// page, and a module failing to load must never be the site failing to load.
|
||||
console.error(`[module-${ID}] window.__rg is missing — core did not publish its shared dependencies`)
|
||||
} else if (!compatible(rg.version)) {
|
||||
console.error(`[module-${ID}] needs core API ${CORE_API}, this core is ${rg.version} — not registering`)
|
||||
} else {
|
||||
rg.registry.registerRoutes(ID, {
|
||||
// Paths are relative to the module's namespace; core prefixes them, so these
|
||||
// render at /uo/atlas and /uo/atlas/:slug (MODULE_SYSTEM.md §2.8).
|
||||
public: [
|
||||
{ path: 'atlas', element: <Atlas /> },
|
||||
{ path: 'atlas/:slug', element: <AtlasCreature /> },
|
||||
],
|
||||
})
|
||||
|
||||
// Interleaves into core's public nav rather than appending a "UO" group.
|
||||
// `order: 12` puts it where the Atlas link already sat — after Wiki and the
|
||||
// shard boards, before About. `feature` is resolved by the provider below.
|
||||
rg.registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas', order: 12 }],
|
||||
})
|
||||
}
|
||||
307
modules/uo/client/src/pages/Atlas.jsx
Normal file
307
modules/uo/client/src/pages/Atlas.jsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// What the shard CONTAINS, as opposed to what it is doing: which creatures
|
||||
// spawn, where, and which champion altars are configured. There is no live feed
|
||||
// here and no `connected` indicator, deliberately — this is parsed from the
|
||||
// shard's own files and stays complete while the shard is down.
|
||||
//
|
||||
// Facet names come from the shard's data, never from a list in this file. A
|
||||
// shard running custom maps gets its own names in the filter with no code
|
||||
// change (docs/link/v3.md §6.1 R2).
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
const TABS = [
|
||||
{ key: 'creatures', label: 'Creatures' },
|
||||
{ key: 'champions', label: 'Champion altars' },
|
||||
{ key: 'places', label: 'Places' },
|
||||
]
|
||||
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatureCard({ creature }) {
|
||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||
return (
|
||||
<Link
|
||||
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '13px 15px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
style={{
|
||||
fontSize: '0.98rem',
|
||||
color: 'var(--head)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{creature.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{facets.length === 0
|
||||
? '—'
|
||||
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
|
||||
{num(creature.points)} spawners
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// The creature list owns its own paging rather than going through useAsync: a
|
||||
// "load more" appends to what is already on screen, which a hook that resets to
|
||||
// `{ loading: true, data: null }` on every dependency change cannot express.
|
||||
function Creatures({ q, facet }) {
|
||||
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
|
||||
const [more, setMore] = useState(false)
|
||||
|
||||
const load = useCallback(
|
||||
async (offset) => {
|
||||
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
|
||||
return page
|
||||
},
|
||||
[q, facet],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ loading: true, error: null, items: [], total: 0 })
|
||||
load(0)
|
||||
.then((page) => {
|
||||
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
|
||||
})
|
||||
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const loadMore = async () => {
|
||||
setMore(true)
|
||||
try {
|
||||
const page = await load(state.items.length)
|
||||
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
|
||||
} catch {
|
||||
// A failed "load more" leaves what is already on screen alone; the button
|
||||
// simply stays available to retry.
|
||||
} finally {
|
||||
setMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) return <Loading />
|
||||
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
|
||||
if (state.items.length === 0) {
|
||||
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
Showing {num(state.items.length)} of {num(state.total)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.items.map((c) => (
|
||||
<CreatureCard key={c.slug} creature={c} />
|
||||
))}
|
||||
</div>
|
||||
{state.items.length < state.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||
{more ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The CONFIGURED altar roster — where the altars are and what each summons. The
|
||||
// live board ("it is on level 3 right now") is a different page, /site/champs,
|
||||
// fed by the sidecar. Both exist; they are not the same thing.
|
||||
function Champions({ facet }) {
|
||||
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the champion altars right now." />
|
||||
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((champ) => (
|
||||
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
|
||||
{champ.label || champ.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{champ.facet}
|
||||
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
|
||||
{champ.randomType ? 'Random champion' : champ.type || '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regions and landmarks together: both answer "where is that?", and splitting
|
||||
// them into two tabs would make the visitor guess which list a name lives in.
|
||||
function Places({ q, facet }) {
|
||||
const { loading, error, data } = useAsync(
|
||||
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
|
||||
[q, facet],
|
||||
)
|
||||
const rows = useMemo(() => {
|
||||
if (!data) return []
|
||||
const [regions, landmarks] = data
|
||||
return [
|
||||
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
|
||||
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
|
||||
].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [data])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load places right now." />
|
||||
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Atlas() {
|
||||
const [tab, setTab] = useState('creatures')
|
||||
const [input, setInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [facet, setFacet] = useState('')
|
||||
const meta = useAsync(() => api.atlas.meta())
|
||||
|
||||
// Debounced: typing "lizardman" should be one request, not nine.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setQ(input.trim()), 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [input])
|
||||
|
||||
const facets = meta.data?.facets || []
|
||||
const counts = meta.data?.counts || null
|
||||
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title="Spawn atlas"
|
||||
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||
/>
|
||||
|
||||
{/* The atlas is only as good as its placement rate, so the page states
|
||||
it rather than implying every spawner resolved to a named place. */}
|
||||
{counts && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||
{num(counts.creatures)} creatures across {num(counts.points)} spawners
|
||||
{Number.isFinite(counts.unresolvedPoints) && counts.points
|
||||
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
|
||||
: ''}
|
||||
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{TABS.map((t) => (
|
||||
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab !== 'champions' && (
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{facets.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
<Chip active={facet === ''} onClick={() => setFacet('')}>
|
||||
All facets
|
||||
</Chip>
|
||||
{facets.map((f) => (
|
||||
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
|
||||
{f}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.error && <ErrorState message="Could not load the atlas right now." />}
|
||||
{!meta.error && !meta.loading && !imported && (
|
||||
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
|
||||
)}
|
||||
|
||||
{!meta.error && imported && (
|
||||
<>
|
||||
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
|
||||
{tab === 'champions' && <Champions facet={facet} />}
|
||||
{tab === 'places' && <Places q={q} facet={facet} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
198
modules/uo/client/src/pages/AtlasCreature.jsx
Normal file
198
modules/uo/client/src/pages/AtlasCreature.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync } from '../ui.js'
|
||||
import { api } from '../api.js'
|
||||
|
||||
// One creature: where it spawns, and what spawns alongside it.
|
||||
//
|
||||
// `places` is the point of the page — the aggregate that turns 62 raw
|
||||
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
|
||||
// available underneath for the reader who actually wants a coordinate, but they
|
||||
// are secondary and collapsed by default.
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
|
||||
function delay(min, max) {
|
||||
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
||||
if (min === max) return fmt(min)
|
||||
return `${fmt(min)}–${fmt(max)}`
|
||||
}
|
||||
|
||||
function Panel({ title, right, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Places({ places }) {
|
||||
if (places.length === 0) {
|
||||
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{places.map((place) => (
|
||||
<div
|
||||
key={`${place.facet}:${place.label}`}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
|
||||
<span className="dim" style={{ flex: 'none' }}>
|
||||
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
|
||||
{num(place.maxAlive)} at once
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Spawners({ spawners, truncated }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (spawners.length === 0) return null
|
||||
return (
|
||||
<Panel
|
||||
title="Individual spawners"
|
||||
right={
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
|
||||
>
|
||||
{open ? 'Hide' : `Show ${num(spawners.length)}`}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{open && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
|
||||
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{spawners.map((s) => (
|
||||
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
|
||||
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{truncated && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
Only the largest spawners are listed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AtlasCreature() {
|
||||
const { slug } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
|
||||
|
||||
// A 404 here means "no such creature in this atlas", which is a real answer
|
||||
// and not a failure — a visitor following a stale link deserves to be told
|
||||
// that plainly rather than shown a generic error box.
|
||||
const missing = error?.status === 404 || error?.message === 'Not Found'
|
||||
|
||||
const facets = useMemo(
|
||||
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
|
||||
[data],
|
||||
)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<p className="sans" style={{ marginBottom: 8 }}>
|
||||
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||
← Spawn atlas
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && !missing && <ErrorState message="Could not load that creature right now." />}
|
||||
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title={data.name}
|
||||
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel
|
||||
title="Where it spawns"
|
||||
right={
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Places places={data.places || []} />
|
||||
</Panel>
|
||||
|
||||
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
|
||||
|
||||
{data.alsoHere?.length > 0 && (
|
||||
<Panel title="Shares a spawner with">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{data.alsoHere.map((other) => (
|
||||
<Link
|
||||
key={other.slug}
|
||||
to={`/uo/atlas/${encodeURIComponent(other.slug)}`}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '4px 11px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--line)',
|
||||
color: 'var(--muted)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{other.name} <span className="dim">×{num(other.shared)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
3
modules/uo/client/src/shim/react-dom-client.js
vendored
Normal file
3
modules/uo/client/src/shim/react-dom-client.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
const reactDom = window.__rg.reactDom
|
||||
export default reactDom
|
||||
export const { createRoot, hydrateRoot } = reactDom
|
||||
8
modules/uo/client/src/shim/react-jsx-runtime.js
vendored
Normal file
8
modules/uo/client/src/shim/react-jsx-runtime.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// The automatic JSX runtime, from core's global. Every .jsx file in this module
|
||||
// compiles to imports from here, so this is the single hottest path in the
|
||||
// bundle — and the one that would silently produce a SECOND React if it resolved
|
||||
// to a bundled copy instead.
|
||||
const jsx = window.__rg.jsxRuntime
|
||||
export default jsx
|
||||
export const { jsx: jsxFn, jsxs, Fragment } = jsx
|
||||
export { jsxFn as jsx }
|
||||
10
modules/uo/client/src/shim/react-router-dom.js
vendored
Normal file
10
modules/uo/client/src/shim/react-router-dom.js
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
// react-router-dom from core's global. Same singleton argument as React, with a
|
||||
// sharper edge: the router's context is created by whichever copy is loaded, so
|
||||
// a second copy would give module pages an EMPTY router context — <Link> would
|
||||
// throw and useParams() would return {} rather than the URL's params.
|
||||
const router = window.__rg.router
|
||||
export default router
|
||||
export const {
|
||||
Link, NavLink, Navigate, Outlet, Route, Routes, useParams, useNavigate,
|
||||
useLocation, useSearchParams, createBrowserRouter, RouterProvider,
|
||||
} = router
|
||||
19
modules/uo/client/src/shim/react.js
vendored
Normal file
19
modules/uo/client/src/shim/react.js
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
// React, taken from core's global rather than bundled.
|
||||
//
|
||||
// There is exactly ONE React in the page and core owns it (MODULE_API.md §3.2).
|
||||
// A module that bundled its own would get a second hook dispatcher and fail at
|
||||
// the first useState — so `react` is declared external in vite.config.js and
|
||||
// aliased here.
|
||||
//
|
||||
// Why an alias module rather than rollup's `output.globals`: `globals` only
|
||||
// applies to iife/umd output, and this is an ES module. An alias is the ESM
|
||||
// equivalent, and it also keeps named imports (`import { useState } from
|
||||
// 'react'`) working unchanged in the page source.
|
||||
const react = window.__rg.react
|
||||
|
||||
export default react
|
||||
export const {
|
||||
useState, useEffect, useMemo, useCallback, useRef, useContext, useReducer,
|
||||
createElement, cloneElement, createContext, forwardRef, memo, Fragment,
|
||||
Children, isValidElement, StrictMode, Suspense, lazy,
|
||||
} = react
|
||||
13
modules/uo/client/src/ui.js
Normal file
13
modules/uo/client/src/ui.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Core's shared UI kit, from the global.
|
||||
//
|
||||
// This is the §3.4 kit: a curated, closed set — the layout chrome, the three
|
||||
// page states, the async hook and the two read-only contexts. It exists because
|
||||
// a module page that does not use core's layout is a module page that does not
|
||||
// look like the site it is installed in, and drifts further every time core's
|
||||
// chrome changes.
|
||||
//
|
||||
// Anything NOT in here, the module bundles itself.
|
||||
const { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite } =
|
||||
window.__rg.ui
|
||||
|
||||
export { PublicLayout, PageHeader, Loading, ErrorState, EmptyState, useAsync, useAuth, useSite }
|
||||
43
modules/uo/client/vite.config.js
Normal file
43
modules/uo/client/vite.config.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// Library mode: one prebuilt ESM chunk, published by CI and dropped onto the
|
||||
// operator's volume. The operator never builds anything (MODULE_SYSTEM.md §2.5).
|
||||
//
|
||||
// The four externals are the whole contract with core. Declaring them external
|
||||
// alone is not enough, though: rollup would emit bare `import 'react'`
|
||||
// specifiers, which a browser cannot resolve without an import map — and CSP
|
||||
// forbids the inline <script type="importmap"> that would provide one. So each
|
||||
// is ALIASED to a two-line shim that re-exports from window.__rg, and the
|
||||
// external list then only has to stop Vite from following them into node_modules
|
||||
// this package does not have.
|
||||
const shim = (f) => path.resolve(import.meta.dirname, 'src/shim', f)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// EXACT matches, via the array form. Vite's object form does PREFIX
|
||||
// replacement, so a plain `react` key also rewrote `react/jsx-runtime` into
|
||||
// `src/shim/react.js/jsx-runtime` — a path that does not exist, and the
|
||||
// first thing this build hit.
|
||||
alias: [
|
||||
{ find: /^react$/, replacement: shim('react.js') },
|
||||
{ find: /^react\/jsx-runtime$/, replacement: shim('react-jsx-runtime.js') },
|
||||
{ find: /^react-dom\/client$/, replacement: shim('react-dom-client.js') },
|
||||
{ find: /^react-router-dom$/, replacement: shim('react-router-dom.js') },
|
||||
],
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.resolve(import.meta.dirname, 'src/entry.jsx'),
|
||||
formats: ['es'],
|
||||
fileName: () => 'entry.js',
|
||||
},
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
// Same reason as core's client: no inline bootstrap script for
|
||||
// `script-src 'self'` to trip on.
|
||||
modulePreload: { polyfill: false },
|
||||
},
|
||||
})
|
||||
14
modules/uo/module.json
Normal file
14
modules/uo/module.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "uo",
|
||||
"name": "Ultima Online",
|
||||
"version": "0.1.0-spike",
|
||||
"coreApi": "^1.0.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
"purge": "server/db/purge.sql",
|
||||
"mounts": {
|
||||
"public": ["/atlas"]
|
||||
},
|
||||
"capabilities": ["atlas"]
|
||||
}
|
||||
88
modules/uo/server/core.js
Normal file
88
modules/uo/server/core.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// ── The module's single point of contact with core ─────────────────────────
|
||||
//
|
||||
// Every other file in this module imports THIS file instead of reaching into
|
||||
// the website's tree. That is the whole mechanical trick behind the
|
||||
// zero-internal-imports rule (docs/website/MODULE_API.md §5.1): the moved files
|
||||
// changed by one `require` line each, and a CI grep for a relative path
|
||||
// escaping the module root can then be an exact test rather than a heuristic.
|
||||
//
|
||||
// It exists because `ctx` arrives as an ARGUMENT to register(), while the files
|
||||
// that need it are plain CommonJS modules that were written against top-level
|
||||
// requires. Rather than thread ctx through nine constructors, register() parks
|
||||
// it here once and everything else reads it lazily.
|
||||
//
|
||||
// Lazily is load-bearing: this file is required at module-require time, which is
|
||||
// during app.js's own require, and reading `ctx.db` eagerly would rebuild the
|
||||
// startup-time database dependency the loader is careful not to have.
|
||||
|
||||
let ctx = null
|
||||
|
||||
/** Called exactly once, by server/index.js, at the top of register(). */
|
||||
function init(next) {
|
||||
if (ctx) throw new Error('module-uo: core.init() called twice')
|
||||
ctx = next
|
||||
}
|
||||
|
||||
function require_() {
|
||||
if (!ctx) throw new Error('module-uo: core used before register() ran')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Forwarders rather than re-exports: `const { query } = require('./core')`
|
||||
// destructures at require time, which is before init(), so a plain re-export
|
||||
// would capture undefined. Each of these resolves ctx at CALL time.
|
||||
const query = (sql, params) => require_().db.query(sql, params)
|
||||
|
||||
const logger = (namespace) => require_().log(namespace)
|
||||
|
||||
const settings = {
|
||||
get: (key) => require_().settings.get(key),
|
||||
// `updatedBy` is the third parameter core's settings.model.set carries — the
|
||||
// atlas path setter passes it (shardAtlas.model.js:60), so dropping it here
|
||||
// would silently lose the audit attribution rather than fail.
|
||||
set: (key, value, updatedBy) => require_().settings.set(key, value, updatedBy),
|
||||
getInstanceName: () => require_().settings.getInstanceName(),
|
||||
}
|
||||
|
||||
const auth = {
|
||||
getUserFromRequest: (req) => require_().auth.getUserFromRequest(req),
|
||||
}
|
||||
|
||||
const middleware = {
|
||||
siteMode: (req, res, next) => require_().middleware.siteMode(req, res, next),
|
||||
validate: (req, res, next) => require_().middleware.validate(req, res, next),
|
||||
requireAuth: (req, res, next) => require_().middleware.requireAuth(req, res, next),
|
||||
noindex: (req, res, next) => require_().middleware.noindex(req, res, next),
|
||||
requireRole: (...roles) => {
|
||||
// requireRole is a FACTORY, so it must be resolved at call time and the
|
||||
// resulting middleware kept — resolving it per request would build a new
|
||||
// closure on every hit.
|
||||
let built = null
|
||||
return (req, res, next) => {
|
||||
built = built || require_().middleware.requireRole(...roles)
|
||||
return built(req, res, next)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
// Shared server dependencies, taken from core rather than required directly.
|
||||
// A module lives outside server/, so `require('express')` from here does not
|
||||
// resolve at all — and even where it did, a second express in the process
|
||||
// would be a second Router prototype. Same rule as React on the client.
|
||||
get express() { return require_().express },
|
||||
get validator() { return require_().validator },
|
||||
query,
|
||||
logger,
|
||||
settings,
|
||||
auth,
|
||||
middleware,
|
||||
get pool() { return require_().db.pool },
|
||||
get secretBox() { return require_().secretBox },
|
||||
get push() { return require_().push },
|
||||
get uploads() { return require_().uploads },
|
||||
get posts() { return require_().posts },
|
||||
get paths() { return require_().paths },
|
||||
get moduleId() { return require_().moduleId },
|
||||
}
|
||||
21
modules/uo/server/db/purge.sql
Normal file
21
modules/uo/server/db/purge.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- ── module-uo · purge ──────────────────────────────────────────────────────
|
||||
--
|
||||
-- DESTRUCTIVE. Run ONLY by the explicit admin purge action, never by uninstall
|
||||
-- (docs/website/MODULE_API.md §2.6) — uninstalling a module removes its code and
|
||||
-- retains its data, and an operator who wants the data gone has to say so.
|
||||
--
|
||||
-- Required because this module declares a schema fragment: a module that can
|
||||
-- create tables and cannot drop them leaves an operator with orphaned data and
|
||||
-- no supported way to remove it.
|
||||
--
|
||||
-- Dropped children-first even though these tables carry no foreign keys, so the
|
||||
-- order stays correct if Phase 3 adds one.
|
||||
|
||||
DROP TABLE IF EXISTS shard_atlas_pending;
|
||||
DROP TABLE IF EXISTS shard_atlas_meta;
|
||||
DROP TABLE IF EXISTS shard_champion_spawns;
|
||||
DROP TABLE IF EXISTS shard_landmarks;
|
||||
DROP TABLE IF EXISTS shard_regions;
|
||||
DROP TABLE IF EXISTS shard_spawn_point_types;
|
||||
DROP TABLE IF EXISTS shard_spawn_points;
|
||||
DROP TABLE IF EXISTS shard_spawn_creatures;
|
||||
166
modules/uo/server/db/schema.sql
Normal file
166
modules/uo/server/db/schema.sql
Normal file
@@ -0,0 +1,166 @@
|
||||
-- ── module-uo · schema fragment ────────────────────────────────────────────
|
||||
--
|
||||
-- Replayed by core's ensureSchema() immediately after core's own schema.sql,
|
||||
-- statement by statement, split the same way (docs/website/MODULE_API.md §2.6).
|
||||
-- It inherits core's rules because it goes through core's splitter: idempotent
|
||||
-- CREATE/ALTER only, no DROP, and no `--` inside a string literal.
|
||||
--
|
||||
-- SPIKE SCOPE: the eight spawn-atlas tables, lifted verbatim out of
|
||||
-- server/db/schema.sql. Phase 3 brings the other nineteen.
|
||||
--
|
||||
-- These names are NOT `uo_`-prefixed, which the contract otherwise requires of a
|
||||
-- module's tables. module-uo is grandfathered by an explicit allowlist in the
|
||||
-- loader: renaming twenty-seven live tables is a data migration this workstream
|
||||
-- deliberately does not do, and the prefix rule holds for every module written
|
||||
-- after this one.
|
||||
|
||||
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
|
||||
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
||||
-- and landmarks exist, and which champion altars are configured. Nothing here
|
||||
-- comes from the sidecar — it is imported from a committed artifact built off a
|
||||
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
|
||||
-- these tables stay populated whether the shard is up or not.
|
||||
--
|
||||
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
|
||||
-- in one transaction. Nothing else may write here, and nothing else may hold a
|
||||
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
|
||||
|
||||
-- One row per spawnable type, aggregated across the world. `total` is the sum of
|
||||
-- each type's own MX across every point that spawns it (how many exist at once);
|
||||
-- `facets` is a per-facet point count, so the facet filter and "where does this
|
||||
-- live" both answer without touching shard_spawn_points.
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
|
||||
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
|
||||
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
|
||||
total INT NOT NULL DEFAULT 0,
|
||||
points INT NOT NULL DEFAULT 0,
|
||||
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
|
||||
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
|
||||
-- creature art: sprites live in the operator's own client .mul/.uop files and
|
||||
-- are theirs to extract and place under uploads/atlas/. The UI renders without
|
||||
-- art when this is NULL, which is the normal case.
|
||||
art VARCHAR(255) NULL,
|
||||
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
|
||||
-- and FULLTEXT's min-token-length would break searches for names like "orc".
|
||||
INDEX idx_shard_spawn_creatures_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One row per spawner. `region`/`landmark` are the resolved place name — the
|
||||
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
|
||||
-- the resolved display string (region, else landmark, else 'Wilderness').
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
width INT NOT NULL DEFAULT 0,
|
||||
height INT NOT NULL DEFAULT 0,
|
||||
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
|
||||
max_count INT NOT NULL DEFAULT 0,
|
||||
min_delay INT NOT NULL DEFAULT 0,
|
||||
max_delay INT NOT NULL DEFAULT 0,
|
||||
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
|
||||
tod_end INT NOT NULL DEFAULT 0,
|
||||
tod_mode INT NOT NULL DEFAULT 0,
|
||||
region VARCHAR(120) NULL,
|
||||
landmark VARCHAR(120) NULL,
|
||||
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
||||
INDEX idx_shard_spawn_points_facet (facet),
|
||||
INDEX idx_shard_spawn_points_label (label)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The many-to-many between the two above: one spawner commonly carries several
|
||||
-- types (a single Trammel point spawns six), each with its own max. This is how
|
||||
-- /atlas/creatures/:slug finds the places a creature appears.
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
|
||||
point_id INT NOT NULL,
|
||||
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
|
||||
max_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (point_id, slug),
|
||||
INDEX idx_shard_spawn_point_types_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
|
||||
-- holds the region's rectangles; `priority` and rect area are what resolved each
|
||||
-- spawn point at build time, kept here so the admin drift check can re-derive.
|
||||
CREATE TABLE IF NOT EXISTS shard_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
type VARCHAR(80) NULL, -- ServUO region class
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
parent VARCHAR(120) NULL, -- enclosing named region, if any
|
||||
rects JSON NULL,
|
||||
INDEX idx_shard_regions_facet (facet),
|
||||
INDEX idx_shard_regions_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
|
||||
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
|
||||
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
|
||||
CREATE TABLE IF NOT EXISTS shard_landmarks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
grp VARCHAR(120) NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
z INT NOT NULL DEFAULT 0,
|
||||
INDEX idx_shard_landmarks_facet (facet),
|
||||
INDEX idx_shard_landmarks_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
||||
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
||||
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
||||
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
||||
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
|
||||
name VARCHAR(120) NOT NULL,
|
||||
grp VARCHAR(80) NULL, -- spawn group; one active per group
|
||||
type VARCHAR(80) NULL, -- '' when randomised per activation
|
||||
random_type TINYINT(1) NOT NULL DEFAULT 0,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
z INT NOT NULL DEFAULT 0,
|
||||
radius INT NOT NULL DEFAULT 0,
|
||||
label VARCHAR(120) NULL, -- resolved place name
|
||||
INDEX idx_shard_champion_spawns_facet (facet)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
|
||||
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
||||
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
||||
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
||||
-- is behind the committed artifact.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
payload JSON NOT NULL,
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
|
||||
-- NOT applied, because it would remove a facet the site currently serves.
|
||||
--
|
||||
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
|
||||
-- much as of a real map change, and boot cannot tell the two apart — so the
|
||||
-- refresh is staged here for a human instead of being applied. Startup is never
|
||||
-- blocked by it: the site comes up serving the atlas it already had.
|
||||
--
|
||||
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
|
||||
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
|
||||
-- keeps a multi-megabyte blob out of the database and guarantees the applied
|
||||
-- atlas matches the tree as it is at approval time, not as it was at boot.
|
||||
--
|
||||
-- `rejected` is remembered against those exact source hashes so a declined
|
||||
-- refresh does not re-prompt on every restart; changing the tree changes the
|
||||
-- hashes and asks again.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
|
||||
payload JSON NOT NULL, -- source hashes + facet diff
|
||||
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
59
modules/uo/server/index.js
Normal file
59
modules/uo/server/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ── module-uo · server entry point ─────────────────────────────────────────
|
||||
//
|
||||
// SPIKE SCOPE. Phase 1 carries only /api/v1/public/atlas/* out of core
|
||||
// (MODULE_SYSTEM.md §2.7): six routes, DB-backed, no sidecar, no SSE, one boot
|
||||
// hook. Phase 3 brings the rest — the other 12 router/controller files, the 7
|
||||
// remaining model directories, the notification-stream catalog and the
|
||||
// town-crier announce leg.
|
||||
//
|
||||
// Called ONCE, synchronously, during the website's app.js require. Everything
|
||||
// here must therefore be synchronous and must not touch the database: the route
|
||||
// manifest generator and the OpenAPI generator both require app.js with the
|
||||
// pool pointed at a dead port, and a module that queried here would hang both
|
||||
// (MODULE_API.md §2.2). Anything needing a live database goes in onBoot.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
module.exports = function register(ctx, api) {
|
||||
// Park ctx before requiring anything that reads it. The requires below pull in
|
||||
// the model layer, whose files resolve core lazily — but the ORDER still
|
||||
// matters for the router, which is constructed at require time.
|
||||
core.init(ctx)
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const atlasRouter = require('./router/atlas.router')
|
||||
const atlas = require('./model/shardAtlas/shardAtlas.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const log = ctx.log('boot')
|
||||
|
||||
// The URL is unchanged from when this router lived in core's
|
||||
// router/v1/public/index.js — that is the point, and routes.manifest.json is
|
||||
// the proof (MODULE_API.md §5.3).
|
||||
api.registerRoutes({
|
||||
public: { '/atlas': atlasRouter },
|
||||
})
|
||||
|
||||
// Was server.js:92, an explicit call in core's start(). Re-derive the spawn
|
||||
// atlas from the shard's own ServUO tree: the shard's maps change over its
|
||||
// lifetime — facets get added, replaced or renamed — so the atlas is rebuilt
|
||||
// on every boot rather than shipped as a snapshot that would silently go
|
||||
// stale. Hash-gated, so an unchanged tree costs one read pass and no write.
|
||||
//
|
||||
// Best-effort by contract: no configured path, an unreadable mount or a
|
||||
// malformed file must never stop the site coming up, and a refresh that would
|
||||
// REMOVE a facet is staged for admin approval instead of being applied. So it
|
||||
// is caught HERE rather than left to the loader — the loader's catch would be
|
||||
// correct about the failure but wrong about the severity, marking the module
|
||||
// startup_failed and 503-ing six routes that serve perfectly good stale data.
|
||||
api.onBoot(async () => {
|
||||
try {
|
||||
const result = await atlas.refreshOnBoot()
|
||||
log.info('spawn atlas refreshed', { status: result && result.status })
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas refresh failed — serving whatever was last imported', {
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const { pool, query } = require('../../utils/db')
|
||||
const { pool, query } = require('../../core')
|
||||
|
||||
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
|
||||
// empties and refills all six inside one transaction, and nothing else in the
|
||||
@@ -192,6 +192,185 @@ async function clearPending() {
|
||||
return query('DELETE FROM shard_atlas_pending')
|
||||
}
|
||||
|
||||
// ── Reads (the public /atlas surface) ──────────────────────────────────────
|
||||
//
|
||||
// Every read here is a plain indexed query over ~7k rows and is served entirely
|
||||
// from MariaDB: the atlas is static shard content, so nothing on this path
|
||||
// touches the sidecar and nothing degrades when the shard is down.
|
||||
//
|
||||
// A facet filter is expressed as EXISTS over the points, never as a JSON path
|
||||
// built from caller input. `shard_spawn_creatures.facets` is a JSON object keyed
|
||||
// by facet name, and matching a key means either concatenating the name into a
|
||||
// path or handing it to JSON_SEARCH — whose search string treats `%` and `_` as
|
||||
// wildcards, so `?facet=%` would quietly match everything. The join is exact and
|
||||
// uses the indexes that already exist.
|
||||
const CREATURE_FACET_EXISTS = `EXISTS (
|
||||
SELECT 1 FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = c.slug AND p.facet = ?
|
||||
)`
|
||||
|
||||
// Build the WHERE for a creature search. `q` is a substring match on the display
|
||||
// name — a LIKE scan, which is free at ~800 rows and, unlike FULLTEXT, has no
|
||||
// minimum token length to break a search for "orc".
|
||||
function creatureWhere({ q, facet }) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (q) {
|
||||
where.push('c.name LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
if (facet) {
|
||||
where.push(CREATURE_FACET_EXISTS)
|
||||
params.push(facet)
|
||||
}
|
||||
return { sql: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
async function countCreatures({ q = '', facet = '' } = {}) {
|
||||
const { sql, params } = creatureWhere({ q, facet })
|
||||
const rows = await query(`SELECT COUNT(*) AS n FROM shard_spawn_creatures c ${sql}`, params)
|
||||
return rows[0] ? Number(rows[0].n) : 0
|
||||
}
|
||||
|
||||
function listCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||
const { sql, params } = creatureWhere({ q, facet })
|
||||
return query(
|
||||
`SELECT c.slug, c.name, c.total, c.points, c.facets, c.art
|
||||
FROM shard_spawn_creatures c
|
||||
${sql}
|
||||
ORDER BY c.total DESC, c.name ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
async function getCreature(slug) {
|
||||
const rows = await query(
|
||||
'SELECT slug, name, total, points, facets, art FROM shard_spawn_creatures WHERE slug = ?',
|
||||
[slug],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a creature spawns, grouped by resolved place.
|
||||
*
|
||||
* This is the answer the atlas exists to give — "lizardman → Shrines,
|
||||
* Isamu-Jima, Yew" — so it is aggregated in SQL rather than by summing 6,455
|
||||
* point rows in Node.
|
||||
*/
|
||||
function listCreaturePlaces(slug, { facet = '' } = {}) {
|
||||
const params = [slug]
|
||||
let facetSql = ''
|
||||
if (facet) {
|
||||
facetSql = 'AND p.facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
return query(
|
||||
`SELECT p.facet, p.label, COUNT(*) AS spawners, SUM(t.max_count) AS max_alive
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = ? ${facetSql}
|
||||
GROUP BY p.facet, p.label
|
||||
ORDER BY spawners DESC, p.facet ASC, p.label ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** The individual spawners for a creature, newest-largest first. Bounded. */
|
||||
function listCreaturePoints(slug, { facet = '', limit = 200 } = {}) {
|
||||
const params = [slug]
|
||||
let facetSql = ''
|
||||
if (facet) {
|
||||
facetSql = 'AND p.facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
params.push(limit)
|
||||
return query(
|
||||
`SELECT p.id, p.facet, p.name, p.x, p.y, p.width, p.height, p.spawn_range,
|
||||
p.min_delay, p.max_delay, p.tod_start, p.tod_end, p.tod_mode,
|
||||
p.region, p.landmark, p.label, t.max_count
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = ? ${facetSql}
|
||||
ORDER BY t.max_count DESC, p.facet ASC, p.label ASC, p.id ASC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** Every other creature sharing a spawner with this one. */
|
||||
function listCreatureCompanions(slug, { limit = 24 } = {}) {
|
||||
return query(
|
||||
`SELECT o.slug, c.name, COUNT(*) AS shared
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_point_types o ON o.point_id = t.point_id AND o.slug <> t.slug
|
||||
JOIN shard_spawn_creatures c ON c.slug = o.slug
|
||||
WHERE t.slug = ?
|
||||
GROUP BY o.slug, c.name
|
||||
ORDER BY shared DESC, c.name ASC
|
||||
LIMIT ?`,
|
||||
[slug, limit],
|
||||
)
|
||||
}
|
||||
|
||||
function listRegions({ facet = '', q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (facet) {
|
||||
where.push('facet = ?')
|
||||
params.push(facet)
|
||||
}
|
||||
if (q) {
|
||||
where.push('name LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT facet, name, type, priority, parent, rects
|
||||
FROM shard_regions
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY facet ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
function listLandmarks({ facet = '', q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (facet) {
|
||||
where.push('facet = ?')
|
||||
params.push(facet)
|
||||
}
|
||||
if (q) {
|
||||
where.push('(name LIKE ? OR grp LIKE ?)')
|
||||
params.push(`%${q}%`, `%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT facet, name, grp, x, y, z
|
||||
FROM shard_landmarks
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY facet ASC, grp ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
function listChampions({ facet = '' } = {}) {
|
||||
const params = []
|
||||
let where = ''
|
||||
if (facet) {
|
||||
where = 'WHERE facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
return query(
|
||||
`SELECT slug, name, grp, type, random_type, facet, x, y, z, radius, label
|
||||
FROM shard_champion_spawns
|
||||
${where}
|
||||
ORDER BY facet ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceAtlas,
|
||||
getMeta,
|
||||
@@ -199,4 +378,13 @@ module.exports = {
|
||||
getPending,
|
||||
setPending,
|
||||
clearPending,
|
||||
countCreatures,
|
||||
listCreatures,
|
||||
getCreature,
|
||||
listCreaturePlaces,
|
||||
listCreaturePoints,
|
||||
listCreatureCompanions,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listChampions,
|
||||
}
|
||||
@@ -2,15 +2,16 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const db = require('./shardAtlas.db')
|
||||
const settings = require('../settings/settings.model')
|
||||
const { settings } = require('../../core')
|
||||
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||
const {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
buildAtlas,
|
||||
hashSources,
|
||||
sameSources,
|
||||
} = require('../../utils/spawnAtlasSource')
|
||||
const log = require('../../utils/logger')('shardAtlas')
|
||||
const log = require('../../core').logger('atlas')
|
||||
|
||||
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||
//
|
||||
@@ -120,6 +121,14 @@ async function applyAtlas(atlas) {
|
||||
* `force` skips the hash check (an admin asking for a reimport) and `approve`
|
||||
* additionally accepts facet loss (an admin approving a staged refresh).
|
||||
*/
|
||||
/**
|
||||
* Was the loaded atlas built by THIS parser?
|
||||
*
|
||||
* An atlas imported before `parserVersion` existed reports undefined, which is
|
||||
* correctly "no" — those are exactly the ones carrying the old readings.
|
||||
*/
|
||||
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||
|
||||
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||
// An explicit override wins outright — it is a one-off "use this tree", and it
|
||||
// must not be silently overruled by the configured path the way an env default
|
||||
@@ -142,7 +151,11 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256]))
|
||||
: null
|
||||
|
||||
if (!force && sameSources(hashes, loaded)) {
|
||||
// Two things make a loaded atlas stale: the tree changed, or the PARSER did.
|
||||
// Only checking the tree would strand an install whose maps never change on
|
||||
// whatever an older build derived — a corrected parse would ship and never
|
||||
// reach the data.
|
||||
if (!force && sameSources(hashes, loaded) && currentParser(meta)) {
|
||||
return { status: 'unchanged', path: root }
|
||||
}
|
||||
|
||||
@@ -227,7 +240,9 @@ async function status({ path: pathOverride = '' } = {}) {
|
||||
const loaded = meta?.source
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
|
||||
: null
|
||||
drift = !sameSources(hashes, loaded)
|
||||
// Same question `refresh` asks: an import picks something up when either
|
||||
// the tree or the parser has moved on.
|
||||
drift = !sameSources(hashes, loaded) || !currentParser(meta)
|
||||
} catch {
|
||||
treeReadable = false
|
||||
}
|
||||
@@ -282,6 +297,173 @@ async function refreshOnBoot() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The shapes the /public/atlas endpoints serve. Rows are camelCased here rather
|
||||
// than in the controller, for the same reason shardState does it: the column
|
||||
// names are an implementation detail of the import, and the browser contract
|
||||
// should not move when a column is renamed.
|
||||
|
||||
const jsonOr = (value, fallback) => {
|
||||
if (value == null) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const shapeCreature = (row) => ({
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
// `total` is the summed MaxCount across every spawner (how many can be alive
|
||||
// at once); `points` is how many spawners mention it. They answer different
|
||||
// questions and the UI shows both.
|
||||
total: row.total,
|
||||
points: row.points,
|
||||
facets: jsonOr(row.facets, {}),
|
||||
art: row.art || null,
|
||||
})
|
||||
|
||||
const shapePlace = (row) => ({
|
||||
facet: row.facet,
|
||||
label: row.label,
|
||||
spawners: Number(row.spawners) || 0,
|
||||
maxAlive: Number(row.max_alive) || 0,
|
||||
})
|
||||
|
||||
const shapePoint = (row) => ({
|
||||
id: row.id,
|
||||
facet: row.facet,
|
||||
name: row.name || null,
|
||||
x: row.x,
|
||||
y: row.y,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
range: row.spawn_range,
|
||||
maxCount: row.max_count,
|
||||
minDelay: row.min_delay,
|
||||
maxDelay: row.max_delay,
|
||||
todStart: row.tod_start,
|
||||
todEnd: row.tod_end,
|
||||
todMode: row.tod_mode,
|
||||
region: row.region || null,
|
||||
landmark: row.landmark || null,
|
||||
label: row.label,
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated creature search. Returns the page plus the unpaginated total, so
|
||||
* the UI can say "showing 50 of 800" without a second round trip.
|
||||
*/
|
||||
async function searchCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||
const [rows, total] = await Promise.all([
|
||||
db.listCreatures({ q, facet, limit, offset }),
|
||||
db.countCreatures({ q, facet }),
|
||||
])
|
||||
return { total, limit, offset, creatures: rows.map(shapeCreature) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One creature: its totals, the places it spawns (the aggregate the atlas
|
||||
* exists for), the individual spawners, and what else shares those spawners.
|
||||
*
|
||||
* `null` when the slug is unknown — the controller turns that into a 404.
|
||||
*/
|
||||
async function getCreature(slug, { facet = '', points = 200 } = {}) {
|
||||
const row = await db.getCreature(slug)
|
||||
if (!row) return null
|
||||
const [places, pointRows, alsoHere] = await Promise.all([
|
||||
db.listCreaturePlaces(slug, { facet }),
|
||||
db.listCreaturePoints(slug, { facet, limit: points }),
|
||||
db.listCreatureCompanions(slug),
|
||||
])
|
||||
return {
|
||||
...shapeCreature(row),
|
||||
places: places.map(shapePlace),
|
||||
// `spawners`, not `points`: shapeCreature already uses `points` for the
|
||||
// COUNT of spawners, and reusing the key for the list of them would make the
|
||||
// same field a number on the search route and an array here.
|
||||
spawners: pointRows.map(shapePoint),
|
||||
// Bounded by the query, so a creature on hundreds of spawners returns a page
|
||||
// rather than the world.
|
||||
spawnersTruncated: pointRows.length >= points,
|
||||
alsoHere: alsoHere.map((r) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
shared: Number(r.shared) || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function listRegions(opts = {}) {
|
||||
const rows = await db.listRegions(opts)
|
||||
return rows.map((r) => ({
|
||||
facet: r.facet,
|
||||
name: r.name,
|
||||
type: r.type || null,
|
||||
priority: r.priority,
|
||||
parent: r.parent || null,
|
||||
rects: jsonOr(r.rects, []),
|
||||
}))
|
||||
}
|
||||
|
||||
async function listLandmarks(opts = {}) {
|
||||
const rows = await db.listLandmarks(opts)
|
||||
return rows.map((r) => ({
|
||||
facet: r.facet,
|
||||
name: r.name,
|
||||
group: r.grp || null,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
}))
|
||||
}
|
||||
|
||||
async function listChampions(opts = {}) {
|
||||
const rows = await db.listChampions(opts)
|
||||
return rows.map((r) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
group: r.grp || null,
|
||||
// '' on the wire means "randomised at activation"; `randomType` says so
|
||||
// explicitly rather than making the client infer it from an empty string.
|
||||
type: r.type || null,
|
||||
randomType: !!r.random_type,
|
||||
facet: r.facet,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
radius: r.radius,
|
||||
label: r.label || null,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* What is loaded: the facet list, the counts, and when it was imported.
|
||||
*
|
||||
* Deliberately does NOT report the source path, the per-file hashes or whether
|
||||
* a refresh is pending. Those describe the operator's filesystem, and this is a
|
||||
* public endpoint; the admin status route carries them instead.
|
||||
*/
|
||||
async function publicMeta() {
|
||||
const [meta, facets] = await Promise.all([
|
||||
db.getMeta().catch(() => null),
|
||||
db.getFacets().catch(() => []),
|
||||
])
|
||||
return {
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
generatedAt: meta?.generatedAt ?? null,
|
||||
// The parse counts, not the row counts: `unresolvedPoints` is what lets the
|
||||
// page state its own placement accuracy instead of implying it is complete.
|
||||
counts: meta?.counts ?? null,
|
||||
facets,
|
||||
}
|
||||
}
|
||||
|
||||
const listFacets = () => db.getFacets()
|
||||
|
||||
module.exports = {
|
||||
refresh,
|
||||
refreshOnBoot,
|
||||
@@ -293,4 +475,11 @@ module.exports = {
|
||||
pointTypeRows,
|
||||
loadArtMap,
|
||||
SETTING_KEY,
|
||||
searchCreatures,
|
||||
getCreature,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listChampions,
|
||||
listFacets,
|
||||
publicMeta,
|
||||
}
|
||||
42
modules/uo/server/model/shardLinks/shardLinks.db.js
Normal file
42
modules/uo/server/model/shardLinks/shardLinks.db.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
const COLS = 'account, user_id, char_name, linked_at'
|
||||
|
||||
// Upsert a link. account is the PK, so a re-link moves the account to the new
|
||||
// user (the sidecar already treats /link/confirm as authoritative).
|
||||
async function upsert({ account, userId, charName }) {
|
||||
await query(
|
||||
`INSERT INTO shard_account_links (account, user_id, char_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
|
||||
[account, userId, charName || null],
|
||||
)
|
||||
return getByAccount(account)
|
||||
}
|
||||
|
||||
async function getByAccount(account) {
|
||||
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const listByUser = (userId) =>
|
||||
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
|
||||
|
||||
async function isOwnedBy(account, userId) {
|
||||
const rows = await query(
|
||||
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
|
||||
[account, userId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const remove = (account, userId) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||
|
||||
// Drop the mirror for an account regardless of which user held it — used to
|
||||
// reconcile when the tie is severed at the source (an in-game [unlink →
|
||||
// account.unlinked event, or a site-side DELETE /link/{account}).
|
||||
const removeByAccount = (account) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||
37
modules/uo/server/model/shardLinks/shardLinks.model.js
Normal file
37
modules/uo/server/model/shardLinks/shardLinks.model.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
|
||||
// authoritative link (it tags the game account on /link/confirm); this model
|
||||
// records it locally so the player portal can list links and enforce ownership.
|
||||
|
||||
const db = require('./shardLinks.db')
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
account: row.account,
|
||||
userId: row.user_id,
|
||||
charName: row.char_name || null,
|
||||
linkedAt: row.linked_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function link({ account, userId, charName }) {
|
||||
return toSafe(await db.upsert({ account, userId, charName }))
|
||||
}
|
||||
|
||||
async function listForUser(userId) {
|
||||
const rows = await db.listByUser(userId)
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
|
||||
|
||||
async function getByAccount(account) {
|
||||
return toSafe(await db.getByAccount(account))
|
||||
}
|
||||
|
||||
const unlink = (account, userId) => db.remove(account, userId)
|
||||
|
||||
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||
const removeByAccount = (account) => db.removeByAccount(account)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
|
||||
@@ -0,0 +1,37 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
|
||||
// compiles a default for every known feature and merges stored rows over it, so
|
||||
// a fresh install with an empty table behaves exactly as the site did pre-v3.
|
||||
|
||||
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
|
||||
|
||||
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
|
||||
|
||||
const getOne = (feature) =>
|
||||
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
|
||||
|
||||
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
|
||||
// {field: rung}; the caller has already stripped locked fields and validated
|
||||
// every rung against the ladder.
|
||||
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
|
||||
query(
|
||||
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
audience = VALUES(audience),
|
||||
stream = VALUES(stream),
|
||||
field_rules = VALUES(field_rules),
|
||||
updated_by = VALUES(updated_by)`,
|
||||
[
|
||||
feature,
|
||||
enabled ? 1 : 0,
|
||||
audience,
|
||||
stream ? 1 : 0,
|
||||
fieldRules == null ? null : JSON.stringify(fieldRules),
|
||||
updatedBy ?? null,
|
||||
],
|
||||
)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
@@ -0,0 +1,44 @@
|
||||
// ── Shard feature visibility (model) ───────────────────────────────────────
|
||||
//
|
||||
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
|
||||
// feature catalog, the locked fields, the kind→feature map — lives in
|
||||
// utils/shardVisibility.js; this file only reads and writes rows.
|
||||
|
||||
const db = require('./shardVisibility.db')
|
||||
|
||||
// The `field_rules` JSON column comes back as a string on the mariadb driver.
|
||||
function parseRules(raw) {
|
||||
if (raw == null) return {}
|
||||
if (typeof raw === 'object') return raw
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const toSafe = (row) =>
|
||||
row && {
|
||||
feature: row.feature,
|
||||
enabled: !!row.enabled,
|
||||
audience: row.audience,
|
||||
stream: row.stream == null ? null : !!row.stream,
|
||||
fieldRules: parseRules(row.field_rules),
|
||||
updatedBy: row.updated_by,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
|
||||
async function listAll() {
|
||||
const rows = await db.listAll()
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
async function getOne(feature) {
|
||||
const rows = await db.getOne(feature)
|
||||
return toSafe(rows[0])
|
||||
}
|
||||
|
||||
const upsert = (entry) => db.upsert(entry)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
134
modules/uo/server/router/atlas.controller.js
Normal file
134
modules/uo/server/router/atlas.controller.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── Public: the spawn atlas ────────────────────────────────────────────────
|
||||
//
|
||||
// A browsable catalogue of what the shard CONTAINS — which creatures spawn,
|
||||
// where, how many, and which champion altars are configured. Everything here is
|
||||
// a plain indexed read of the tables the boot-time import fills from the shard's
|
||||
// own ServUO tree (docs/website/SPAWN_ATLAS.md).
|
||||
//
|
||||
// Two properties separate this from /public/shard/*:
|
||||
//
|
||||
// • **Nothing touches the sidecar.** The atlas is static shard content, not
|
||||
// live shard state, so these pages stay fully populated while the shard is
|
||||
// down. That is why the routes are mounted at /public/atlas and are
|
||||
// siteMode-gated like /posts and /wiki, rather than under /shard.
|
||||
// • **The live champion feed is a different thing.** `/atlas/champions` is the
|
||||
// configured roster ("there is an Unholy Terror altar in Deceit");
|
||||
// `/shard/champs` is the running state ("it is on level 3 right now").
|
||||
//
|
||||
// Every response is still passed through `projectFeature` for the `atlas`
|
||||
// feature. It declares no sensitive fields today, so the projection is a
|
||||
// no-op — but v3.md §3.6.1's rule is that a read path returning shard data and
|
||||
// not projecting is a bug, and the cost of honouring it is one call per handler
|
||||
// rather than a retrofit the first time a field needs gating.
|
||||
|
||||
const atlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const visibility = require('../utils/visibility')
|
||||
|
||||
const log = require('../core').logger('public-atlas')
|
||||
|
||||
const FEATURE = 'atlas'
|
||||
|
||||
// Query params arrive as strings; express-validator has already bounded them.
|
||||
const int = (value, fallback) => {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
const str = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
// GET /public/atlas/creatures?q=&facet=&limit=&offset=
|
||||
async function getCreatures(req, res) {
|
||||
try {
|
||||
const page = await atlas.searchCreatures({
|
||||
q: str(req.query.q),
|
||||
facet: str(req.query.facet),
|
||||
limit: int(req.query.limit, 50),
|
||||
offset: int(req.query.offset, 0),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, page, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getCreatures', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/creatures/:slug — one creature, with the places it spawns.
|
||||
//
|
||||
// 404 means "no such creature in this atlas", which also covers "the atlas has
|
||||
// never been imported" — an empty atlas has no slugs, and there is nothing more
|
||||
// specific to say to an anonymous caller.
|
||||
async function getCreature(req, res) {
|
||||
try {
|
||||
const creature = await atlas.getCreature(req.params.slug, {
|
||||
facet: str(req.query.facet),
|
||||
points: int(req.query.points, 200),
|
||||
})
|
||||
if (!creature) return res.status(404).json({ message: 'Not Found' })
|
||||
return res.json(await visibility.project(FEATURE, creature, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getCreature', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/regions?facet=&q=
|
||||
async function getRegions(req, res) {
|
||||
try {
|
||||
const regions = await atlas.listRegions({
|
||||
facet: str(req.query.facet),
|
||||
q: str(req.query.q),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, regions, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getRegions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/landmarks?facet=&q=
|
||||
async function getLandmarks(req, res) {
|
||||
try {
|
||||
const landmarks = await atlas.listLandmarks({
|
||||
facet: str(req.query.facet),
|
||||
q: str(req.query.q),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, landmarks, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getLandmarks', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/champions?facet= — the CONFIGURED altar roster.
|
||||
async function getChampions(req, res) {
|
||||
try {
|
||||
const champions = await atlas.listChampions({ facet: str(req.query.facet) })
|
||||
return res.json(await visibility.project(FEATURE, champions, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getChampions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/meta — what is loaded: facets, counts, when it was imported.
|
||||
//
|
||||
// Public-safe by construction: the model omits the ServUO path, the per-file
|
||||
// hashes and the pending-refresh state, all of which describe the operator's
|
||||
// filesystem rather than the game world. The admin status route carries those.
|
||||
async function getMeta(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project(FEATURE, await atlas.publicMeta(), req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getMeta', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCreatures,
|
||||
getCreature,
|
||||
getRegions,
|
||||
getLandmarks,
|
||||
getChampions,
|
||||
getMeta,
|
||||
}
|
||||
132
modules/uo/server/router/atlas.router.js
Normal file
132
modules/uo/server/router/atlas.router.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// Public · Atlas — the spawn atlas / bestiary. Static shard CONTENT derived from
|
||||
// the shard's own ServUO tree, not live shard state.
|
||||
//
|
||||
// Mounted at /api/v1/public/atlas by public/index.js. Two deliberate differences
|
||||
// from the /public/shard routes next door (docs/link/v3.md §6):
|
||||
//
|
||||
// • **Not under /shard.** Nothing here round-trips the sidecar, and the pages
|
||||
// stay fully populated while the shard is down. Mounting it under /shard
|
||||
// would imply a dependency it does not have.
|
||||
// • **siteMode-gated, like /posts and /wiki.** The shard routes are exempt
|
||||
// because shard status is wanted *during* maintenance; a bestiary is site
|
||||
// content and follows site content's rules.
|
||||
//
|
||||
// Every route also carries `requireFeature('atlas')` — 404 when an admin has
|
||||
// disabled the feature, 403 when the caller sits below its configured audience.
|
||||
// The default audience is `anonymous`, so these gates are inert until an admin
|
||||
// changes something.
|
||||
|
||||
// express and express-validator come from core, never from a require here: this
|
||||
// file lives outside server/, so Node's resolver would not find them, and a
|
||||
// second express in the process would be a second Router prototype
|
||||
// (docs/website/MODULE_API.md §2.3).
|
||||
const core = require('../core')
|
||||
const atlas = require('./atlas.controller')
|
||||
const { requireFeature } = require('../utils/visibility')
|
||||
|
||||
const { express, validator, middleware } = core
|
||||
const { param, query } = validator
|
||||
const { siteMode, validate } = middleware
|
||||
|
||||
const atlasRouter = express.Router()
|
||||
|
||||
// Facet names come from the shard's own files and are never validated against a
|
||||
// list — nothing in the codebase names a facet (§6.1 R2). Only the length is
|
||||
// bounded, and the query matches exactly, so an unknown name returns an empty
|
||||
// result rather than an error.
|
||||
const facetParam = query('facet').optional({ values: 'falsy' }).isString().isLength({ max: 40 })
|
||||
|
||||
atlasRouter.get(
|
||||
'/creatures',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Search the bestiary (paginated)'
|
||||
// #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\'s share on it. Static content parsed from the shard\'s ServUO tree — unaffected by the shard being offline.'
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the creature name (max 60 chars).' }
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard\'s own files; an unknown one returns an empty page.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
facetParam,
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getCreatures,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/creatures/:slug',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'One creature: where it spawns, and what spawns with it'
|
||||
// #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard\'s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' }
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' }
|
||||
// #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' }
|
||||
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('slug').isString().isLength({ min: 1, max: 120 }),
|
||||
facetParam,
|
||||
query('points').optional().isInt({ min: 1, max: 1000 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getCreature,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/regions',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Named regions and their rectangles'
|
||||
// #swagger.description = 'Flattened out of the shard\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' }
|
||||
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */
|
||||
facetParam,
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getRegions,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/landmarks',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Points of interest (dungeon levels, town markers)'
|
||||
// #swagger.description = 'From the shard\'s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' }
|
||||
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */
|
||||
facetParam,
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getLandmarks,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/champions',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Configured champion altars (the roster, not the live board)'
|
||||
// #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */
|
||||
facetParam,
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getChampions,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/meta',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported'
|
||||
// #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on <date>" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.'
|
||||
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */
|
||||
siteMode,
|
||||
atlas.getMeta,
|
||||
)
|
||||
|
||||
module.exports = atlasRouter
|
||||
60
modules/uo/server/test/_ctx.js
Normal file
60
modules/uo/server/test/_ctx.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// ── Test harness: a fake ctx ───────────────────────────────────────────────
|
||||
//
|
||||
// A module's tests cannot require core — that is the whole zero-internal-imports
|
||||
// rule (docs/website/MODULE_API.md §5.1), and it applies to test files too. So
|
||||
// instead of stubbing core's modules the way core's own tests do, a module test
|
||||
// hands `core.init()` a ctx it fabricated.
|
||||
//
|
||||
// That turns out to be the nicer story: the seam that exists so a module can be
|
||||
// swapped onto a different core is the same seam that lets its tests run with no
|
||||
// database, no express app and no settings table. Core's tests reach the same
|
||||
// place by pointing the mariadb pool at a dead port; a module does not have to.
|
||||
|
||||
const core = require('../core')
|
||||
|
||||
/**
|
||||
* Build and install a fake ctx. Every member is a stub the test can reassign.
|
||||
* @param {object} [over] members to override, deep-merged one level
|
||||
*/
|
||||
function installFakeCtx(over = {}) {
|
||||
const settings = new Map()
|
||||
|
||||
const ctx = {
|
||||
moduleId: 'uo',
|
||||
paths: { moduleRoot: require('path').join(__dirname, '..', '..') },
|
||||
// Null, not the real packages: a module cannot resolve express from outside
|
||||
// server/ (that is why ctx carries them at all), and these tests construct no
|
||||
// router. A test that needs one passes the real ones in `over`.
|
||||
express: null,
|
||||
validator: null,
|
||||
db: {
|
||||
// Every test that needs a query result reassigns this.
|
||||
query: async () => [],
|
||||
pool: { getConnection: async () => { throw new Error('no pool in tests') } },
|
||||
},
|
||||
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
settings: {
|
||||
get: async (key) => (settings.has(key) ? settings.get(key) : null),
|
||||
set: async (key, value) => { settings.set(key, value) },
|
||||
getInstanceName: async () => 'Test Shard',
|
||||
},
|
||||
auth: { getUserFromRequest: () => null },
|
||||
push: { publish: async () => {} },
|
||||
secretBox: { encrypt: (s) => s, decrypt: (s) => s },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
siteMode: (req, res, next) => next(),
|
||||
validate: (req, res, next) => next(),
|
||||
noindex: (req, res, next) => next(),
|
||||
},
|
||||
uploads: {},
|
||||
posts: {},
|
||||
...over,
|
||||
}
|
||||
|
||||
core.init(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
module.exports = { installFakeCtx }
|
||||
@@ -15,7 +15,7 @@ const {
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
decodeEntities,
|
||||
} = require('../src/utils/spawnAtlasParse')
|
||||
} = require('../utils/spawnAtlasParse')
|
||||
|
||||
// These parsers are pure and fs-free precisely so this suite can run in CI,
|
||||
// where there is no ServUO tree. Every fixture below is a literal excerpt of a
|
||||
@@ -139,8 +139,10 @@ test('parsePoints: reads the kept fields and drops the rest', () => {
|
||||
assert.equal(covetous.width, 10)
|
||||
assert.equal(covetous.range, 5)
|
||||
assert.equal(covetous.maxCount, 3)
|
||||
assert.equal(covetous.minDelay, 5)
|
||||
assert.equal(covetous.maxDelay, 10)
|
||||
// Delays are normalised to seconds; this record carries no DelayInSec, which
|
||||
// means minutes.
|
||||
assert.equal(covetous.minDelay, 300)
|
||||
assert.equal(covetous.maxDelay, 600)
|
||||
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
||||
// Dropped fields must not survive into the artifact — this is what keeps it
|
||||
// under 1 MB.
|
||||
@@ -561,3 +563,39 @@ test('slugify: produces URL-safe keys', () => {
|
||||
assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy')
|
||||
assert.equal(slugify(' Orc '), 'orc')
|
||||
})
|
||||
|
||||
// ── Respawn delays: the unit is per record ──────────────────────────────────
|
||||
// XmlSpawner writes minutes by default and switches to seconds only when a
|
||||
// delay does not divide into whole minutes, flagged by DelayInSec. `5` therefore
|
||||
// means five MINUTES on one spawner and five SECONDS on the next, and a reader
|
||||
// assuming either unit is wrong about the other — silently, since both are
|
||||
// plausible respawn times.
|
||||
const DELAY_XML = `<Spawns>
|
||||
<Points>
|
||||
<Name>Minutes</Name>
|
||||
<Map>Sosaria</Map>
|
||||
<X>1</X><Y>1</Y>
|
||||
<MinDelay>5</MinDelay>
|
||||
<MaxDelay>10</MaxDelay>
|
||||
<IsRunning>True</IsRunning>
|
||||
<Objects2>Orc:MX=1</Objects2>
|
||||
</Points>
|
||||
<Points>
|
||||
<Name>Seconds</Name>
|
||||
<Map>Sosaria</Map>
|
||||
<X>2</X><Y>2</Y>
|
||||
<DelayInSec>True</DelayInSec>
|
||||
<MinDelay>5</MinDelay>
|
||||
<MaxDelay>10</MaxDelay>
|
||||
<IsRunning>True</IsRunning>
|
||||
<Objects2>Orc:MX=1</Objects2>
|
||||
</Points>
|
||||
</Spawns>`
|
||||
|
||||
test('parsePoints: DelayInSec decides the unit, and both come out in seconds', () => {
|
||||
const [minutes, seconds] = parsePoints(DELAY_XML)
|
||||
assert.equal(minutes.minDelay, 300)
|
||||
assert.equal(minutes.maxDelay, 600)
|
||||
assert.equal(seconds.minDelay, 5)
|
||||
assert.equal(seconds.maxDelay, 10)
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
// No dead-port pool trick here: a module test fabricates its ctx instead, so
|
||||
// there is no database to point anywhere (see test/_ctx.js).
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
@@ -8,6 +7,10 @@ const path = require('path')
|
||||
const { test, after, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { installFakeCtx } = require('./_ctx')
|
||||
|
||||
installFakeCtx()
|
||||
|
||||
const {
|
||||
AtlasSourceError,
|
||||
aggregateCreatures,
|
||||
@@ -15,13 +18,14 @@ const {
|
||||
sameSources,
|
||||
hashSources,
|
||||
buildAtlas,
|
||||
} = require('../src/utils/spawnAtlasSource')
|
||||
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
|
||||
const atlasDb = require('../src/model/shardAtlas/shardAtlas.db')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
PARSER_VERSION,
|
||||
} = require('../utils/spawnAtlasSource')
|
||||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
|
||||
// The model captured this object at require time, so reassigning a method on it
|
||||
// is how a test stubs core — the module equivalent of core's own tests
|
||||
// monkey-patching a model.
|
||||
const { settings } = require('../core')
|
||||
|
||||
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
|
||||
//
|
||||
@@ -273,15 +277,35 @@ test('refresh: a fresh database imports', async () => {
|
||||
|
||||
test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
metaRow = { source: buildAtlas(root).meta.source }
|
||||
metaRow = buildAtlas(root).meta
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'unchanged')
|
||||
assert.equal(applied, null)
|
||||
})
|
||||
|
||||
// The hash gate alone would strand an install whose maps never change on
|
||||
// whatever an older build derived: a corrected parse would ship and never reach
|
||||
// the data, because the only thing compared is the tree.
|
||||
test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 }
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.ok(applied)
|
||||
})
|
||||
|
||||
test('refresh: an atlas imported before parser versions existed is stale', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const meta = buildAtlas(root).meta
|
||||
delete meta.parserVersion
|
||||
metaRow = meta
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
})
|
||||
|
||||
test('refresh: --force reimports an unchanged tree', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
metaRow = { source: buildAtlas(root).meta.source }
|
||||
metaRow = buildAtlas(root).meta
|
||||
const result = await shardAtlas.refresh({ path: root, force: true })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.ok(applied)
|
||||
@@ -360,6 +360,20 @@ function tagValue(block, name) {
|
||||
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
||||
* carry TerMur/Trammel points, so there are 13 files but only 6 facets.
|
||||
*/
|
||||
/**
|
||||
* A spawner's respawn window, in seconds.
|
||||
*
|
||||
* `DelayInSec` decides the unit of `MinDelay`/`MaxDelay`; absent (older files)
|
||||
* it is false, which is minutes — the same default XmlSpawner assumes.
|
||||
*/
|
||||
function delaySeconds(block) {
|
||||
const scale = toBool(tagValue(block, 'DelayInSec')) ? 1 : 60
|
||||
return {
|
||||
minDelay: toInt(tagValue(block, 'MinDelay')) * scale,
|
||||
maxDelay: toInt(tagValue(block, 'MaxDelay')) * scale,
|
||||
}
|
||||
}
|
||||
|
||||
function parsePoints(source) {
|
||||
const text = String(source)
|
||||
const points = []
|
||||
@@ -382,8 +396,16 @@ function parsePoints(source) {
|
||||
height: toInt(tagValue(block, 'Height')),
|
||||
range: toInt(tagValue(block, 'Range')),
|
||||
maxCount: toInt(tagValue(block, 'MaxCount')),
|
||||
minDelay: toInt(tagValue(block, 'MinDelay')),
|
||||
maxDelay: toInt(tagValue(block, 'MaxDelay')),
|
||||
// Normalised to SECONDS here, because the unit is per-record. XmlSpawner
|
||||
// writes minutes by default and switches to seconds only when a spawner's
|
||||
// delay does not divide into whole minutes, flagging that with
|
||||
// `DelayInSec` (XmlSpawner2.cs:7462-7480, read back at :6345-6358). Taken
|
||||
// literally the two are indistinguishable — a `5` means five minutes on
|
||||
// one spawner and five seconds on the next — so a consumer that assumed
|
||||
// either unit would be wrong about the other. Stock ServUO 57.4 has ~30
|
||||
// second-flagged spawners, few enough to look like noise and quietly
|
||||
// mislabel.
|
||||
...delaySeconds(block),
|
||||
// Time-of-day gating: TODMode 0 means "always", in which case the start
|
||||
// and end values are meaningless and the site must not render them.
|
||||
todStart: toInt(tagValue(block, 'TODStart')),
|
||||
@@ -128,6 +128,21 @@ function hashSources(root) {
|
||||
return hashes
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped whenever the parser produces DIFFERENT data from IDENTICAL source
|
||||
* files — a fixed misreading, a new field, a changed unit.
|
||||
*
|
||||
* Without it the hash gate is a trap: an install whose tree has not changed
|
||||
* would keep serving what an older parser derived, indefinitely, because the
|
||||
* only thing the boot path compares is the tree. The version is stored beside
|
||||
* the source hashes and a mismatch counts as drift, so a deploy that corrects
|
||||
* the parse actually reaches the data.
|
||||
*
|
||||
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
||||
* seconds in the source, decided by `DelayInSec`).
|
||||
*/
|
||||
const PARSER_VERSION = 2
|
||||
|
||||
/** True when two source fingerprints describe the same tree. */
|
||||
function sameSources(a, b) {
|
||||
if (!a || !b) return false
|
||||
@@ -286,6 +301,7 @@ function buildAtlas(root, options = {}) {
|
||||
return {
|
||||
meta: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
parserVersion: PARSER_VERSION,
|
||||
landmarkRadius: options.landmarkRadius ?? undefined,
|
||||
counts: {
|
||||
facets: facets.length,
|
||||
@@ -310,6 +326,7 @@ function buildAtlas(root, options = {}) {
|
||||
|
||||
module.exports = {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
readSources,
|
||||
hashSources,
|
||||
sameSources,
|
||||
435
modules/uo/server/utils/visibility.js
Normal file
435
modules/uo/server/utils/visibility.js
Normal file
@@ -0,0 +1,435 @@
|
||||
// ── Shard feature visibility ───────────────────────────────────────────────
|
||||
//
|
||||
// Admin-configurable, per-feature and per-field audience control over every
|
||||
// shard-derived surface on the site. Replaces the hardcoded split that used to
|
||||
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
|
||||
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
|
||||
//
|
||||
// Design rules (docs/link/v3.md §3):
|
||||
//
|
||||
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
|
||||
// is a dumb forwarder: it accepts frames, stores them, forwards them
|
||||
// verbatim, and serves store-backed reads. It defines no audiences.
|
||||
// • Every default reproduces the behavior that shipped before this module, so
|
||||
// installing it changes nothing until an admin edits the config.
|
||||
// • Two rules an admin CANNOT override:
|
||||
// 1. `acct` / `webId` are admin-only, always. They are not in-game
|
||||
// visible (unlike a character name) and are not configurable fields.
|
||||
// 2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
|
||||
// Fail closed — this is what keeps the kind map a security boundary
|
||||
// rather than a convenience filter.
|
||||
//
|
||||
// The audience ladder is ordered; each rung implies the ones below it.
|
||||
|
||||
const db = require('../model/shardVisibility/shardVisibility.model')
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const { auth } = require('../core')
|
||||
const log = require('../core').logger('visibility')
|
||||
|
||||
// ── The ladder ─────────────────────────────────────────────────────────────
|
||||
|
||||
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
|
||||
const RANK = new Map(LADDER.map((level, i) => [level, i]))
|
||||
|
||||
const isLevel = (level) => RANK.has(level)
|
||||
|
||||
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
|
||||
// point: an unrecognised value must always lose. A single shared fallback cannot
|
||||
// do that — whichever direction it picks, it fails open on one side. So:
|
||||
//
|
||||
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
|
||||
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
|
||||
//
|
||||
// With one `rank()` defaulting to admin, a viewer level that fell through (a
|
||||
// typo, a future rung this build doesn't know, a value from a caller that
|
||||
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
|
||||
const viewerRank = (level) => RANK.get(level) ?? 0
|
||||
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
|
||||
|
||||
// True when a viewer at `viewer` satisfies a requirement of `required`.
|
||||
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
|
||||
|
||||
// Exported for tests/diagnostics; `meets` is what callers should use.
|
||||
const rank = viewerRank
|
||||
|
||||
// ── Features ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
|
||||
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
|
||||
// not listed here is visible whenever the feature itself is.
|
||||
//
|
||||
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
|
||||
|
||||
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
|
||||
|
||||
// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
|
||||
// frames nest actors (`leader.acct`), but several read models flatten them
|
||||
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
|
||||
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
|
||||
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
|
||||
// anonymous callers while the same account name was correctly stripped from the
|
||||
// live `house.decay` frame.
|
||||
//
|
||||
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
|
||||
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
|
||||
// fail closed for shapes nobody has written yet.
|
||||
const LOCKED_SUFFIXES = ['acct', 'webid']
|
||||
const isLockedField = (key) => {
|
||||
const k = String(key).toLowerCase()
|
||||
return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
|
||||
}
|
||||
|
||||
const FEATURES = {
|
||||
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||
status: { audience: 'anonymous', fields: {} },
|
||||
activity: { audience: 'anonymous', fields: {} },
|
||||
champs: { audience: 'anonymous', fields: {} },
|
||||
guilds: { audience: 'anonymous', fields: {} },
|
||||
governors: { audience: 'anonymous', fields: {} },
|
||||
// The public Houses page showed IDOC location only; owner/price were staff.
|
||||
// `owner` is the actor object on the house.decay/house.update frames;
|
||||
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
|
||||
// REST read models. Both are listed so one rule covers the wire and the read
|
||||
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
|
||||
houses: {
|
||||
audience: 'anonymous',
|
||||
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
|
||||
},
|
||||
// /public/shard/online listed linked staff to everyone but gated location to
|
||||
// admin+moderator — which is exactly the `staff` rung.
|
||||
presence: { audience: 'anonymous', fields: { location: 'staff' } },
|
||||
|
||||
// ── New in v3. ──
|
||||
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
|
||||
atlas: { audience: 'anonymous', fields: {} },
|
||||
// `name` is the ranked character's name inside points.board's `top` entries, and
|
||||
// it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
|
||||
// ("characterName"). projectValue matches on the literal JSON key, so a rule
|
||||
// named for the field's meaning rather than its key silently does nothing — the
|
||||
// same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
|
||||
// leaderboards payload `name` can only be a character name: the board's own
|
||||
// display name arrives as `nameString`/`nameNumber`.
|
||||
leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
|
||||
// Shop name, owner character name and vendor location are already globally
|
||||
// visible in-game via the stock Vendor Search gump, so publishing them is not
|
||||
// a new disclosure — but they stay configurable so an admin can tighten them.
|
||||
//
|
||||
// `ownerName` and `location` were pre-wired here by Part A, before the frame
|
||||
// existed; both were re-checked against the real `vendor.listing` and both are
|
||||
// genuine keys on it (unlike leaderboards' `characterName`, which was inert).
|
||||
// `location` is a NESTED object on the wire and on the read model precisely so
|
||||
// that one rule hides map, coordinates, region and house together — five flat
|
||||
// keys would be five rules that drift apart.
|
||||
//
|
||||
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
|
||||
// lists both: an admin who hides the owner's name and is left with a serial
|
||||
// that every other board resolves back to that name has not hidden anything.
|
||||
market: {
|
||||
audience: 'anonymous',
|
||||
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
|
||||
},
|
||||
}
|
||||
|
||||
const FEATURE_NAMES = Object.keys(FEATURES)
|
||||
const isFeature = (name) => Object.hasOwn(FEATURES, name)
|
||||
|
||||
// ── Kind → feature ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every event kind that may ever leave the admin channel must appear here.
|
||||
// Anything else is admin-only by omission (rule 2). This map is seeded from
|
||||
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
|
||||
// same kinds it did — now attributed to a feature that an admin can re-gate.
|
||||
|
||||
const KIND_FEATURE = new Map(
|
||||
Object.entries({
|
||||
// status / lifecycle
|
||||
'server.hello': 'status',
|
||||
'server.shutdown': 'status',
|
||||
'server.crashed': 'status',
|
||||
'economy.supply': 'status',
|
||||
// activity feed
|
||||
'player.death': 'activity',
|
||||
'player.murdered': 'activity',
|
||||
'mob.killed': 'activity',
|
||||
'quest.complete': 'activity',
|
||||
'skill.gain': 'activity',
|
||||
'fame.change': 'activity',
|
||||
'karma.change': 'activity',
|
||||
'mob.login': 'activity',
|
||||
'mob.logout': 'activity',
|
||||
// boards
|
||||
'champ.update': 'champs',
|
||||
'champ.remove': 'champs',
|
||||
'guild.update': 'guilds',
|
||||
'guild.remove': 'guilds',
|
||||
'guild.join': 'guilds',
|
||||
'city.update': 'governors',
|
||||
'presence.online': 'presence',
|
||||
'region.enter': 'presence',
|
||||
// house.decay is the IDOC signal the public Houses page renders. The full
|
||||
// registry (house.update / house.remove — owner, price, co-owners) stays
|
||||
// off the map deliberately, so it remains admin-only exactly as before.
|
||||
'house.decay': 'houses',
|
||||
// v3
|
||||
'world.ruleset': 'ruleset',
|
||||
'points.board': 'leaderboards',
|
||||
// vendor.listing IS mapped, but the market feature ships with its stream
|
||||
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
|
||||
// inventories would be the site's biggest bandwidth consumer and no page
|
||||
// needs it live. An admin can turn it on.
|
||||
'vendor.listing': 'market',
|
||||
'vendor.listing.remove': 'market',
|
||||
}),
|
||||
)
|
||||
|
||||
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
|
||||
// are unaffected; only the live stream is suppressed.
|
||||
const DEFAULT_STREAM_OFF = new Set(['market'])
|
||||
|
||||
// Back-compat: the set of kinds that reach an anonymous viewer under the default
|
||||
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
|
||||
// this. Derived from the map above rather than hand-maintained, so the two can
|
||||
// no longer drift.
|
||||
const PUBLIC_KINDS = new Set(
|
||||
[...KIND_FEATURE.entries()]
|
||||
.filter(([, feature]) => {
|
||||
if (DEFAULT_STREAM_OFF.has(feature)) return false
|
||||
return FEATURES[feature].audience === 'anonymous'
|
||||
})
|
||||
.map(([kind]) => kind),
|
||||
)
|
||||
|
||||
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
|
||||
|
||||
const CONFIG_TTL_MS = 5000
|
||||
let cache = null
|
||||
let cachedAt = 0
|
||||
|
||||
// Merge a stored row over its compiled default. Unknown feature names in the DB
|
||||
// are ignored (a stale row from a removed feature must not resurrect it), and an
|
||||
// invalid rung falls back to the default rather than failing open.
|
||||
function applyRow(name, row) {
|
||||
const base = FEATURES[name]
|
||||
const audience = isLevel(row?.audience) ? row.audience : base.audience
|
||||
const fields = { ...base.fields }
|
||||
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
|
||||
if (isLockedField(field)) continue // rule 1: not configurable
|
||||
if (isLevel(level)) fields[field] = level
|
||||
}
|
||||
return {
|
||||
enabled: row ? !!row.enabled : true,
|
||||
audience,
|
||||
fields,
|
||||
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
|
||||
}
|
||||
}
|
||||
|
||||
function compileDefaults() {
|
||||
const out = {}
|
||||
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
|
||||
return out
|
||||
}
|
||||
|
||||
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
|
||||
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
|
||||
// "what the site did before" rather than to "everything is public".
|
||||
async function getConfig() {
|
||||
const now = Date.now()
|
||||
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
|
||||
try {
|
||||
const rows = await db.listAll()
|
||||
const byName = new Map(rows.map((r) => [r.feature, r]))
|
||||
const out = {}
|
||||
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
|
||||
cache = out
|
||||
cachedAt = now
|
||||
} catch (err) {
|
||||
log.error('getConfig; falling back to defaults', err)
|
||||
cache = cache || compileDefaults()
|
||||
cachedAt = now
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
const invalidate = () => {
|
||||
cache = null
|
||||
cachedAt = 0
|
||||
}
|
||||
|
||||
// ── Viewer level ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// anonymous no session
|
||||
// logged_in authenticated, no linked game account
|
||||
// player authenticated with a linked game account
|
||||
// staff admin | moderator — the same set as the existing `modAccess` gate.
|
||||
// `editor` is a CONTENT role with no shard privilege today, so it
|
||||
// resolves by link status like any other member; mapping it to staff
|
||||
// here would silently widen what editors can see.
|
||||
// admin admin
|
||||
//
|
||||
// Staff always satisfy the `player` rung (rank order guarantees it) even without
|
||||
// a linked account, matching the existing rule that /player/* is role-agnostic
|
||||
// self-service.
|
||||
|
||||
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
|
||||
// (or newly relinked) account must not keep the old answer for long. Anonymous,
|
||||
// staff and admin callers short-circuit before this runs, so the lookup only
|
||||
// costs a query on the logged-in-member path.
|
||||
const LINK_TTL_MS = CONFIG_TTL_MS
|
||||
const linkCache = new Map() // userId → { hasLink, at }
|
||||
|
||||
async function hasLinkedAccount(userId) {
|
||||
const hit = linkCache.get(userId)
|
||||
const now = Date.now()
|
||||
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
|
||||
let hasLink = false
|
||||
try {
|
||||
const links = await shardLinks.listForUser(userId)
|
||||
hasLink = Array.isArray(links) && links.length > 0
|
||||
} catch (err) {
|
||||
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
|
||||
}
|
||||
linkCache.set(userId, { hasLink, at: now })
|
||||
return hasLink
|
||||
}
|
||||
|
||||
// Drop a user's cached link status (called when a link is created or removed so
|
||||
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
|
||||
const forgetUser = (userId) => linkCache.delete(userId)
|
||||
|
||||
async function viewerLevel(req) {
|
||||
const viewer = req.user || auth.getUserFromRequest(req)
|
||||
if (!viewer) return 'anonymous'
|
||||
if (viewer.role === 'admin') return 'admin'
|
||||
if (viewer.role === 'moderator') return 'staff'
|
||||
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
|
||||
}
|
||||
|
||||
// ── Enforcement ────────────────────────────────────────────────────────────
|
||||
|
||||
// Route gate. 404 when the feature is disabled (do not leak that it exists);
|
||||
// 403 when it exists but the viewer sits below its audience. Stashes the
|
||||
// resolved level on the request so controllers can project without re-resolving.
|
||||
function requireFeature(name) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
const feature = config[name]
|
||||
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
|
||||
const level = await viewerLevel(req)
|
||||
req.viewerLevel = level
|
||||
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
|
||||
return next()
|
||||
} catch (err) {
|
||||
log.error(`requireFeature(${name})`, err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip the fields a viewer at `level` may not see. Applies the locked rules
|
||||
// first (so acct/webId can never survive below admin), then the feature's
|
||||
// configured field rules. Recurses into arrays and nested objects because the
|
||||
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
|
||||
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
|
||||
// instance is a value, not a bag of fields: rebuilding one key-by-key would
|
||||
// return `{}` (a Date has no enumerable own properties), which is how the DB-
|
||||
// backed read models — whose rows carry real Date columns — differ from the
|
||||
// pure-JSON wire frames the projection was first written against.
|
||||
const isPlainObject = (v) => {
|
||||
if (v === null || typeof v !== 'object') return false
|
||||
const proto = Object.getPrototypeOf(v)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
function projectValue(value, rules, level) {
|
||||
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
|
||||
if (!isPlainObject(value)) return value
|
||||
const out = {}
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
// Locked fields are checked by meaning first, so no configured rule (and no
|
||||
// flattened spelling) can widen them past `admin`.
|
||||
const required = isLockedField(key) ? 'admin' : rules[key]
|
||||
if (required && !meets(level, required)) continue
|
||||
out[key] = projectValue(v, rules, level)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Project a payload for one feature. `level` defaults to admin-equivalent only
|
||||
// when explicitly passed; callers should always pass a resolved level.
|
||||
function projectFeature(name, payload, level, config) {
|
||||
const feature = config?.[name]
|
||||
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
|
||||
return projectValue(payload, rules, level)
|
||||
}
|
||||
|
||||
// Convenience for controllers: resolve config once, project, return.
|
||||
async function project(name, payload, req) {
|
||||
const config = await getConfig()
|
||||
const level = req.viewerLevel || (await viewerLevel(req))
|
||||
return projectFeature(name, payload, level, config)
|
||||
}
|
||||
|
||||
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
|
||||
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
|
||||
function kindVisibleTo(kind, level, config) {
|
||||
if (level === 'admin') return true
|
||||
const name = KIND_FEATURE.get(kind)
|
||||
if (!name) return false // rule 2: unmapped ⇒ admin-only
|
||||
const feature = config?.[name]
|
||||
if (!feature || !feature.enabled || !feature.stream) return false
|
||||
return meets(level, feature.audience)
|
||||
}
|
||||
|
||||
// The event kinds a viewer at `level` may read under the CURRENT config. This is
|
||||
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
|
||||
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
|
||||
// this kind, given what the admin has configured?".
|
||||
//
|
||||
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
|
||||
// feature whose live firehose is off (market) is still readable from the stored
|
||||
// history. Unmapped kinds are absent by construction (rule 2).
|
||||
function visibleKinds(level, config) {
|
||||
return [...KIND_FEATURE.entries()]
|
||||
.filter(([, name]) => {
|
||||
const feature = config?.[name]
|
||||
return !!feature && feature.enabled && meets(level, feature.audience)
|
||||
})
|
||||
.map(([kind]) => kind)
|
||||
}
|
||||
|
||||
// The features a viewer at `level` can actually see — drives SPA nav so it never
|
||||
// renders a link that would 403.
|
||||
function visibleFeatures(level, config) {
|
||||
return FEATURE_NAMES.filter((name) => {
|
||||
const feature = config[name]
|
||||
return feature.enabled && meets(level, feature.audience)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LADDER,
|
||||
FEATURES,
|
||||
FEATURE_NAMES,
|
||||
LOCKED_FIELDS,
|
||||
KIND_FEATURE,
|
||||
PUBLIC_KINDS,
|
||||
DEFAULT_STREAM_OFF,
|
||||
isLevel,
|
||||
isFeature,
|
||||
isLockedField,
|
||||
rank,
|
||||
meets,
|
||||
getConfig,
|
||||
invalidate,
|
||||
compileDefaults,
|
||||
viewerLevel,
|
||||
forgetUser,
|
||||
requireFeature,
|
||||
projectFeature,
|
||||
project,
|
||||
kindVisibleTo,
|
||||
visibleKinds,
|
||||
visibleFeatures,
|
||||
}
|
||||
@@ -359,7 +359,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||
base_url VARCHAR(255) NULL,
|
||||
ws_url VARCHAR(255) NULL,
|
||||
auth_token_enc TEXT NULL,
|
||||
protocol INT NOT NULL DEFAULT 1,
|
||||
protocol INT NOT NULL DEFAULT 3,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
@@ -615,6 +615,101 @@ CREATE TABLE IF NOT EXISTS shard_ruleset (
|
||||
CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point
|
||||
-- system, keyed by the shard's own PointsType name. The shard publishes ~25 of
|
||||
-- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing
|
||||
-- players accumulate over months.
|
||||
--
|
||||
-- The top-N list stays inside `payload` rather than being normalized into a
|
||||
-- shard_points_entries table. It is a fixed-size list (10 by default) that is only
|
||||
-- ever read whole, exactly like shard_governors.candidates — normalizing it would
|
||||
-- buy nothing until something needs a per-character reverse lookup, and a
|
||||
-- character's own standings already ride inside char.profile instead.
|
||||
--
|
||||
-- No delete path: the shard's set of systems is fixed at startup, so there is no
|
||||
-- points.remove to mirror.
|
||||
CREATE TABLE IF NOT EXISTS shard_points_boards (
|
||||
system VARCHAR(48) PRIMARY KEY, -- PointsType name, e.g. QueensLoyalty
|
||||
name VARCHAR(128) NULL, -- resolved display name, if the shard sent a literal
|
||||
name_cliloc INT NULL, -- cliloc id when the name is a TextDefinition number
|
||||
max_points BIGINT NULL,
|
||||
players INT NULL, -- players actually holding points in this system
|
||||
show_on_gump TINYINT(1) NOT NULL DEFAULT 1, -- the shard's own "is this player-facing?" flag
|
||||
payload JSON NOT NULL, -- the whole points.board frame, incl. `top`
|
||||
t BIGINT NULL, -- frame time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player
|
||||
-- vendor and one per priced listing, so the site can offer the search the in-game
|
||||
-- Vendor Search gump offers — from outside the game.
|
||||
--
|
||||
-- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per
|
||||
-- vendor, so ingest is delete-then-insert of that vendor's items inside one
|
||||
-- transaction (see shardMarket.db.js). No foreign key from items to vendors, in
|
||||
-- keeping with every other shard_* table: the ingest transaction is what keeps
|
||||
-- them consistent, and an FK would turn a malformed frame into a failed write
|
||||
-- rather than a dropped row.
|
||||
--
|
||||
-- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent,
|
||||
-- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs.
|
||||
CREATE TABLE IF NOT EXISTS shard_vendors (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- "0x40001234"
|
||||
shop_name VARCHAR(160) NULL,
|
||||
owner_serial VARCHAR(20) NULL,
|
||||
owner_name VARCHAR(64) NULL,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
region VARCHAR(80) NULL,
|
||||
house VARCHAR(160) NULL, -- the house SIGN's name, not the house type
|
||||
item_count INT NOT NULL DEFAULT 0, -- listings published in the frame
|
||||
item_total INT NOT NULL DEFAULT 0, -- listings the shop actually holds
|
||||
truncated TINYINT(1) NOT NULL DEFAULT 0, -- item_total > item_count
|
||||
t BIGINT NULL, -- frame time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_vendors_owner (owner_name),
|
||||
INDEX idx_shard_vendors_map (map),
|
||||
INDEX idx_shard_vendors_region (region),
|
||||
-- The market page's staleness banner is MIN(updated_at) over this column: the
|
||||
-- round-robin sweep means the oldest row is how far behind the index can be.
|
||||
INDEX idx_shard_vendors_updated (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One priced listing. Unlike the points board's top-N — a fixed-size list read
|
||||
-- whole — these are the searchable rows the whole feature exists for, so they are
|
||||
-- normalized rather than left inside a payload column, and there is no payload
|
||||
-- column on shard_vendors at all.
|
||||
--
|
||||
-- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's
|
||||
-- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page
|
||||
-- at query time would make the cliloc table a join on the hot path AND make
|
||||
-- search-by-name impossible. Resolving once on write buys the index. It is
|
||||
-- re-resolved in bulk after a cliloc import, because the diff sweep will not
|
||||
-- re-send an unchanged shop just because the site learned what its items are
|
||||
-- called.
|
||||
CREATE TABLE IF NOT EXISTS shard_vendor_items (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
vendor_serial VARCHAR(20) NOT NULL,
|
||||
serial VARCHAR(20) NOT NULL,
|
||||
item_id INT NOT NULL DEFAULT 0, -- ItemID (the art/graphic id)
|
||||
hue INT NOT NULL DEFAULT 0,
|
||||
amount INT NOT NULL DEFAULT 1,
|
||||
price BIGINT NOT NULL DEFAULT 0,
|
||||
name VARCHAR(160) NULL, -- the item's literal Name, null for most
|
||||
cliloc INT NULL, -- LabelNumber, resolved against shard_clilocs
|
||||
display_name VARCHAR(160) NULL, -- resolved at ingest; what search matches
|
||||
child TINYINT(1) NOT NULL DEFAULT 0, -- priced by an enclosing container, not itself
|
||||
INDEX idx_shard_vendor_items_vendor (vendor_serial),
|
||||
INDEX idx_shard_vendor_items_price (price),
|
||||
INDEX idx_shard_vendor_items_item (item_id),
|
||||
INDEX idx_shard_vendor_items_name (display_name),
|
||||
-- Search filters on name and sorts on price; the composite covers the common
|
||||
-- "cheapest matching X" without a filesort over the whole table.
|
||||
INDEX idx_shard_vendor_items_name_price (display_name, price)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
|
||||
-- per feature; an absent row means "use the compiled default", and the compiled
|
||||
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
|
||||
@@ -1040,154 +1135,37 @@ CREATE TABLE IF NOT EXISTS announce_jobs (
|
||||
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── 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.
|
||||
-- UO's localization table: cliloc id -> display string. Items carry a
|
||||
-- `LabelNumber` rather than a name, so without this the site can only render
|
||||
-- `id 1023721` where the game shows "quarter staff". The shard has always sent
|
||||
-- the id (char.profile's `cliloc`, and one per marketplace listing) — the number
|
||||
-- was never the missing piece, the table was.
|
||||
--
|
||||
-- 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)
|
||||
-- Sourced from a file the OPERATOR converts once from their own UO client and
|
||||
-- points the site at (docs/website/CLILOCS.md); nothing derived from the client
|
||||
-- is committed, the same rule the spawn atlas and the creature art map follow.
|
||||
-- A shard with no cliloc file configured simply renders item ids, which is what
|
||||
-- it did before this table existed.
|
||||
--
|
||||
-- `text` is TEXT, not VARCHAR: real tables top out around 12 KB for the long
|
||||
-- property descriptions, and truncating them silently would be worse than
|
||||
-- storing them. Item NAMES are all short — the index that matters for search is
|
||||
-- on the denormalized `shard_vendor_items.display_name`, not here.
|
||||
CREATE TABLE IF NOT EXISTS shard_clilocs (
|
||||
number INT NOT NULL PRIMARY KEY,
|
||||
flag SMALLINT NOT NULL DEFAULT 0,
|
||||
text TEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 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 (
|
||||
-- Singleton (id = 1) describing the cliloc table currently loaded: the source
|
||||
-- file, its sha256, the entry count and the parser version. The boot path
|
||||
-- compares the stored hash against the file on disk and skips the parse when
|
||||
-- they match, which is every restart that did not follow a client patch.
|
||||
CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
payload JSON NOT NULL,
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_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)
|
||||
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
@@ -1269,3 +1247,19 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
|
||||
-- trust token. A boolean only — the token is returned over that app→server call
|
||||
-- and never persisted here (only its sha256 lands in trusted_devices).
|
||||
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
-- Protocol 3.0 cutover: this build speaks wire protocol 3 (world.ruleset,
|
||||
-- points.board, vendor.listing), so the pinned version an existing install
|
||||
-- carries has to move with it — a 2 against a v3 sidecar 409s every REST call
|
||||
-- and closes the WS on ws.hello. MODIFY fixes the column default for installs
|
||||
-- created before the bump (idempotent, like the other MODIFYs here).
|
||||
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
|
||||
-- The row itself is admin-editable, and schema.sql runs on EVERY boot, so this
|
||||
-- must be one-shot: an operator who deliberately pins an older sidecar in
|
||||
-- Admin → Shard has to stay pinned. The marker row in `settings` is what makes
|
||||
-- it fire once — written after the UPDATE, and on a fresh install (no
|
||||
-- uo_link_config row yet) it is simply written with nothing to update.
|
||||
UPDATE uo_link_config SET protocol = 3
|
||||
WHERE id = 1 AND protocol < 3
|
||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
|
||||
|
||||
@@ -616,6 +616,25 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/settings/:key",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/settings/brand-asset/:slot",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"multerMiddleware"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account",
|
||||
@@ -636,6 +655,55 @@
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/atlas",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/approve",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/import",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/atlas/path",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/reject",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/audit",
|
||||
@@ -678,6 +746,37 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/clilocs",
|
||||
"handlers": 2,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/clilocs/import",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/clilocs/path",
|
||||
"handlers": 4,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth",
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/houses",
|
||||
@@ -1781,6 +1880,64 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/champions",
|
||||
"handlers": 5,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate",
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures",
|
||||
"handlers": 8,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate",
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures/:slug",
|
||||
"handlers": 7,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate",
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/landmarks",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate",
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/meta",
|
||||
"handlers": 3,
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/regions",
|
||||
"handlers": 6,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate",
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact",
|
||||
@@ -1889,12 +2046,48 @@
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market",
|
||||
"handlers": 13,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/meta",
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/vendors/:serial",
|
||||
"handlers": 7,
|
||||
"gates": [
|
||||
"middleware",
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/online",
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points",
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points/:system",
|
||||
"handlers": 2,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/presence",
|
||||
@@ -1962,6 +2155,24 @@
|
||||
"gates": [
|
||||
"siteMode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/nav",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/theme/options",
|
||||
"handlers": 1,
|
||||
"gates": [
|
||||
"noindex",
|
||||
"requireAuth"
|
||||
]
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
|
||||
@@ -249,6 +249,14 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/settings"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/settings/:key"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/settings/brand-asset/:slot"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account"
|
||||
@@ -257,6 +265,26 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/accounts"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/atlas"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/approve"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/import"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/atlas/path"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/reject"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/audit"
|
||||
@@ -273,6 +301,18 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/char/:serial"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/clilocs"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/clilocs/import"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/clilocs/path"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/houses"
|
||||
@@ -713,6 +753,30 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/vendors/:account"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/champions"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures/:slug"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/landmarks"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/meta"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/regions"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
@@ -773,10 +837,30 @@
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/idoc"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/meta"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/vendors/:serial"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/online"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points/:system"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/presence"
|
||||
@@ -816,6 +900,14 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/wiki/tags"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/nav"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/settings/theme/options"
|
||||
}
|
||||
],
|
||||
"internal": [
|
||||
|
||||
@@ -16,8 +16,10 @@ const brand = require('./config/brand')
|
||||
const csp = require('./config/csp')
|
||||
const { cspReportLimiter } = require('./middleware/rateLimit')
|
||||
const createLogger = require('./utils/logger')
|
||||
const htmlShell = require('./utils/htmlShell')
|
||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||
const botScore = require('./middleware/botScore')
|
||||
const modules = require('./modules/loader')
|
||||
|
||||
const httpLog = createLogger('http')
|
||||
const errLog = createLogger('error')
|
||||
@@ -96,31 +98,6 @@ const htmlEscape = (s) =>
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||
)
|
||||
|
||||
// Template the built index.html <head> with instance branding (title, meta
|
||||
// description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env,
|
||||
// so the prebuilt SPA image serves per-instance metadata without a rebuild.
|
||||
function renderIndexHtml(html) {
|
||||
const title = htmlEscape(brand.name)
|
||||
const desc = htmlEscape(brand.description)
|
||||
const tags = [
|
||||
`<meta property="og:title" content="${title}" />`,
|
||||
`<meta property="og:description" content="${desc}" />`,
|
||||
'<meta property="og:type" content="website" />',
|
||||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||||
brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
|
||||
'<meta name="twitter:card" content="summary_large_image" />',
|
||||
`<meta name="twitter:title" content="${title}" />`,
|
||||
`<meta name="twitter:description" content="${desc}" />`,
|
||||
brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
||||
}
|
||||
|
||||
// Uploaded images — always served, even during maintenance. Force nosniff so a
|
||||
// stored file is never interpreted as anything other than its declared type
|
||||
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
|
||||
@@ -132,6 +109,34 @@ app.use(
|
||||
}),
|
||||
)
|
||||
|
||||
// ── Installed modules ─────────────────────────────────────────────────
|
||||
// Discovered synchronously from the filesystem, with no database (see
|
||||
// modules/loader.js for why that is not negotiable). The scan has already run by
|
||||
// the time the routers below are required; calling it here makes the ordering
|
||||
// explicit rather than incidental.
|
||||
modules.scan()
|
||||
|
||||
// A module's prebuilt client chunk, served same-origin at /modules/<id>/*.
|
||||
// Same-origin is the whole point: CSP is `script-src 'self'` with no
|
||||
// 'unsafe-inline' (config/csp.js:49), so this loads with no nonce and no import
|
||||
// map — see docs/website/MODULE_API.md §3.1.
|
||||
//
|
||||
// **One static mount PER MODULE, rooted at that module's client dist** — never
|
||||
// one mount over the modules directory. A module holds its server source, its
|
||||
// module.json and its schema fragment alongside the client build; a single
|
||||
// `express.static(modulesDir)` would publish all of it. This serves exactly the
|
||||
// directory the module nominated as its browser bundle and nothing above it.
|
||||
for (const mod of modules.list()) {
|
||||
if (!mod.clientDir || !fs.existsSync(mod.clientDir)) continue
|
||||
app.use(
|
||||
`/modules/${mod.id}`,
|
||||
express.static(mod.clientDir, {
|
||||
index: false,
|
||||
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ── API docs (Swagger UI) ─────────────────────────────────────────────
|
||||
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
|
||||
// is generated from route annotations by `npm run swagger` (server/swagger/).
|
||||
@@ -204,9 +209,23 @@ if (fs.existsSync(BRAND_DIR)) {
|
||||
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
|
||||
// Serve a branded copy of the index.html shell for every SPA route; assets keep
|
||||
// their own cache-friendly static handler.
|
||||
const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
//
|
||||
// The shell is templated from BRAND_* env *and* the admin's brand_assets /
|
||||
// theme_visual rows, so it is rendered lazily and cached rather than built once
|
||||
// at boot: see utils/htmlShell.js for the caching, the invalidation and why a
|
||||
// DB fault still serves a page.
|
||||
htmlShell.init(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
||||
app.use(express.static(CLIENT_DIST, { index: false }))
|
||||
app.get('*', (req, res) => res.type('html').send(indexHtml))
|
||||
app.get('*', async (req, res, next) => {
|
||||
// htmlShell.get() swallows a settings-read failure itself; the try is for
|
||||
// anything unforeseen, since an async handler that rejects in Express 4
|
||||
// hangs the request instead of reaching the error handler below.
|
||||
try {
|
||||
res.type('html').send(await htmlShell.get())
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
app.get('*', (req, res) =>
|
||||
res
|
||||
|
||||
215
server/src/config/themePresets.js
Normal file
215
server/src/config/themePresets.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// ── Theme presets & the closed sets an admin may choose from ───────────────
|
||||
//
|
||||
// The single authority for admin-configurable theming (docs/website/THEMING_AND_NAV.md
|
||||
// §5-§6). Everything an admin can pick is enumerated here; nothing is free text.
|
||||
//
|
||||
// Why the server owns this rather than theme.css:
|
||||
// The effective token set is resolved server-side and returned by
|
||||
// settings.getPublic() as `theme`, which the SPA writes onto the document as
|
||||
// CSS custom properties. That keeps ONE authority for the override merge
|
||||
// (:root ← preset ← custom), lets brand.accent — a cross-repo contract the
|
||||
// Android app themes itself from — report the same accent the website paints,
|
||||
// and avoids the precedence trap of `[data-theme]` blocks losing to the inline
|
||||
// `--accent` SiteContext already sets on <html>.
|
||||
//
|
||||
// theme.css's `:root` remains the default and is NOT duplicated here beyond
|
||||
// the runic-gateway preset. An instance with no `theme_visual` row gets no
|
||||
// `theme` block at all and renders from :root exactly as it does today.
|
||||
//
|
||||
// Security note: these values end up as CSS custom property values. Every one is
|
||||
// picked from a closed set (a preset id, a shortlist stack, a bounded px length,
|
||||
// a hex color) — see utils/themeResolve.js, which both the write path and the
|
||||
// read path validate through.
|
||||
|
||||
// The three color tokens that are semantic rather than decorative. They mean
|
||||
// "live" and "maintenance" and stay fixed across every preset — green is not a
|
||||
// brand choice. Deliberately absent from every preset block below.
|
||||
const FIXED_TOKENS = ['--mode-live', '--mode-maint']
|
||||
|
||||
// Full palettes. A preset must carry EVERY color token, not just the eight the
|
||||
// admin form exposes: a partial palette leaves e.g. --line and --blue at their
|
||||
// dark-blue :root values, which reads as broken on a warm background.
|
||||
//
|
||||
// --panel-grad is deliberately absent: it is derived (`linear-gradient(180deg,
|
||||
// var(--panel-a), var(--panel-b))`) and must stay derived, or a future light
|
||||
// preset silently inherits a dark gradient.
|
||||
const PRESETS = {
|
||||
// Today's :root, verbatim. Declared as a preset so that switching back to it
|
||||
// after trying another is the same code path as any other choice.
|
||||
'runic-gateway': {
|
||||
label: 'Runic Gateway',
|
||||
tokens: {
|
||||
'--bg': '#0e1318',
|
||||
'--bg-deep': '#0b0f14',
|
||||
'--panel-a': '#192231',
|
||||
'--panel-b': '#141a21',
|
||||
'--panel-flat': '#11161d',
|
||||
'--line': '#2a3544',
|
||||
'--line-soft': '#1d2733',
|
||||
'--accent': '#7f99bd',
|
||||
'--accent-bright': '#cdd9e8',
|
||||
'--ink': '#eef3f8',
|
||||
'--head': '#e6edf6',
|
||||
'--text': '#c4cdd8',
|
||||
'--muted': '#aeb8c4',
|
||||
'--dim': '#6f7d8e',
|
||||
'--blue': '#13243c',
|
||||
'--radius-pill': '999px',
|
||||
'--radius-panel': '12px',
|
||||
'--radius-card': '10px',
|
||||
'--radius-input': '8px',
|
||||
'--shadow-card': '0 14px 34px rgba(0, 0, 0, 0.3)',
|
||||
'--serif': 'Georgia, "Times New Roman", serif',
|
||||
'--display': 'Cinzel, Georgia, serif',
|
||||
'--sans': '"Helvetica Neue", Arial, sans-serif',
|
||||
},
|
||||
},
|
||||
// Flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy.
|
||||
modern: {
|
||||
label: 'Modern',
|
||||
tokens: {
|
||||
'--bg': '#101114',
|
||||
'--bg-deep': '#0a0a0c',
|
||||
'--panel-a': '#1c1d22',
|
||||
'--panel-b': '#17181c',
|
||||
'--panel-flat': '#141519',
|
||||
'--line': '#2b2d34',
|
||||
'--line-soft': '#212329',
|
||||
'--accent': '#4f8ef7',
|
||||
'--accent-bright': '#a8c8ff',
|
||||
'--ink': '#f2f3f5',
|
||||
'--head': '#f7f8fa',
|
||||
'--text': '#b8bcc4',
|
||||
'--muted': '#a9aeb8',
|
||||
'--dim': '#71767f',
|
||||
'--blue': '#1b2c47',
|
||||
'--radius-pill': '8px',
|
||||
'--radius-panel': '8px',
|
||||
'--radius-card': '6px',
|
||||
'--radius-input': '6px',
|
||||
'--shadow-card': '0 8px 20px rgba(0, 0, 0, 0.25)',
|
||||
'--serif': 'Inter, Arial, sans-serif',
|
||||
'--display': "'Work Sans', Arial, sans-serif",
|
||||
'--sans': 'Inter, Arial, sans-serif',
|
||||
},
|
||||
},
|
||||
// Warmer, higher contrast, carved corners; leans into UO harder.
|
||||
fantasy: {
|
||||
label: 'Fantasy',
|
||||
tokens: {
|
||||
'--bg': '#1a120b',
|
||||
'--bg-deep': '#120c07',
|
||||
'--panel-a': '#2c1f14',
|
||||
'--panel-b': '#241a10',
|
||||
'--panel-flat': '#1f160d',
|
||||
'--line': '#4a3721',
|
||||
'--line-soft': '#33251a',
|
||||
'--accent': '#c9973f',
|
||||
'--accent-bright': '#e8c374',
|
||||
'--ink': '#f3e8d4',
|
||||
'--head': '#f7efe0',
|
||||
'--text': '#d3bfa0',
|
||||
'--muted': '#bfa985',
|
||||
'--dim': '#8a7454',
|
||||
'--blue': '#382613',
|
||||
'--radius-pill': '4px',
|
||||
'--radius-panel': '3px',
|
||||
'--radius-card': '2px',
|
||||
'--radius-input': '2px',
|
||||
'--shadow-card': '0 16px 38px rgba(0, 0, 0, 0.45)',
|
||||
'--serif': "'EB Garamond', Georgia, serif",
|
||||
'--display': 'Cinzel, Georgia, serif',
|
||||
'--sans': "'EB Garamond', Georgia, serif",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 'custom' is a valid stored preset meaning "no preset base" — :root plus
|
||||
// whatever custom fields are set. It has no palette of its own.
|
||||
const CUSTOM_PRESET = 'custom'
|
||||
const PRESET_IDS = [...Object.keys(PRESETS), CUSTOM_PRESET]
|
||||
|
||||
// The colors the admin form exposes, mapped to their CSS token. Deliberately
|
||||
// the eight of §6.1 rather than all fifteen: the rest are supporting shades a
|
||||
// preset sets coherently but that are not worth (or safe to) hand-picking.
|
||||
const COLOR_FIELDS = {
|
||||
bg: '--bg',
|
||||
bgDeep: '--bg-deep',
|
||||
panelA: '--panel-a',
|
||||
panelB: '--panel-b',
|
||||
accent: '--accent',
|
||||
accentBright: '--accent-bright',
|
||||
ink: '--ink',
|
||||
text: '--text',
|
||||
}
|
||||
|
||||
const RADIUS_FIELDS = {
|
||||
radiusPill: '--radius-pill',
|
||||
radiusPanel: '--radius-panel',
|
||||
radiusCard: '--radius-card',
|
||||
radiusInput: '--radius-input',
|
||||
}
|
||||
|
||||
const FONT_FIELDS = {
|
||||
serif: '--serif',
|
||||
display: '--display',
|
||||
sans: '--sans',
|
||||
}
|
||||
|
||||
// The curated Google Fonts shortlist (§5.1). The dropdown's VALUE is the full
|
||||
// stack exactly as applied, so no string is ever built from admin input and no
|
||||
// Google Fonts URL is ever assembled at runtime — the combined css2? request in
|
||||
// client/index.html is static and covers all eight web families.
|
||||
//
|
||||
// One addition to §5.1's twelve: Georgia in the serif list. The shortlist as
|
||||
// drafted gave the sans role a "current default" option (Arial, byte-identical
|
||||
// to today's --sans) but left the serif role with no way back to today's
|
||||
// `Georgia, "Times New Roman", serif` short of resetting the whole theme. It
|
||||
// pulls in no web family, so §5.2's URL is unchanged.
|
||||
const FONT_OPTIONS = {
|
||||
serif: [
|
||||
{ value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — strongest fantasy/historic' },
|
||||
{ value: 'Merriweather, Georgia, serif', label: 'Merriweather — excellent readability' },
|
||||
{ value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant/editorial' },
|
||||
{ value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — old-world (no bold weight)' },
|
||||
{ value: 'Georgia, "Times New Roman", serif', label: 'Georgia — the shipped default' },
|
||||
],
|
||||
display: [
|
||||
{ value: 'Cinzel, Georgia, serif', label: 'Cinzel — current Runic Gateway identity' },
|
||||
{ value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant alternative' },
|
||||
{ value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — softer/classic' },
|
||||
{ value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — very strong fantasy (no bold weight)' },
|
||||
],
|
||||
sans: [
|
||||
{ value: 'Inter, Arial, sans-serif', label: 'Inter — default modern UI choice' },
|
||||
{ value: "'Work Sans', Arial, sans-serif", label: 'Work Sans — slightly more character' },
|
||||
{ value: "'Source Sans 3', Arial, sans-serif", label: 'Source Sans 3 — extremely readable' },
|
||||
{ value: '"Helvetica Neue", Arial, sans-serif', label: 'Arial — no webfont; the shipped default' },
|
||||
],
|
||||
}
|
||||
|
||||
// Shadow depth, as a closed set for the same reason fonts are: the stored value
|
||||
// is applied verbatim as --shadow-card.
|
||||
const SHADOW_OPTIONS = [
|
||||
{ value: 'none', label: 'None — flat' },
|
||||
{ value: '0 8px 20px rgba(0, 0, 0, 0.25)', label: 'Soft' },
|
||||
{ value: '0 14px 34px rgba(0, 0, 0, 0.3)', label: 'Default' },
|
||||
{ value: '0 18px 44px rgba(0, 0, 0, 0.45)', label: 'Deep' },
|
||||
]
|
||||
|
||||
// Corner radius is a number, not a shortlist, so it is bounded instead: an
|
||||
// integer count of px from 0 to 999 (999 being the pill).
|
||||
const RADIUS_MAX_PX = 999
|
||||
|
||||
module.exports = {
|
||||
PRESETS,
|
||||
PRESET_IDS,
|
||||
CUSTOM_PRESET,
|
||||
FIXED_TOKENS,
|
||||
COLOR_FIELDS,
|
||||
RADIUS_FIELDS,
|
||||
FONT_FIELDS,
|
||||
FONT_OPTIONS,
|
||||
SHADOW_OPTIONS,
|
||||
RADIUS_MAX_PX,
|
||||
}
|
||||
@@ -118,6 +118,19 @@ const passwordResetConfirmLimiter = makeLimiter({
|
||||
message: 'Too many attempts. Please try again later.',
|
||||
})
|
||||
|
||||
// The player-vendor market search. The first genuinely expensive PUBLIC endpoint
|
||||
// on the site: every call is a LIKE scan plus a COUNT over the listings table,
|
||||
// which on a large shard is the biggest table there is, and it is anonymous by
|
||||
// default. Generous for a human browsing shops (a typed search is debounced to
|
||||
// one request, and paging is a click), tight enough that it cannot be used as a
|
||||
// cheap way to load the database.
|
||||
const marketLimiter = makeLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
label: 'market',
|
||||
message: 'Too many searches. Please slow down.',
|
||||
})
|
||||
|
||||
// CSP violation reports. Unauthenticated by necessity (browsers send them with no
|
||||
// session), and every accepted report writes a log line — so an attacker who can get
|
||||
// a victim to load a page could otherwise use it as a log-flood amplifier. Generous
|
||||
@@ -141,5 +154,6 @@ module.exports = {
|
||||
mobileSsoExchangeLimiter,
|
||||
passwordResetRequestLimiter,
|
||||
passwordResetConfirmLimiter,
|
||||
marketLimiter,
|
||||
cspReportLimiter,
|
||||
}
|
||||
|
||||
@@ -22,4 +22,12 @@ async function seedDefault(key, value) {
|
||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault }
|
||||
// Delete a settings row. "Reset to defaults" for the theming/nav keys is the
|
||||
// *absence* of a row, not a stored copy of the defaults — see
|
||||
// docs/website/THEMING_AND_NAV.md §2. Deleting a key that was never set is a
|
||||
// no-op, so reset is idempotent.
|
||||
async function remove(key) {
|
||||
await query('DELETE FROM settings WHERE `key` = ?', [key])
|
||||
}
|
||||
|
||||
module.exports = { getAll, get, set, seedDefault, remove }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const settingsDb = require('./settings.db')
|
||||
const brand = require('../../config/brand')
|
||||
const { parseJsonSetting } = require('../../utils/settingsJson')
|
||||
const { resolveThemeTokens } = require('../../utils/themeResolve')
|
||||
const { resolveBrandAssets } = require('../../utils/brandAssets')
|
||||
|
||||
// Keys safe to expose on the public site.
|
||||
const PUBLIC_KEYS = [
|
||||
@@ -10,8 +13,27 @@ const PUBLIC_KEYS = [
|
||||
'contact_email',
|
||||
'site_title',
|
||||
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
|
||||
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
|
||||
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
|
||||
'nav_public', // public site nav overrides (JSON). §6.4.
|
||||
]
|
||||
|
||||
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
|
||||
// All five are JSON strings and all five are ABSENT by default — no migration
|
||||
// seeds them. Absence of the row, not an empty value, is what makes a surface
|
||||
// fall back to BRAND_* env / the hardcoded theme.css / the hardcoded NAV arrays.
|
||||
//
|
||||
// nav_admin and nav_player are deliberately not public: an anonymous visitor has
|
||||
// no use for either, and the admin nav's labels describe the shape of the admin
|
||||
// surface. They are read by their owners through GET /api/v1/settings/nav (§4.2).
|
||||
const THEMING_KEYS = ['theme_visual', 'brand_assets', 'nav_public', 'nav_admin', 'nav_player']
|
||||
|
||||
// Keys a reset may delete. An explicit allowlist, not "any key": DELETE on an
|
||||
// arbitrary key would let a bad request drop site_mode or the uo-link config,
|
||||
// whose absence means something else entirely. hero_layout_draft is included
|
||||
// because discarding a draft is the same operation.
|
||||
const DELETABLE_KEYS = [...THEMING_KEYS, 'hero_layout_draft']
|
||||
|
||||
// Player self-registration mode. Stored under the 'player_registration' key.
|
||||
// NOTE: the raw value is never exposed publicly — getPublic() derives boolean
|
||||
// availability flags from it instead (see below).
|
||||
@@ -70,6 +92,24 @@ async function isMobileAppLinksEnabled() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This instance's name, resolved exactly as `getPublic().brand.name` resolves it —
|
||||
* the admin-editable site title wins over BRAND_NAME. Anything that has to *speak*
|
||||
* the instance's name outside the settings payload must use this rather than
|
||||
* `brand.name`, or an install that set only the site title gets two different names
|
||||
* on two different pages.
|
||||
*
|
||||
* Never throws: a name is always better than an error, so a DB fault falls back to
|
||||
* the env value.
|
||||
*/
|
||||
async function getInstanceName() {
|
||||
try {
|
||||
return (await settingsDb.get('site_title')) || brand.name
|
||||
} catch {
|
||||
return brand.name
|
||||
}
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
return settingsDb.get(key)
|
||||
}
|
||||
@@ -78,6 +118,10 @@ async function set(key, value, updatedBy = null) {
|
||||
return settingsDb.set(key, value, updatedBy)
|
||||
}
|
||||
|
||||
async function remove(key) {
|
||||
return settingsDb.remove(key)
|
||||
}
|
||||
|
||||
async function setMany(obj, updatedBy = null) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
await settingsDb.set(key, value, updatedBy)
|
||||
@@ -106,9 +150,29 @@ async function getPublic() {
|
||||
// the final say when the call is made). Lets the portal show/hide the form.
|
||||
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
|
||||
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
|
||||
// Instance branding (BRAND_* env defaults). The two admin-editable settings —
|
||||
// site title and contact email — override the env value when set, so existing
|
||||
// installs keep their DB-configured name; everything else comes from env.
|
||||
// The effective CSS custom properties for the admin's theme, or absent when
|
||||
// no theme_visual row exists (or nothing in it was usable). The SPA writes
|
||||
// these onto <html>; absence means it writes nothing and theme.css's :root
|
||||
// stands, which is what keeps an untouched instance byte-for-byte as today.
|
||||
// Resolution — :root ← preset ← custom — happens here rather than in CSS so
|
||||
// there is one authority and brand.accent below can report the same value the
|
||||
// site actually paints. See THEMING_AND_NAV.md §6.
|
||||
const theme = resolveThemeTokens(all.theme_visual)
|
||||
if (theme) out.theme = theme
|
||||
// Uploaded brand-asset overrides (§6.3), resolved here so every consumer of
|
||||
// the brand block — the SPA, the Android app, the Discord bot — picks them up
|
||||
// through the one contract. Forgiving on read like the theme: a slot holding
|
||||
// something we would not emit as a URL is dropped and its neighbours kept.
|
||||
const brandAssets = resolveBrandAssets(parseJsonSetting(all.brand_assets))
|
||||
// Instance branding (BRAND_* env defaults). The admin-editable settings —
|
||||
// site title, contact email, and now the theme accent and uploaded assets —
|
||||
// override the env value when set, so existing installs keep their
|
||||
// DB-configured name; everything else comes from env.
|
||||
//
|
||||
// brand.accent is a CROSS-REPO CONTRACT: the Android app themes its whole
|
||||
// Material palette from it (BrandDto → RunicGatewayTheme) and the Discord bot
|
||||
// colors its embeds from it. Resolving the effective accent here is what lets
|
||||
// both track admin theming with no client change.
|
||||
out.brand = {
|
||||
name: out.site_title || brand.name,
|
||||
shortName: brand.shortName,
|
||||
@@ -116,10 +180,10 @@ async function getPublic() {
|
||||
description: brand.description,
|
||||
contactEmail: out.contact_email || brand.contactEmail,
|
||||
url: brand.url,
|
||||
accent: brand.accent,
|
||||
logo: brand.logo,
|
||||
hero: brand.hero,
|
||||
favicon: brand.favicon,
|
||||
accent: theme?.['--accent'] || brand.accent,
|
||||
logo: brandAssets.logo || brand.logo,
|
||||
hero: brandAssets.hero || brand.hero,
|
||||
favicon: brandAssets.favicon || brand.favicon,
|
||||
}
|
||||
// Push-notification relay (M7). The client-facing ntfy base URL the app's
|
||||
// embedded distributor registers its device topic against; null when push is
|
||||
@@ -137,6 +201,41 @@ async function getPublic() {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* What the HTML shell needs, resolved exactly as getPublic() resolves it: the
|
||||
* effective favicon and logo, plus the theme token map for the boot <style>
|
||||
* block. Kept here rather than in utils/htmlShell.js so there is one authority
|
||||
* for "which asset wins", and so the shell can never disagree with the payload
|
||||
* the SPA fetches a moment later.
|
||||
*
|
||||
* Throws on a DB fault — the caller (utils/htmlShell.js) decides what a failure
|
||||
* means for the page, and for it the answer is "serve the env-only shell".
|
||||
*
|
||||
* @returns {Promise<{logo: string, favicon: string, theme: object|null}>}
|
||||
*/
|
||||
async function getShellBrand() {
|
||||
const all = await getAll()
|
||||
const assets = resolveBrandAssets(parseJsonSetting(all.brand_assets))
|
||||
return {
|
||||
logo: assets.logo || brand.logo,
|
||||
favicon: assets.favicon || brand.favicon,
|
||||
theme: resolveThemeTokens(all.theme_visual),
|
||||
}
|
||||
}
|
||||
|
||||
// The two nav-override keys their own audiences need but cannot read from
|
||||
// GET /admin/settings (admin-only, while AdminLayout renders for editors and
|
||||
// moderators and PlayerPortalLayout renders for players — THEMING_AND_NAV.md
|
||||
// §4.2). Values are returned as stored: raw JSON strings, or null when the
|
||||
// admin never overrode that nav.
|
||||
async function getNav() {
|
||||
const all = await getAll()
|
||||
return {
|
||||
nav_admin: all.nav_admin ?? null,
|
||||
nav_player: all.nav_player ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// The client-facing ntfy base URL (no trailing slash), or null when unset.
|
||||
function publicNtfyUrl() {
|
||||
const explicit = (process.env.NTFY_PUBLIC_URL || '').trim()
|
||||
@@ -151,10 +250,16 @@ function publicNtfyUrl() {
|
||||
module.exports = {
|
||||
get,
|
||||
set,
|
||||
remove,
|
||||
setMany,
|
||||
getAll,
|
||||
getPublic,
|
||||
getShellBrand,
|
||||
getNav,
|
||||
getInstanceName,
|
||||
PUBLIC_KEYS,
|
||||
THEMING_KEYS,
|
||||
DELETABLE_KEYS,
|
||||
REGISTRATION_KEY,
|
||||
REGISTRATION_MODES,
|
||||
getRegistrationMode,
|
||||
|
||||
108
server/src/model/shardClilocs/shardClilocs.db.js
Normal file
108
server/src/model/shardClilocs/shardClilocs.db.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const { pool, query } = require('../../utils/db')
|
||||
|
||||
// Raw SQL for the cliloc table. `shard_clilocs` is IMPORT-OWNED: `replaceAll`
|
||||
// empties and refills it inside one transaction, and nothing else in the
|
||||
// codebase writes to it. No foreign keys, consistent with every other shard_*
|
||||
// table.
|
||||
|
||||
const BATCH = 1000
|
||||
|
||||
/**
|
||||
* Replace the entire cliloc table in one transaction.
|
||||
*
|
||||
* All-or-nothing on purpose: a failed reload must leave the previous table
|
||||
* intact rather than a half-loaded one, because a partially-imported cliloc
|
||||
* table is indistinguishable from a complete one to anyone reading it — you
|
||||
* would just see some items named and some not, which is also what "no table at
|
||||
* all" looks like.
|
||||
*
|
||||
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
|
||||
* commits, which would defeat exactly that guarantee. (The same trap the spawn
|
||||
* atlas import documents; at ~123k rows `DELETE` is still well under a second.)
|
||||
*/
|
||||
async function replaceAll(entries, meta) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query('DELETE FROM shard_clilocs')
|
||||
|
||||
// Blank entries are dropped rather than stored. Roughly HALF of a real
|
||||
// cliloc table is empty strings — ids the client reserves and never uses —
|
||||
// and a row that resolves to no name is indistinguishable from no row at
|
||||
// all to every caller. Dropping them halves the table (123,490 → ~67,500)
|
||||
// and, more importantly, makes the binary and text imports converge on
|
||||
// identical content: the binary format carries the blanks explicitly and a
|
||||
// text export may or may not, depending on the tool.
|
||||
//
|
||||
// Later duplicates win. Merging across sources already happened upstream in
|
||||
// `readCliloc`, so in practice this collapses nothing — it is kept because
|
||||
// the plain format permits a repeated id WITHIN one file and the client's
|
||||
// own loader resolves it the same way (its dictionary assignment
|
||||
// overwrites). Without it, a file the game itself would load happily would
|
||||
// fail the batch insert on a primary-key collision.
|
||||
const byNumber = new Map()
|
||||
let blank = 0
|
||||
for (const entry of entries) {
|
||||
if (!Number.isInteger(entry.number)) continue
|
||||
if (String(entry.text ?? '').trim() === '') {
|
||||
blank++
|
||||
continue
|
||||
}
|
||||
byNumber.set(entry.number, entry)
|
||||
}
|
||||
|
||||
const rows = [...byNumber.values()].map((e) => [e.number, e.flag ?? 0, e.text])
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch('INSERT INTO shard_clilocs (number, flag, text) VALUES (?,?,?)', rows.slice(i, i + BATCH))
|
||||
}
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO shard_cliloc_meta (id, payload) VALUES (1, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||
[JSON.stringify({ ...meta, count: rows.length })],
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
return { count: rows.length, blank, duplicates: entries.length - blank - rows.length }
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMeta() {
|
||||
const rows = await query('SELECT payload, imported_at FROM shard_cliloc_meta WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||
return { ...payload, importedAt: rows[0].imported_at }
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a batch of ids.
|
||||
*
|
||||
* Batched rather than one-at-a-time because every caller has a LIST: a character
|
||||
* sheet resolves a dozen equipment ids at once, and a page of marketplace
|
||||
* listings resolves fifty. `IN (...)` with generated placeholders keeps it one
|
||||
* round trip and one parameterized statement.
|
||||
*/
|
||||
async function lookup(numbers) {
|
||||
if (!Array.isArray(numbers) || numbers.length === 0) return []
|
||||
const ids = [...new Set(numbers.filter((n) => Number.isInteger(n)))]
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
return query(`SELECT number, text FROM shard_clilocs WHERE number IN (${placeholders})`, ids)
|
||||
}
|
||||
|
||||
async function count() {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM shard_clilocs')
|
||||
return Number(rows[0]?.n) || 0
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceAll,
|
||||
getMeta,
|
||||
lookup,
|
||||
count,
|
||||
}
|
||||
368
server/src/model/shardClilocs/shardClilocs.model.js
Normal file
368
server/src/model/shardClilocs/shardClilocs.model.js
Normal file
@@ -0,0 +1,368 @@
|
||||
const db = require('./shardClilocs.db')
|
||||
const settings = require('../settings/settings.model')
|
||||
const { displayText } = require('../../utils/clilocParse')
|
||||
const {
|
||||
ClilocFormatError,
|
||||
ClilocSourceError,
|
||||
PARSER_VERSION,
|
||||
hashSources,
|
||||
sameSources,
|
||||
missingSources,
|
||||
readCliloc,
|
||||
} = require('../../utils/clilocSource')
|
||||
const log = require('../../utils/logger')('shardClilocs')
|
||||
|
||||
// The cliloc table — UO's id → display-string map, refreshed from a file the
|
||||
// operator converts once from their own client.
|
||||
//
|
||||
// Why the site holds this at all: items on the wire carry a `LabelNumber`, not a
|
||||
// name. `char.profile.equipment` has always sent `cliloc`, and every marketplace
|
||||
// listing sends one too. Without the table the UI can only print `id 1023721`
|
||||
// where the game prints "quarter staff".
|
||||
//
|
||||
// Two rules govern the boot path, both inherited from the spawn atlas:
|
||||
//
|
||||
// 1. **It never blocks startup.** No configured path, an unreadable file, a
|
||||
// wrong-format file, a database error — all caught and logged. The site
|
||||
// comes up either way, serving whatever table it already had (or none, in
|
||||
// which case the UI falls back to item ids exactly as it did before).
|
||||
// 2. **Nothing client-derived is committed.** The table is built from the
|
||||
// operator's own file at a configured path. The repo ships no strings.
|
||||
//
|
||||
// The table is built from a SET of sources — the converted client table plus
|
||||
// every operator-maintained overlay beside it — because shards edit items and
|
||||
// add new ones, and those carry cliloc ids no stock client table has. All of
|
||||
// them are re-read on every boot and hash-gated together, so adding one custom
|
||||
// item never means re-exporting a 5 MB client file. Later sources win.
|
||||
//
|
||||
// That set is also why this has the atlas's escalation, in a lighter form. A
|
||||
// single corrupt file fails the parse loudly, but a source that has simply
|
||||
// VANISHED parses perfectly and imports a table quietly missing everything it
|
||||
// contributed — the same ambiguity (real change vs half-copied mount) the atlas
|
||||
// stages a facet removal for. So a disappearing source is refused and reported
|
||||
// rather than applied.
|
||||
//
|
||||
// It is lighter than the atlas's because it needs to be: the atlas stores a
|
||||
// pending decision in its own table and adds approve/reject endpoints, whereas
|
||||
// here the decision is a single boolean an admin passes to the import they were
|
||||
// already going to run. Re-parsing at approval time — the property that makes
|
||||
// the atlas store only the decision — is automatic when there is nothing stored.
|
||||
|
||||
const SETTING_KEY = 'cliloc_client_path'
|
||||
|
||||
/**
|
||||
* Where the converted cliloc file lives.
|
||||
*
|
||||
* The admin setting wins over the environment so an operator can repoint it
|
||||
* without a redeploy, matching how the rest of the shard integration is
|
||||
* admin-managed rather than env-configured. `UO_CLIENT_PATH` remains as the
|
||||
* deploy-time default, since the path usually describes a mount the deployment
|
||||
* sets up.
|
||||
*/
|
||||
async function getClientPath() {
|
||||
try {
|
||||
const configured = await settings.get(SETTING_KEY)
|
||||
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
||||
} catch {
|
||||
// Settings unavailable is not fatal — fall through to the env default.
|
||||
}
|
||||
const fromEnv = process.env.UO_CLIENT_PATH
|
||||
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
||||
}
|
||||
|
||||
async function setClientPath(value, updatedBy = null) {
|
||||
const result = await settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
||||
invalidate()
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Was the loaded table built by THIS parser? */
|
||||
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||
|
||||
/**
|
||||
* Refresh the cliloc table from the configured file.
|
||||
*
|
||||
* Returns a result describing what happened rather than throwing, so the caller
|
||||
* — including the boot path — can log it and move on:
|
||||
*
|
||||
* `skipped` no path configured
|
||||
* `unavailable` path configured but missing / unreadable / not a cliloc file
|
||||
* `unchanged` source hashes match the loaded table; nothing parsed
|
||||
* `imported` parsed and applied
|
||||
* `needsReview` a previously-present source has vanished; NOT applied
|
||||
* `failed` parsed or applied and something went wrong
|
||||
*
|
||||
* `force` skips the hash check (an admin asking for a reimport). `approve`
|
||||
* additionally accepts a vanished source.
|
||||
*/
|
||||
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||
// An explicit override wins outright — a one-off "use this file", which must
|
||||
// not be silently overruled by the configured path the way an env default is.
|
||||
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
|
||||
if (configured === '') return { status: 'skipped', reason: 'no cliloc path configured' }
|
||||
|
||||
let fingerprint
|
||||
try {
|
||||
fingerprint = hashSources(configured)
|
||||
} catch (err) {
|
||||
if (err instanceof ClilocSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: configured }
|
||||
}
|
||||
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
|
||||
// Two things make a loaded table stale: any source changed, or the PARSER did.
|
||||
// Only checking the sources would strand an install whose client never patches
|
||||
// on whatever an older build derived.
|
||||
if (!force && sameSources(fingerprint.hashes, meta?.hashes) && currentParser(meta)) {
|
||||
return {
|
||||
status: 'unchanged',
|
||||
path: configured,
|
||||
file: fingerprint.file,
|
||||
count: meta.count ?? null,
|
||||
customCount: fingerprint.customCount,
|
||||
}
|
||||
}
|
||||
|
||||
// A source that was there last import and is not there now is refused, not
|
||||
// applied — an unmounted volume and a deliberate deletion look identical from
|
||||
// here, and the wrong guess silently drops every name that file contributed.
|
||||
const gone = missingSources(fingerprint.hashes, meta?.hashes)
|
||||
if (gone.length > 0 && !approve) {
|
||||
return {
|
||||
status: 'needsReview',
|
||||
reason: `${gone.length} previously-loaded cliloc source(s) are missing; the existing table is unchanged`,
|
||||
missingSources: gone,
|
||||
path: configured,
|
||||
file: fingerprint.file,
|
||||
}
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = readCliloc(configured)
|
||||
} catch (err) {
|
||||
if (err instanceof ClilocFormatError || err instanceof ClilocSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: configured }
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: configured }
|
||||
}
|
||||
|
||||
try {
|
||||
const applied = await db.replaceAll(parsed.entries, parsed.source)
|
||||
invalidate()
|
||||
return {
|
||||
status: 'imported',
|
||||
path: configured,
|
||||
file: parsed.source.file,
|
||||
count: applied.count,
|
||||
parsed: parsed.entries.length,
|
||||
blank: applied.blank,
|
||||
// Per-source breakdown: how many entries each file contributed and how
|
||||
// many of them overrode something already merged. An operator who adds an
|
||||
// overlay wants to see it took effect, and "overrode: 0" on a file meant
|
||||
// to re-label stock items says it did not.
|
||||
sources: parsed.source.sources,
|
||||
acceptedMissing: gone.length > 0 ? gone : undefined,
|
||||
}
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: configured }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
|
||||
* missing or malformed cliloc file can never stop the site coming up.
|
||||
*/
|
||||
async function refreshOnBoot() {
|
||||
try {
|
||||
const result = await refresh()
|
||||
switch (result.status) {
|
||||
case 'imported':
|
||||
log.info('cliloc table refreshed', {
|
||||
file: result.file,
|
||||
count: result.count,
|
||||
overlays: (result.sources || []).filter((s) => s.kind === 'custom').length,
|
||||
})
|
||||
break
|
||||
case 'needsReview':
|
||||
log.warn(
|
||||
'cliloc refresh staged for admin review — a previously-loaded source is missing; ' +
|
||||
'the existing table is unchanged',
|
||||
{ missing: result.missingSources },
|
||||
)
|
||||
break
|
||||
case 'unavailable':
|
||||
// Deliberately a warning, not an error: an operator who has not supplied
|
||||
// a cliloc file is in a supported state (the UI shows item ids), and the
|
||||
// most common cause — pointing at the client's own compressed file —
|
||||
// needs the reason spelled out rather than a stack trace.
|
||||
log.warn('cliloc source unavailable (item names will show as ids)', {
|
||||
reason: result.reason,
|
||||
code: result.code,
|
||||
path: result.path,
|
||||
})
|
||||
break
|
||||
case 'failed':
|
||||
log.warn('cliloc refresh failed', { reason: result.reason })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
log.warn('cliloc refresh errored', { error: err.message })
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the admin panel needs to describe cliloc state. */
|
||||
async function status({ path: pathOverride = '' } = {}) {
|
||||
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const loaded = await db.count().catch(() => 0)
|
||||
|
||||
let fileReadable = false
|
||||
let file = null
|
||||
let drift = null
|
||||
let problem = null
|
||||
let code = null
|
||||
let sources = []
|
||||
let missing = []
|
||||
if (configured !== '') {
|
||||
try {
|
||||
const fingerprint = hashSources(configured)
|
||||
fileReadable = true
|
||||
file = fingerprint.file
|
||||
sources = Object.keys(fingerprint.hashes)
|
||||
missing = missingSources(fingerprint.hashes, meta?.hashes)
|
||||
// A compressed file is readable but not importable, and the panel has to
|
||||
// say so HERE — otherwise pointing at an unconverted client directory
|
||||
// reports a healthy file with pending drift ("ready to import") and the
|
||||
// operator only finds out when the import fails. `drift` stays null
|
||||
// because comparing hashes with an unusable file answers nothing.
|
||||
if (fingerprint.compressed) {
|
||||
problem =
|
||||
'This is a compressed (Mythic-format) cliloc file, which the site cannot read. ' +
|
||||
'Convert it to the plain format first — see docs/website/CLILOCS.md.'
|
||||
code = 'COMPRESSED'
|
||||
} else {
|
||||
drift = !sameSources(fingerprint.hashes, meta?.hashes) || !currentParser(meta)
|
||||
}
|
||||
} catch (err) {
|
||||
fileReadable = false
|
||||
problem = err.message
|
||||
code = err.code ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configured: configured !== '',
|
||||
path: configured,
|
||||
file,
|
||||
fileReadable,
|
||||
problem,
|
||||
code,
|
||||
drift,
|
||||
count: loaded,
|
||||
// Every source found now (base first, then overlays), what each contributed
|
||||
// at the last import, and any that have since vanished — which is the state
|
||||
// an import will refuse without `approve`.
|
||||
sources,
|
||||
loadedSources: meta?.sources ?? null,
|
||||
missingSources: missing,
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
sourceBytes: meta?.bytes ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lookup ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolution happens SERVER-SIDE, not in the browser. Two reasons: the table is
|
||||
// ~123k rows and shipping it to a client would dwarf every page that uses it,
|
||||
// and the Android app consumes the same JSON and would otherwise need its own
|
||||
// copy. Callers get names, not ids-plus-a-table.
|
||||
|
||||
// A small write-through cache in front of the table. Item ids repeat heavily —
|
||||
// one page of listings is mostly the same few hundred clilocs, and a character
|
||||
// sheet re-resolves the same gear on every view — so this turns the steady state
|
||||
// into zero queries. Capped so a pathological caller cannot grow it without
|
||||
// bound; on overflow it is dropped wholesale rather than evicted entry-by-entry,
|
||||
// which is cheap and correct for a table that only changes on reimport.
|
||||
const CACHE_MAX = 20000
|
||||
let cache = new Map()
|
||||
|
||||
function invalidate() {
|
||||
cache = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a batch of cliloc ids to display strings.
|
||||
*
|
||||
* Returns a `Map<number, string>` holding only the ids that resolved to
|
||||
* something displayable — an id with no row, or one whose text is nothing but
|
||||
* interpolated arguments we do not have, is simply absent. Callers fall back to
|
||||
* whatever they had (the item id), so "missing" and "unnamed" collapse into one
|
||||
* branch at the call site.
|
||||
*
|
||||
* Never throws: a cliloc lookup is decoration on someone's character sheet, and
|
||||
* a database blip must not fail the sheet.
|
||||
*/
|
||||
async function resolveMany(numbers) {
|
||||
const out = new Map()
|
||||
if (!Array.isArray(numbers)) return out
|
||||
|
||||
const wanted = [...new Set(numbers.filter((n) => Number.isInteger(n) && n > 0))]
|
||||
if (wanted.length === 0) return out
|
||||
|
||||
const missing = []
|
||||
for (const number of wanted) {
|
||||
if (cache.has(number)) {
|
||||
const hit = cache.get(number)
|
||||
if (hit !== '') out.set(number, hit)
|
||||
} else {
|
||||
missing.push(number)
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
try {
|
||||
const rows = await db.lookup(missing)
|
||||
const found = new Map(rows.map((r) => [Number(r.number), displayText(r.text)]))
|
||||
if (cache.size + missing.length > CACHE_MAX) invalidate()
|
||||
for (const number of missing) {
|
||||
// Cache the miss too ('' meaning "no usable name"), so an id absent from
|
||||
// the table does not re-query on every page view.
|
||||
const text = found.get(number) ?? ''
|
||||
cache.set(number, text)
|
||||
if (text !== '') out.set(number, text)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('cliloc lookup failed', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** Single-id convenience. Returns `null` when there is no usable name. */
|
||||
async function resolve(number) {
|
||||
const found = await resolveMany([number])
|
||||
return found.get(number) ?? null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SETTING_KEY,
|
||||
getClientPath,
|
||||
setClientPath,
|
||||
refresh,
|
||||
refreshOnBoot,
|
||||
status,
|
||||
resolveMany,
|
||||
resolve,
|
||||
invalidate,
|
||||
}
|
||||
299
server/src/model/shardMarket/shardMarket.db.js
Normal file
299
server/src/model/shardMarket/shardMarket.db.js
Normal file
@@ -0,0 +1,299 @@
|
||||
const { pool, query } = require('../../utils/db')
|
||||
|
||||
// Raw SQL for the player-vendor market index (Protocol 3.0 vendor.listing).
|
||||
//
|
||||
// Two tables, both INGEST-OWNED: `shard_vendors` (one row per shop) and
|
||||
// `shard_vendor_items` (one row per priced listing). Nothing else in the codebase
|
||||
// writes to either. No foreign keys, consistent with every other shard_* table.
|
||||
|
||||
// Insert batch size for one vendor's listings. A shop is capped at
|
||||
// MarketMaxListings (250 by default) on the shard side, so in practice this is
|
||||
// one batch — it exists for the operator who raised that cap.
|
||||
const BATCH = 500
|
||||
|
||||
// LIKE wildcards in user input. `%` and `_` are not special to the parameterized
|
||||
// query — they are special to LIKE itself — so a search for "50% off" would
|
||||
// otherwise match everything containing "50" and a search for "_" would match
|
||||
// every single-character name. Escaped with a backslash, which is MariaDB's
|
||||
// default LIKE escape (no ESCAPE clause needed).
|
||||
const likeTerm = (q) => `%${String(q).replace(/[\\%_]/g, (c) => `\\${c}`)}%`
|
||||
|
||||
/**
|
||||
* Replace one vendor's whole row and listing set, in one transaction.
|
||||
*
|
||||
* Delete-then-insert rather than a diff, because the frame is AUTHORITATIVE for
|
||||
* that vendor: the shard's sweep only emits a shop whose contents, prices or
|
||||
* location moved, and when it does it sends the whole shop. Reconciling it item
|
||||
* by item would be more code for the same result and would leave sold items
|
||||
* behind on any path the reconciliation missed.
|
||||
*
|
||||
* All-or-nothing matters here for a specific reason: the two writes are "the
|
||||
* shop" and "what is in it", and a failure between them leaves a shop advertising
|
||||
* an inventory it no longer has (or none at all) — visibly wrong on the page, and
|
||||
* indistinguishable from a genuinely empty shop.
|
||||
*/
|
||||
async function replaceVendor(vendor, items) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
await conn.query(
|
||||
`INSERT INTO shard_vendors
|
||||
(serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
|
||||
item_count, item_total, truncated, t)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE shop_name = VALUES(shop_name), owner_serial = VALUES(owner_serial),
|
||||
owner_name = VALUES(owner_name), map = VALUES(map), x = VALUES(x), y = VALUES(y),
|
||||
z = VALUES(z), region = VALUES(region), house = VALUES(house),
|
||||
item_count = VALUES(item_count), item_total = VALUES(item_total),
|
||||
truncated = VALUES(truncated), t = VALUES(t),
|
||||
-- Touched explicitly rather than left to ON UPDATE CURRENT_TIMESTAMP:
|
||||
-- MariaDB does not fire that when every column is written back
|
||||
-- unchanged, and a shop that is re-published identically is still
|
||||
-- FRESHLY CONFIRMED. Without this the staleness banner would age a
|
||||
-- perfectly current shop forever.
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
vendor.serial,
|
||||
vendor.shopName ?? null,
|
||||
vendor.ownerSerial ?? null,
|
||||
vendor.ownerName ?? null,
|
||||
vendor.map ?? null,
|
||||
Number.isFinite(vendor.x) ? vendor.x : null,
|
||||
Number.isFinite(vendor.y) ? vendor.y : null,
|
||||
Number.isFinite(vendor.z) ? vendor.z : null,
|
||||
vendor.region ?? null,
|
||||
vendor.house ?? null,
|
||||
items.length,
|
||||
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
|
||||
vendor.truncated ? 1 : 0,
|
||||
Number.isFinite(vendor.t) ? vendor.t : null,
|
||||
],
|
||||
)
|
||||
|
||||
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [vendor.serial])
|
||||
|
||||
const rows = items.map((i) => [
|
||||
vendor.serial,
|
||||
i.serial,
|
||||
i.itemId,
|
||||
i.hue,
|
||||
i.amount,
|
||||
i.price,
|
||||
i.name,
|
||||
i.cliloc,
|
||||
i.displayName,
|
||||
i.child ? 1 : 0,
|
||||
])
|
||||
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(
|
||||
`INSERT INTO shard_vendor_items
|
||||
(vendor_serial, serial, item_id, hue, amount, price, name, cliloc, display_name, child)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
rows.slice(i, i + BATCH),
|
||||
)
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
return { items: rows.length }
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop one vendor and its listings (vendor.listing.remove). */
|
||||
async function removeVendor(serial) {
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query('DELETE FROM shard_vendor_items WHERE vendor_serial = ?', [serial])
|
||||
await conn.query('DELETE FROM shard_vendors WHERE serial = ?', [serial])
|
||||
await conn.commit()
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The unit of a search RESULT is a listing, not a vendor: "who sells a vanquishing
|
||||
// kryss and for how much" is the question, and answering it per vendor would make
|
||||
// the caller flatten the shops back out. The vendor's columns ride along on the
|
||||
// join so a result row is self-contained.
|
||||
|
||||
function searchWhere({ q, minPrice, maxPrice, itemId, map, region }) {
|
||||
const where = ['i.price > 0']
|
||||
const params = []
|
||||
|
||||
if (q) {
|
||||
// Both the resolved display name and the item's own literal, because an item
|
||||
// with a player-set name (most of what is actually worth searching for on a
|
||||
// player-run shard) may have a generic cliloc.
|
||||
where.push('(i.display_name LIKE ? OR i.name LIKE ?)')
|
||||
params.push(likeTerm(q), likeTerm(q))
|
||||
}
|
||||
if (Number.isFinite(minPrice)) {
|
||||
where.push('i.price >= ?')
|
||||
params.push(minPrice)
|
||||
}
|
||||
if (Number.isFinite(maxPrice)) {
|
||||
where.push('i.price <= ?')
|
||||
params.push(maxPrice)
|
||||
}
|
||||
if (Number.isFinite(itemId)) {
|
||||
where.push('i.item_id = ?')
|
||||
params.push(itemId)
|
||||
}
|
||||
if (map) {
|
||||
where.push('v.map = ?')
|
||||
params.push(map)
|
||||
}
|
||||
if (region) {
|
||||
where.push('v.region = ?')
|
||||
params.push(region)
|
||||
}
|
||||
|
||||
return { sql: `WHERE ${where.join(' AND ')}`, params }
|
||||
}
|
||||
|
||||
// Whitelisted, because this interpolates into the statement. `recent` sorts by
|
||||
// the vendor's freshness, which is the only way to see what has just been listed
|
||||
// on a shard whose sweep is minutes wide.
|
||||
const SORTS = {
|
||||
price_asc: 'i.price ASC, i.id ASC',
|
||||
price_desc: 'i.price DESC, i.id ASC',
|
||||
recent: 'v.updated_at DESC, i.id ASC',
|
||||
}
|
||||
|
||||
async function searchListings({ q, minPrice, maxPrice, itemId, map, region, sort, limit, offset }) {
|
||||
const { sql, params } = searchWhere({ q, minPrice, maxPrice, itemId, map, region })
|
||||
const order = SORTS[sort] || SORTS.price_asc
|
||||
|
||||
const rows = await query(
|
||||
`SELECT i.serial, i.item_id, i.hue, i.amount, i.price, i.name, i.cliloc, i.display_name, i.child,
|
||||
v.serial AS vendor_serial, v.shop_name, v.owner_serial, v.owner_name,
|
||||
v.map, v.x, v.y, v.z, v.region, v.house, v.updated_at
|
||||
FROM shard_vendor_items i
|
||||
JOIN shard_vendors v ON v.serial = i.vendor_serial
|
||||
${sql}
|
||||
ORDER BY ${order}
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
|
||||
const counted = await query(
|
||||
`SELECT COUNT(*) AS n
|
||||
FROM shard_vendor_items i
|
||||
JOIN shard_vendors v ON v.serial = i.vendor_serial
|
||||
${sql}`,
|
||||
params,
|
||||
)
|
||||
|
||||
return { rows, total: Number(counted[0]?.n) || 0 }
|
||||
}
|
||||
|
||||
async function getVendor(serial) {
|
||||
const rows = await query(
|
||||
`SELECT serial, shop_name, owner_serial, owner_name, map, x, y, z, region, house,
|
||||
item_count, item_total, truncated, t, updated_at
|
||||
FROM shard_vendors WHERE serial = ?`,
|
||||
[serial],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function listVendorItems(serial, { limit, offset }) {
|
||||
return query(
|
||||
`SELECT serial, item_id, hue, amount, price, name, cliloc, display_name, child
|
||||
FROM shard_vendor_items
|
||||
WHERE vendor_serial = ?
|
||||
ORDER BY price ASC, id ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[serial, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the market page's header needs: how big the index is, and how stale it may
|
||||
* be. `staleAt` is the OLDEST vendor row — the round-robin sweep means a shop can
|
||||
* be a full cycle behind, and the page says so rather than implying live prices.
|
||||
*/
|
||||
async function meta() {
|
||||
const rows = await query(
|
||||
`SELECT COUNT(*) AS vendors, MIN(updated_at) AS stale_at, MAX(updated_at) AS fresh_at
|
||||
FROM shard_vendors`,
|
||||
)
|
||||
const items = await query('SELECT COUNT(*) AS n FROM shard_vendor_items')
|
||||
return {
|
||||
vendors: Number(rows[0]?.vendors) || 0,
|
||||
items: Number(items[0]?.n) || 0,
|
||||
staleAt: rows[0]?.stale_at || null,
|
||||
freshAt: rows[0]?.fresh_at || null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The distinct facets and regions holding vendors — drives the page's filters. */
|
||||
async function listPlaces() {
|
||||
const maps = await query(
|
||||
'SELECT DISTINCT map FROM shard_vendors WHERE map IS NOT NULL ORDER BY map',
|
||||
)
|
||||
const regions = await query(
|
||||
'SELECT DISTINCT region FROM shard_vendors WHERE region IS NOT NULL ORDER BY region',
|
||||
)
|
||||
return { maps: maps.map((r) => r.map), regions: regions.map((r) => r.region) }
|
||||
}
|
||||
|
||||
// ── Cliloc re-resolution ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One page of listings whose name still needs resolving, for the bulk pass that
|
||||
* runs after a cliloc import.
|
||||
*
|
||||
* Keyed on `id > after` rather than OFFSET: the pass updates the very rows it is
|
||||
* scanning, and an OFFSET walk over a table being rewritten skips rows. Every
|
||||
* row with a cliloc is re-read, not just the unresolved ones, because an import
|
||||
* can also CHANGE a name — a shard overlay relabelling a stock item is the whole
|
||||
* reason overlays exist.
|
||||
*/
|
||||
async function listResolvableItems(after, limit) {
|
||||
return query(
|
||||
`SELECT id, cliloc, name, display_name
|
||||
FROM shard_vendor_items
|
||||
WHERE cliloc IS NOT NULL AND cliloc > 0 AND id > ?
|
||||
ORDER BY id
|
||||
LIMIT ?`,
|
||||
[after, limit],
|
||||
)
|
||||
}
|
||||
|
||||
/** Write back a batch of re-resolved display names. */
|
||||
async function updateDisplayNames(pairs) {
|
||||
if (pairs.length === 0) return 0
|
||||
const conn = await pool.getConnection()
|
||||
try {
|
||||
await conn.batch('UPDATE shard_vendor_items SET display_name = ? WHERE id = ?', pairs)
|
||||
return pairs.length
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceVendor,
|
||||
removeVendor,
|
||||
searchListings,
|
||||
getVendor,
|
||||
listVendorItems,
|
||||
meta,
|
||||
listPlaces,
|
||||
listResolvableItems,
|
||||
updateDisplayNames,
|
||||
likeTerm,
|
||||
}
|
||||
329
server/src/model/shardMarket/shardMarket.model.js
Normal file
329
server/src/model/shardMarket/shardMarket.model.js
Normal file
@@ -0,0 +1,329 @@
|
||||
// ── Player-vendor market index (Protocol 3.0 vendor.listing) ───────────────
|
||||
//
|
||||
// The shard-wide shop index: what every player vendor is selling, for how much,
|
||||
// and where it is standing. This is the website's half of the search the in-game
|
||||
// Vendor Search gump offers — the same data, the same opt-out, reachable without
|
||||
// logging in to the game.
|
||||
//
|
||||
// Ingest is per-vendor and authoritative: the shard's round-robin sweep emits one
|
||||
// `vendor.listing` frame per shop whose contents, prices or location moved, and
|
||||
// the frame is the whole shop (see docs/link/v3.md §8 and BridgeMarket.cs). This
|
||||
// module normalizes it into shard_vendors + shard_vendor_items and, crucially,
|
||||
// resolves each listing's cliloc to a DISPLAY NAME on the way in — a search for
|
||||
// "kryss" is a search over names, and the shard only ever sends numbers.
|
||||
|
||||
const db = require('./shardMarket.db')
|
||||
const clilocs = require('../shardClilocs/shardClilocs.model')
|
||||
const log = require('../../utils/logger')('shard-market')
|
||||
|
||||
// Defense in depth on top of the shard's own MarketMaxListings cap. The shard is
|
||||
// trusted, but it is a separately-versioned component: a frame from a plugin
|
||||
// whose cap was raised (or a shard running modified scripts) must not be able to
|
||||
// turn one ingest into an unbounded transaction.
|
||||
const MAX_ITEMS_PER_VENDOR = 5000
|
||||
|
||||
// Column widths in schema.sql. Truncating here rather than letting MariaDB do it
|
||||
// keeps the behavior the same in strict mode, where an over-length value is an
|
||||
// ERROR and would fail the whole vendor rather than shortening one name.
|
||||
const MAX_NAME = 160
|
||||
const MAX_SHOP = 160
|
||||
const MAX_OWNER = 64
|
||||
const MAX_MAP = 40
|
||||
const MAX_REGION = 80
|
||||
const MAX_SERIAL = 20
|
||||
|
||||
const clip = (value, max) => {
|
||||
if (value == null) return null
|
||||
const s = String(value)
|
||||
return s.length > max ? s.slice(0, max) : s
|
||||
}
|
||||
|
||||
const int = (value, fallback = 0) => {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||
}
|
||||
|
||||
// ── Ingest ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flatten one `vendor.listing` frame into the row shapes the DB layer wants.
|
||||
*
|
||||
* `location` arrives as a nested object rather than flat map/x/y/region, and that
|
||||
* shape is load-bearing rather than cosmetic: the visibility projection matches
|
||||
* literal JSON keys, so ONE `market.location` rule can hide a vendor's
|
||||
* whereabouts only if `location` is a single key on both the live frame and the
|
||||
* stored read model. Flattening it here for storage and re-nesting it on read is
|
||||
* what keeps that true on both paths.
|
||||
*
|
||||
* Exported for tests — it is the part with rules in it, and it is pure.
|
||||
*/
|
||||
function flattenFrame(ev) {
|
||||
const loc = (ev && ev.location) || {}
|
||||
return {
|
||||
serial: clip(ev.serial, MAX_SERIAL),
|
||||
shopName: clip(ev.shopName, MAX_SHOP),
|
||||
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
|
||||
ownerName: clip(ev.ownerName, MAX_OWNER),
|
||||
map: clip(loc.map, MAX_MAP),
|
||||
x: Number.isFinite(loc.x) ? Math.trunc(loc.x) : null,
|
||||
y: Number.isFinite(loc.y) ? Math.trunc(loc.y) : null,
|
||||
z: Number.isFinite(loc.z) ? Math.trunc(loc.z) : null,
|
||||
region: clip(loc.region, MAX_REGION),
|
||||
house: clip(loc.house, MAX_SHOP),
|
||||
// What the SHOP holds, which is not what the frame carries when it was
|
||||
// truncated. Kept apart so the page can say "showing 250 of 3,104" rather
|
||||
// than presenting a partial shop as a complete one.
|
||||
itemTotal: int(ev.total, int(ev.count, 0)),
|
||||
truncated: ev.truncated === true,
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each listing's display name.
|
||||
*
|
||||
* Order of preference is the item's own literal `name` first, then the cliloc.
|
||||
* That is the opposite of what "resolve the id" suggests and it is right: a
|
||||
* literal name only exists because a player set one ("Bob's vanquishing kryss"),
|
||||
* and it is strictly more specific than the generic cliloc the item still
|
||||
* carries.
|
||||
*
|
||||
* One batched lookup per frame rather than per item; `resolveMany` is cached and
|
||||
* never throws, so a cliloc table that is missing entirely just leaves
|
||||
* `displayName` null and the page renders item ids, exactly as it did before the
|
||||
* table existed.
|
||||
*/
|
||||
async function shapeItems(ev) {
|
||||
const raw = Array.isArray(ev.items) ? ev.items.slice(0, MAX_ITEMS_PER_VENDOR) : []
|
||||
|
||||
const wanted = raw
|
||||
.map((i) => int(i && i.cliloc, 0))
|
||||
.filter((n) => n > 0)
|
||||
|
||||
const names = await clilocs.resolveMany(wanted)
|
||||
|
||||
return raw
|
||||
.filter((i) => i && i.serial)
|
||||
.map((i) => {
|
||||
const literal = clip(i.name, MAX_NAME)
|
||||
const cliloc = int(i.cliloc, 0) || null
|
||||
return {
|
||||
serial: clip(i.serial, MAX_SERIAL),
|
||||
itemId: int(i.itemId, 0),
|
||||
hue: int(i.hue, 0),
|
||||
amount: int(i.amount, 1),
|
||||
price: int(i.price, 0),
|
||||
name: literal,
|
||||
cliloc,
|
||||
displayName: literal || (cliloc ? clip(names.get(cliloc) ?? null, MAX_NAME) : null),
|
||||
child: i.child === true,
|
||||
}
|
||||
})
|
||||
// Unpriced rows are inventory, not listings. The shard already drops them;
|
||||
// this is the same rule enforced where the table is written, so a plugin that
|
||||
// stops enforcing it cannot put un-buyable rows on the market page.
|
||||
.filter((i) => i.price > 0)
|
||||
}
|
||||
|
||||
/** Ingest one `vendor.listing` frame. */
|
||||
async function upsertVendor(ev) {
|
||||
if (!ev || !ev.serial) return
|
||||
const vendor = flattenFrame(ev)
|
||||
const items = await shapeItems(ev)
|
||||
await db.replaceVendor(vendor, items)
|
||||
}
|
||||
|
||||
/** Ingest one `vendor.listing.remove` frame. */
|
||||
async function removeVendor(serial) {
|
||||
if (!serial) return
|
||||
await db.removeVendor(String(serial).slice(0, MAX_SERIAL))
|
||||
}
|
||||
|
||||
// ── Read models ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `location` is re-nested (see flattenFrame) so the stored read model and the
|
||||
// live wire frame present the same keys to the visibility projection.
|
||||
|
||||
const place = (r) => ({
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
region: r.region,
|
||||
house: r.house,
|
||||
})
|
||||
|
||||
// A listing as the search returns it: the item, plus enough of its shop to be
|
||||
// actionable without a second request. `displayName` falls back to nothing rather
|
||||
// than to a fabricated "Item 3922" — the client decides how to render an
|
||||
// unresolved id, and inventing a name here would make it indistinguishable from
|
||||
// a real one.
|
||||
const shapeListing = (r) => ({
|
||||
serial: r.serial,
|
||||
itemId: r.item_id,
|
||||
hue: r.hue,
|
||||
amount: r.amount,
|
||||
price: Number(r.price),
|
||||
name: r.name,
|
||||
cliloc: r.cliloc,
|
||||
displayName: r.display_name,
|
||||
child: !!r.child,
|
||||
vendor: {
|
||||
serial: r.vendor_serial,
|
||||
shopName: r.shop_name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerName: r.owner_name,
|
||||
location: place(r),
|
||||
updatedAt: r.updated_at,
|
||||
},
|
||||
})
|
||||
|
||||
const shapeVendor = (r) => ({
|
||||
serial: r.serial,
|
||||
shopName: r.shop_name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerName: r.owner_name,
|
||||
location: place(r),
|
||||
count: r.item_count,
|
||||
total: r.item_total,
|
||||
truncated: !!r.truncated,
|
||||
updatedAt: r.updated_at,
|
||||
})
|
||||
|
||||
const shapeItem = (r) => ({
|
||||
serial: r.serial,
|
||||
itemId: r.item_id,
|
||||
hue: r.hue,
|
||||
amount: r.amount,
|
||||
price: Number(r.price),
|
||||
name: r.name,
|
||||
cliloc: r.cliloc,
|
||||
displayName: r.display_name,
|
||||
child: !!r.child,
|
||||
})
|
||||
|
||||
/**
|
||||
* Search the index. Returns a page of LISTINGS (not vendors) plus the
|
||||
* unpaginated total and the staleness stamp the page's banner needs.
|
||||
*/
|
||||
async function search({
|
||||
q = '',
|
||||
minPrice,
|
||||
maxPrice,
|
||||
itemId,
|
||||
map = '',
|
||||
region = '',
|
||||
sort = 'price_asc',
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
} = {}) {
|
||||
const { rows, total } = await db.searchListings({
|
||||
q: q.trim(),
|
||||
minPrice: Number.isFinite(minPrice) ? minPrice : undefined,
|
||||
maxPrice: Number.isFinite(maxPrice) ? maxPrice : undefined,
|
||||
itemId: Number.isFinite(itemId) ? itemId : undefined,
|
||||
map: map.trim(),
|
||||
region: region.trim(),
|
||||
sort,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
|
||||
const info = await db.meta()
|
||||
|
||||
return {
|
||||
listings: rows.map(shapeListing),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
// Repeated on every search response rather than left to a separate /meta
|
||||
// call: the banner that says how old these prices are must age with the
|
||||
// results it labels, and a client that fetched it once would keep showing a
|
||||
// stamp from before the page it is looking at.
|
||||
staleAt: info.staleAt,
|
||||
vendors: info.vendors,
|
||||
}
|
||||
}
|
||||
|
||||
/** One shop and its listings. `null` when the index has never seen that serial. */
|
||||
async function getVendor(serial, { limit = 250, offset = 0 } = {}) {
|
||||
const row = await db.getVendor(serial)
|
||||
if (!row) return null
|
||||
const items = await db.listVendorItems(serial, { limit, offset })
|
||||
return { ...shapeVendor(row), items: items.map(shapeItem) }
|
||||
}
|
||||
|
||||
/** Index size, staleness, and the facet/region filter options. */
|
||||
async function meta() {
|
||||
const [info, places] = await Promise.all([db.meta(), db.listPlaces()])
|
||||
return { ...info, ...places }
|
||||
}
|
||||
|
||||
// ── Cliloc re-resolution ───────────────────────────────────────────────────
|
||||
|
||||
// Batch size for the post-import pass. Big enough that a 40k-row table is ~40
|
||||
// round trips, small enough that a single batch is not a long-held connection.
|
||||
const RESOLVE_BATCH = 1000
|
||||
|
||||
/**
|
||||
* Re-resolve every listing's display name against the current cliloc table.
|
||||
*
|
||||
* Called after a cliloc import, and it has to be: the market's diff sweep will
|
||||
* NOT re-send an unchanged shop just because the site learned what its items are
|
||||
* called, so without this an operator who configures clilocs after the first
|
||||
* market sweep sees item ids until every shop happens to change. That is the same
|
||||
* class of staleness the spawn atlas avoids by re-parsing on boot — here the
|
||||
* source of truth for names moved, not the data.
|
||||
*
|
||||
* Never throws. It is a cosmetic backfill on a table that is already serving; a
|
||||
* failure means names stay as they were, which is exactly the pre-import state.
|
||||
*/
|
||||
async function refreshDisplayNames() {
|
||||
let after = 0
|
||||
let scanned = 0
|
||||
let changed = 0
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const rows = await db.listResolvableItems(after, RESOLVE_BATCH)
|
||||
if (rows.length === 0) break
|
||||
|
||||
after = rows[rows.length - 1].id
|
||||
scanned += rows.length
|
||||
|
||||
const names = await clilocs.resolveMany(rows.map((r) => Number(r.cliloc)))
|
||||
|
||||
const pairs = []
|
||||
for (const row of rows) {
|
||||
// The literal name still wins, so a re-resolution never overwrites a
|
||||
// player-set name with the generic cliloc behind it.
|
||||
const next = row.name
|
||||
? clip(row.name, MAX_NAME)
|
||||
: clip(names.get(Number(row.cliloc)) ?? null, MAX_NAME)
|
||||
if (next !== row.display_name) pairs.push([next, row.id])
|
||||
}
|
||||
|
||||
changed += await db.updateDisplayNames(pairs)
|
||||
}
|
||||
|
||||
if (changed > 0) log.info('market display names refreshed', { scanned, changed })
|
||||
return { scanned, changed }
|
||||
} catch (err) {
|
||||
log.warn('market display-name refresh failed', { message: err.message, scanned, changed })
|
||||
return { scanned, changed, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertVendor,
|
||||
removeVendor,
|
||||
search,
|
||||
getVendor,
|
||||
meta,
|
||||
refreshDisplayNames,
|
||||
flattenFrame,
|
||||
shapeItems,
|
||||
shapeListing,
|
||||
shapeVendor,
|
||||
MAX_ITEMS_PER_VENDOR,
|
||||
}
|
||||
@@ -278,6 +278,50 @@ async function getRuleset() {
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
|
||||
// One row per point system. The shard only emits a system whose top N actually
|
||||
// moved, so this is a sparse stream of overwrites; there is no delete, because
|
||||
// the shard's set of systems is fixed at startup.
|
||||
async function upsertPointsBoard({ system, name, nameCliloc, maxPoints, players, showOnGump, payload, t }) {
|
||||
await query(
|
||||
`INSERT INTO shard_points_boards
|
||||
(system, name, name_cliloc, max_points, players, show_on_gump, payload, t)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), name_cliloc = VALUES(name_cliloc),
|
||||
max_points = VALUES(max_points), players = VALUES(players),
|
||||
show_on_gump = VALUES(show_on_gump), payload = VALUES(payload), t = VALUES(t)`,
|
||||
[
|
||||
system,
|
||||
name ?? null,
|
||||
Number.isFinite(nameCliloc) ? nameCliloc : null,
|
||||
Number.isFinite(maxPoints) ? maxPoints : null,
|
||||
Number.isFinite(players) ? players : null,
|
||||
showOnGump ? 1 : 0,
|
||||
payload,
|
||||
Number.isFinite(t) ? t : null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
// Ordered by display name, falling back to the system key for a board whose name
|
||||
// arrived as a bare cliloc — otherwise every unresolved board would sort together
|
||||
// under NULL.
|
||||
async function listPointsBoards() {
|
||||
return query(
|
||||
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
|
||||
FROM shard_points_boards ORDER BY COALESCE(name, system), system`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getPointsBoard(system) {
|
||||
const rows = await query(
|
||||
`SELECT system, name, name_cliloc, max_points, players, show_on_gump, payload, t, updated_at
|
||||
FROM shard_points_boards WHERE system = ?`,
|
||||
[system],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
@@ -311,6 +355,9 @@ module.exports = {
|
||||
latestPresence,
|
||||
setRuleset,
|
||||
getRuleset,
|
||||
upsertPointsBoard,
|
||||
listPointsBoards,
|
||||
getPointsBoard,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
|
||||
@@ -537,6 +537,50 @@ async function getRuleset() {
|
||||
return { ...payload, updatedAt: r.updated_at }
|
||||
}
|
||||
|
||||
// ── Points/loyalty boards (Protocol 3.0 points.board) ──────────────────────
|
||||
//
|
||||
// The whole frame is stored in `payload`; the columns beside it are hoisted for
|
||||
// listing and ordering only. The top-N list deliberately stays inside the payload
|
||||
// (see schema.sql) — it is a fixed-size list read whole, like the governor board's
|
||||
// candidates.
|
||||
async function upsertPointsBoard(ev) {
|
||||
if (!ev || !ev.system) return
|
||||
await db.upsertPointsBoard({
|
||||
system: String(ev.system).slice(0, 48),
|
||||
name: ev.nameString ?? null,
|
||||
nameCliloc: ev.nameNumber,
|
||||
maxPoints: ev.maxPoints,
|
||||
players: ev.players,
|
||||
showOnGump: ev.showOnGump !== false,
|
||||
payload: JSON.stringify(ev),
|
||||
t: ev.t,
|
||||
})
|
||||
}
|
||||
|
||||
// A stored frame plus the freshness stamp. `top` is normalized to an array so a
|
||||
// caller never has to guard it — a board with nobody on it is a real state (a
|
||||
// system nobody has scored in yet), distinct from a system that was never
|
||||
// published at all, which is absent from the table entirely.
|
||||
function shapePointsBoard(r) {
|
||||
const payload = (typeof r.payload === 'string' ? safeJson(r.payload) : r.payload) || {}
|
||||
return {
|
||||
...payload,
|
||||
system: r.system,
|
||||
top: Array.isArray(payload.top) ? payload.top : [],
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function listPointsBoards() {
|
||||
const rows = await db.listPointsBoards()
|
||||
return rows.map(shapePointsBoard)
|
||||
}
|
||||
|
||||
async function getPointsBoard(system) {
|
||||
const r = await db.getPointsBoard(system)
|
||||
return r ? shapePointsBoard(r) : null
|
||||
}
|
||||
|
||||
function safeJson(s) {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
@@ -588,4 +632,7 @@ module.exports = {
|
||||
latestPresence,
|
||||
setRuleset,
|
||||
getRuleset,
|
||||
upsertPointsBoard,
|
||||
listPointsBoards,
|
||||
getPointsBoard,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
const db = require('./uoLinkConfig.db')
|
||||
const secretBox = require('../../utils/secretBox')
|
||||
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 1
|
||||
// The wire protocol this build speaks (link/sidecar/src/main.rs PROTOCOL_VERSION).
|
||||
// Only used before an admin has saved anything — the stored row wins once it exists,
|
||||
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 3
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) {
|
||||
|
||||
495
server/src/modules/loader.js
Normal file
495
server/src/modules/loader.js
Normal file
@@ -0,0 +1,495 @@
|
||||
// ── The module loader ──────────────────────────────────────────────────────
|
||||
//
|
||||
// SPIKE (docs/website/MODULE_SYSTEM.md §2.7 Phase 1). This is the smallest
|
||||
// loader that can carry /api/v1/public/atlas/* out of core and prove the
|
||||
// contract in docs/website/MODULE_API.md. Phase 2 rebuilds it properly with the
|
||||
// installed_modules table, the full state machine and the admin panel behind it.
|
||||
//
|
||||
// The one property this file exists to guarantee, and the reason it looks the
|
||||
// way it does:
|
||||
//
|
||||
// **The filesystem is the mounting source of truth, and mounting is
|
||||
// SYNCHRONOUS.** `scripts/routeManifest.js:38` and `swagger/swagger.js:29`
|
||||
// both require app.js with the pool pointed at a dead port. A loader that
|
||||
// awaited a database row before mounting would make every module route
|
||||
// invisible to the frozen-URL-surface test (§1.12). So: readdirSync at
|
||||
// require time, no database, no promises.
|
||||
//
|
||||
// A module that fails ANYWHERE in this file fails alone. Nothing here may throw
|
||||
// past its own try/catch — a bad module must cost the site its routes, never its
|
||||
// boot.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const { MODULE_API_VERSION } = require('./version')
|
||||
const semver = require('./semver')
|
||||
|
||||
const log = require('../utils/logger')('modules')
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..', '..', '..')
|
||||
const MODULES_DIR = process.env.MODULES_DIR || path.join(REPO_ROOT, 'modules')
|
||||
|
||||
// One segment, lowercase, no parameters. A module prefix that could contain a
|
||||
// `/` or a `:` would let a module reach outside the slot it was given.
|
||||
const ID = /^[a-z][a-z0-9-]{1,31}$/
|
||||
const PREFIX = /^\/[a-z0-9][a-z0-9-]*$/
|
||||
const TIERS = ['public', 'admin', 'player']
|
||||
|
||||
const MANIFEST_KEYS = new Set([
|
||||
'id', 'name', 'version', 'coreApi', 'server', 'client',
|
||||
'schema', 'purge', 'mounts', 'extensions', 'capabilities',
|
||||
])
|
||||
|
||||
// Prefixes core itself owns, per tier. A module may not take one of these.
|
||||
// Hardcoded for the spike; Phase 2 derives it from the tier mount tables so it
|
||||
// cannot drift the first time core adds a capability router.
|
||||
const CORE_PREFIXES = {
|
||||
public: ['/posts', '/wiki', '/pages', '/shard'],
|
||||
admin: [
|
||||
'/account', '/users', '/invites', '/auth', '/moderation', '/bot-activity',
|
||||
'/activity', '/posts', '/uploads', '/wiki', '/pages', '/shard', '/uo-link',
|
||||
'/email', '/discord-bot', '/settings',
|
||||
],
|
||||
player: ['/account', '/shard', '/appeals'],
|
||||
}
|
||||
|
||||
// id → record. Populated by scan(), read by mountInto/boot/shutdown/list.
|
||||
const modules = new Map()
|
||||
let scanned = false
|
||||
|
||||
// ── ctx ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Everything a module may reach in core, and nothing else (MODULE_API.md §2.3).
|
||||
// Required lazily inside the factory rather than at file scope: this module is
|
||||
// required by app.js, and hoisting these to the top would make the DB pool, the
|
||||
// settings model and the upload directory startup-time dependencies of the
|
||||
// loader itself.
|
||||
function buildCtx(id, moduleRoot) {
|
||||
/* eslint-disable global-require */
|
||||
// The shared SERVER dependencies — the exact counterpart of window.__rg's
|
||||
// react/react-dom/react-router on the client, and load-bearing for the same
|
||||
// two reasons.
|
||||
//
|
||||
// 1. A module lives at <repo>/modules/<id>/, OUTSIDE server/, so Node's
|
||||
// resolver walks up from there and never sees server/node_modules. A
|
||||
// module that required 'express' itself would fail to load — which is
|
||||
// exactly how this was discovered.
|
||||
// 2. Even if it resolved, a second copy of express in the process is a
|
||||
// second Router prototype and a second set of instanceof checks. One
|
||||
// express, owned by core, is the same rule as one React.
|
||||
//
|
||||
// The consequence for a module author is the same on both sides: declare these
|
||||
// external, never bundle them, take them from what core hands you.
|
||||
const express = require('express')
|
||||
const validator = require('express-validator')
|
||||
const db = require('../utils/db')
|
||||
const settings = require('../model/settings/settings.model')
|
||||
const posts = require('../model/posts/posts.model')
|
||||
const auth = require('../utils/auth')
|
||||
const pushDispatch = require('../utils/pushDispatch')
|
||||
const secretBox = require('../utils/secretBox')
|
||||
const createLogger = require('../utils/logger')
|
||||
const { requireAuth, requireRole } = require('../auth/session.middleware')
|
||||
const siteMode = require('../middleware/siteMode')
|
||||
const validate = require('../middleware/validate')
|
||||
const noindex = require('../middleware/noindex')
|
||||
const uploads = require('../router/v1/admin/imageUpload')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
// Narrowed on purpose (MODULE_API.md §2.3): utils/auth also re-exports
|
||||
// signToken/setAuthCookie/the TOTP challenge primitives, and minting a session
|
||||
// is core's job. A module that needs an identity needs to READ one.
|
||||
const ctx = {
|
||||
moduleId: id,
|
||||
paths: { moduleRoot },
|
||||
express,
|
||||
validator,
|
||||
db: { query: db.query, pool: db.pool },
|
||||
log: (namespace) => createLogger(namespace ? `${id}:${namespace}` : id),
|
||||
settings: {
|
||||
get: settings.get,
|
||||
set: settings.set,
|
||||
getInstanceName: settings.getInstanceName,
|
||||
},
|
||||
auth: { getUserFromRequest: auth.getUserFromRequest },
|
||||
push: { publish: pushDispatch.publish },
|
||||
secretBox: { encrypt: secretBox.encrypt, decrypt: secretBox.decrypt },
|
||||
middleware: { requireAuth, requireRole, siteMode, validate, noindex },
|
||||
uploads,
|
||||
posts: {
|
||||
listAll: posts.listAll,
|
||||
getById: posts.getById,
|
||||
linkAnnounceJob: posts.linkAnnounceJob,
|
||||
markAnnounced: posts.markAnnounced,
|
||||
},
|
||||
}
|
||||
// A guard against accident, not against a hostile module — the boundary is
|
||||
// organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
|
||||
for (const value of Object.values(ctx)) {
|
||||
if (value && typeof value === 'object') Object.freeze(value)
|
||||
}
|
||||
return Object.freeze(ctx)
|
||||
}
|
||||
|
||||
// ── The registration api ───────────────────────────────────────────────────
|
||||
|
||||
// Collects what the module registers so validation can compare it against what
|
||||
// module.json DECLARED. Declaration is the contract; a module that registers a
|
||||
// prefix it did not declare is rejected, because module.json is what the admin
|
||||
// panel, the collision check and the reviewer all read.
|
||||
function buildApi(record) {
|
||||
const once = (name) => {
|
||||
if (record.called.has(name)) throw new Error(`${name}() called twice`)
|
||||
record.called.add(name)
|
||||
}
|
||||
return {
|
||||
registerRoutes(mounts) {
|
||||
once('registerRoutes')
|
||||
if (!mounts || typeof mounts !== 'object') throw new Error('registerRoutes: expected an object')
|
||||
for (const [tier, byPrefix] of Object.entries(mounts)) {
|
||||
if (!TIERS.includes(tier)) throw new Error(`registerRoutes: unknown tier "${tier}"`)
|
||||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||
if (!PREFIX.test(prefix)) throw new Error(`registerRoutes: bad prefix "${prefix}"`)
|
||||
if (typeof router !== 'function') throw new Error(`registerRoutes: ${tier}${prefix} is not a router`)
|
||||
record.routes[tier].set(prefix, router)
|
||||
}
|
||||
}
|
||||
},
|
||||
// Declared for contract completeness; the spike registers none of these, and
|
||||
// an accepting no-op would let a module think it had registered something.
|
||||
registerExtension() { throw new Error('registerExtension: not implemented in the Phase 1 spike') },
|
||||
registerNotificationStreams() { throw new Error('registerNotificationStreams: not implemented in the Phase 1 spike') },
|
||||
registerAnnounceLeg() { throw new Error('registerAnnounceLeg: not implemented in the Phase 1 spike') },
|
||||
onBoot(fn) {
|
||||
once('onBoot')
|
||||
if (typeof fn !== 'function') throw new Error('onBoot: expected a function')
|
||||
record.onBoot = fn
|
||||
},
|
||||
onShutdown(fn) {
|
||||
once('onShutdown')
|
||||
if (typeof fn !== 'function') throw new Error('onShutdown: expected a function')
|
||||
record.onShutdown = fn
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Table names a module may create despite not carrying its own id as a prefix.
|
||||
//
|
||||
// module-uo's twenty-seven tables predate the module system by two years, and
|
||||
// renaming live tables is a data migration this workstream deliberately does not
|
||||
// do (MODULE_SYSTEM.md §1.6). Grandfathering them by an explicit, per-module
|
||||
// allowlist keeps the prefix rule real for every module written after this one —
|
||||
// the alternative, dropping the rule, would leave the first name collision to be
|
||||
// discovered by a module silently adopting someone else's table.
|
||||
const LEGACY_TABLE_PREFIXES = { uo: ['shard_', 'uo_link_'] }
|
||||
|
||||
const CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?(\w+)[`"]?/gi
|
||||
|
||||
/** Table names core's own schema.sql declares — a module may not touch these. */
|
||||
let coreTables = null
|
||||
function coreTableNames() {
|
||||
if (coreTables) return coreTables
|
||||
coreTables = new Set()
|
||||
try {
|
||||
const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8')
|
||||
for (const m of sql.matchAll(CREATE_TABLE)) coreTables.add(m[1].toLowerCase())
|
||||
} catch (err) {
|
||||
log.warn('could not read core schema for the table-collision check', { message: err.message })
|
||||
}
|
||||
return coreTables
|
||||
}
|
||||
|
||||
function checkTableNames(dir, manifest) {
|
||||
const file = path.join(dir, manifest.schema)
|
||||
const sql = fs.readFileSync(file, 'utf8')
|
||||
const allowed = LEGACY_TABLE_PREFIXES[manifest.id] || []
|
||||
const core = coreTableNames()
|
||||
|
||||
for (const m of sql.matchAll(CREATE_TABLE)) {
|
||||
const table = m[1].toLowerCase()
|
||||
if (core.has(table)) throw new Error(`schema fragment declares core table "${table}"`)
|
||||
for (const other of modules.values()) {
|
||||
if (other.tables && other.tables.has(table)) {
|
||||
throw new Error(`schema fragment declares "${table}", already owned by module "${other.id}"`)
|
||||
}
|
||||
}
|
||||
const prefixed = table.startsWith(`${manifest.id}_`) || allowed.some((p) => table.startsWith(p))
|
||||
if (!prefixed) {
|
||||
throw new Error(`schema fragment table "${table}" is not prefixed "${manifest.id}_"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Record which tables a module owns, so the next module can be checked against it. */
|
||||
function tablesOf(dir, manifest) {
|
||||
if (!manifest.schema) return new Set()
|
||||
const sql = fs.readFileSync(path.join(dir, manifest.schema), 'utf8')
|
||||
return new Set([...sql.matchAll(CREATE_TABLE)].map((m) => m[1].toLowerCase()))
|
||||
}
|
||||
|
||||
function readManifest(dir, id) {
|
||||
const file = path.join(dir, 'module.json')
|
||||
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
|
||||
for (const key of Object.keys(manifest)) {
|
||||
// Rejected, not ignored: a typo'd key must be a loud failure rather than a
|
||||
// silently inert setting the operator believes they configured.
|
||||
if (!MANIFEST_KEYS.has(key)) throw new Error(`unknown key "${key}" in module.json`)
|
||||
}
|
||||
if (!ID.test(manifest.id || '')) throw new Error(`invalid id "${manifest.id}"`)
|
||||
if (manifest.id !== id) throw new Error(`id "${manifest.id}" does not match directory "${id}"`)
|
||||
if (!manifest.version) throw new Error('missing version')
|
||||
if (!manifest.coreApi) throw new Error('missing coreApi')
|
||||
if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) {
|
||||
throw new Error(`needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
|
||||
}
|
||||
if (manifest.schema && !manifest.purge) {
|
||||
// A module that can create tables and cannot drop them leaves an operator
|
||||
// with orphaned data and no supported way to remove it.
|
||||
throw new Error('declares schema but no purge')
|
||||
}
|
||||
if (manifest.schema) checkTableNames(dir, manifest)
|
||||
for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
|
||||
if (!TIERS.includes(tier)) throw new Error(`unknown tier "${tier}" in mounts`)
|
||||
for (const prefix of prefixes) {
|
||||
if (!PREFIX.test(prefix)) throw new Error(`bad prefix "${prefix}" in mounts.${tier}`)
|
||||
if (CORE_PREFIXES[tier].includes(prefix)) throw new Error(`prefix ${tier}${prefix} is owned by core`)
|
||||
for (const other of modules.values()) {
|
||||
if ((other.manifest.mounts?.[tier] || []).includes(prefix)) {
|
||||
throw new Error(`prefix ${tier}${prefix} already registered by module "${other.id}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
// What the module registered must equal what it declared — in both directions.
|
||||
function checkDeclared(record) {
|
||||
const declared = record.manifest.mounts || {}
|
||||
for (const tier of TIERS) {
|
||||
const want = new Set(declared[tier] || [])
|
||||
const got = new Set(record.routes[tier].keys())
|
||||
for (const p of got) if (!want.has(p)) throw new Error(`registered ${tier}${p} without declaring it`)
|
||||
for (const p of want) if (!got.has(p)) throw new Error(`declared ${tier}${p} but never registered it`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scan ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Discover, validate and register every module under MODULES_DIR. Synchronous,
|
||||
* filesystem-only, and safe to call when the directory does not exist. Called
|
||||
* once from app.js at require time; a second call is a no-op.
|
||||
*/
|
||||
function scan() {
|
||||
if (scanned) return
|
||||
scanned = true
|
||||
|
||||
let entries = []
|
||||
try {
|
||||
entries = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.sort() // alphabetical: there is no dependency resolution, and any other
|
||||
// order would imply a precedence nothing computes (§4.2)
|
||||
} catch {
|
||||
return // no modules directory is the normal case for a bare core
|
||||
}
|
||||
|
||||
for (const id of entries) {
|
||||
const dir = path.join(MODULES_DIR, id)
|
||||
if (!fs.existsSync(path.join(dir, 'module.json'))) continue
|
||||
|
||||
const record = {
|
||||
id,
|
||||
dir,
|
||||
manifest: null,
|
||||
routes: { public: new Map(), admin: new Map(), player: new Map() },
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
onBoot: null,
|
||||
onShutdown: null,
|
||||
state: 'installed',
|
||||
reason: null,
|
||||
}
|
||||
|
||||
try {
|
||||
record.manifest = readManifest(dir, id)
|
||||
record.tables = tablesOf(dir, record.manifest)
|
||||
if (record.manifest.server) {
|
||||
const entry = path.join(dir, record.manifest.server)
|
||||
// eslint-disable-next-line global-require, import/no-dynamic-require
|
||||
const register = require(entry)
|
||||
if (typeof register !== 'function') throw new Error(`${record.manifest.server} does not export a function`)
|
||||
register(buildCtx(id, dir), buildApi(record))
|
||||
checkDeclared(record)
|
||||
}
|
||||
record.state = 'registered'
|
||||
modules.set(id, record)
|
||||
log.info(`registered module "${id}" v${record.manifest.version}`, {
|
||||
mounts: record.manifest.mounts,
|
||||
})
|
||||
} catch (err) {
|
||||
// A failure here is BEFORE any route was mounted, so this module's routes
|
||||
// and nav are simply absent and the site comes up without it (§4.4).
|
||||
record.state = 'startup_failed'
|
||||
record.reason = err.message
|
||||
record.manifest = record.manifest || { id, version: 'unknown' }
|
||||
modules.set(id, record)
|
||||
log.error(`module "${id}" failed to load — continuing without it`, { reason: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mounting ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mount every registered module's routers for one tier onto that tier's router.
|
||||
* Called from router/v1/{public,admin,player}/index.js, after core's own mounts
|
||||
* so a module can never shadow a core prefix even if the collision check above
|
||||
* were somehow bypassed.
|
||||
*/
|
||||
function mountInto(tier, tierRouter) {
|
||||
scan()
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered' && record.state !== 'started') continue
|
||||
for (const [prefix, router] of record.routes[tier]) {
|
||||
// The dispatch guard. A module that failed AFTER mounting (schema replay,
|
||||
// onBoot) keeps its URLs — so routes.manifest.json does not depend on
|
||||
// whether a boot hook happened to succeed on the generating machine — but
|
||||
// answers 503 rather than serving half-initialised data (§4.4).
|
||||
tierRouter.use(prefix, (req, res, next) => {
|
||||
if (record.state === 'startup_failed') {
|
||||
return res.status(503).json({ message: 'Module unavailable' })
|
||||
}
|
||||
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
|
||||
return next()
|
||||
}, router)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Read every registered module's schema fragment, in scan order. */
|
||||
function schemaFragments() {
|
||||
scan()
|
||||
const out = []
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered' || !record.manifest.schema) continue
|
||||
const file = path.join(record.dir, record.manifest.schema)
|
||||
try {
|
||||
out.push({ id: record.id, sql: fs.readFileSync(file, 'utf8') })
|
||||
} catch (err) {
|
||||
markFailed(record.id, `schema fragment unreadable: ${err.message}`)
|
||||
log.error(`module "${record.id}" schema fragment unreadable`, { reason: err.message })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a module to `startup_failed` with a reason. Called by whoever ran the
|
||||
* step that failed — ensureSchema() replays the fragments, so it is the only
|
||||
* thing that can know a fragment threw.
|
||||
*
|
||||
* Failing here is a POST-mount failure: the routes stay mounted and the dispatch
|
||||
* guard turns them into 503s, which is what keeps routes.manifest.json
|
||||
* independent of whether a boot step succeeded on the generating machine (§4.4).
|
||||
*/
|
||||
function markFailed(id, reason) {
|
||||
const record = modules.get(id)
|
||||
if (!record) return
|
||||
record.state = 'startup_failed'
|
||||
record.reason = reason
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every registered module's onBoot. Called from server.js AFTER
|
||||
* ensureSchema() and seedDefaults() (so a module's own tables exist) and BEFORE
|
||||
* the listener binds. Individually try/caught: a hook that throws costs that
|
||||
* module its `started` state and nothing else.
|
||||
*/
|
||||
async function boot() {
|
||||
scan()
|
||||
for (const record of modules.values()) {
|
||||
if (record.state !== 'registered') continue
|
||||
try {
|
||||
if (record.onBoot) await record.onBoot(buildCtx(record.id, record.dir))
|
||||
record.state = 'started'
|
||||
log.info(`module "${record.id}" started`)
|
||||
} catch (err) {
|
||||
record.state = 'startup_failed'
|
||||
record.reason = `onBoot: ${err.message}`
|
||||
log.error(`module "${record.id}" onBoot failed — its routes will answer 503`, {
|
||||
reason: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SHUTDOWN_BUDGET_MS = 5000
|
||||
|
||||
/** Run onShutdown in reverse registration order, bounded, never throwing. */
|
||||
async function shutdown() {
|
||||
const records = [...modules.values()].reverse()
|
||||
for (const record of records) {
|
||||
if (record.state !== 'started' || !record.onShutdown) continue
|
||||
try {
|
||||
await Promise.race([
|
||||
record.onShutdown(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('timed out')), SHUTDOWN_BUDGET_MS).unref()),
|
||||
])
|
||||
} catch (err) {
|
||||
log.warn(`module "${record.id}" onShutdown failed`, { reason: err.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Introspection ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What GET /api/v1/public/modules, the HTML shell and the admin panel read.
|
||||
*
|
||||
* `clientDir` and `entryUrl` are split deliberately: app.js needs the absolute
|
||||
* directory to serve statically, and it must be the DIST directory rather than
|
||||
* the module root — a module keeps its server source, its module.json and its
|
||||
* schema fragment alongside the client build, and one static mount over the
|
||||
* module root would publish all of them.
|
||||
*/
|
||||
function list() {
|
||||
scan()
|
||||
return [...modules.values()].map((r) => {
|
||||
const entry = r.manifest.client && r.manifest.client.entry
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.manifest.name || r.id,
|
||||
version: r.manifest.version,
|
||||
state: r.state,
|
||||
reason: r.reason,
|
||||
capabilities: r.manifest.capabilities || [],
|
||||
// e.g. entry "client/dist/entry.js" → dir <root>/client/dist, url /modules/uo/entry.js
|
||||
clientDir: entry ? path.join(r.dir, path.dirname(entry)) : null,
|
||||
entryUrl: entry ? `/modules/${r.id}/${path.basename(entry)}` : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Absolute path of the modules directory. */
|
||||
const dir = () => MODULES_DIR
|
||||
|
||||
// Test seam: the scan is memoised, and a test that points MODULES_DIR somewhere
|
||||
// else needs to be able to redo it.
|
||||
function _reset() {
|
||||
modules.clear()
|
||||
scanned = false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scan, mountInto, schemaFragments, markFailed, boot, shutdown, list, dir, _reset, MODULES_DIR,
|
||||
}
|
||||
47
server/src/modules/semver.js
Normal file
47
server/src/modules/semver.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// A deliberately tiny semver range check — enough for `coreApi` and no more.
|
||||
//
|
||||
// Supports `*`, an exact `x.y.z`, `^x.y.z` and `~x.y.z`. That is the whole
|
||||
// grammar a module manifest is allowed to use (MODULE_API.md §1.1), so pulling
|
||||
// in the `semver` package for it would add a dependency to the server for a
|
||||
// twenty-line job. A range this parser does not understand is REJECTED rather
|
||||
// than assumed to match — an unparseable range must not silently load a module
|
||||
// against an API it was never tested on.
|
||||
|
||||
const PARTS = /^(\d+)\.(\d+)\.(\d+)$/
|
||||
|
||||
function parse(version) {
|
||||
const m = PARTS.exec(String(version).trim())
|
||||
if (!m) return null
|
||||
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }
|
||||
}
|
||||
|
||||
const gte = (a, b) => {
|
||||
if (a.major !== b.major) return a.major > b.major
|
||||
if (a.minor !== b.minor) return a.minor > b.minor
|
||||
return a.patch >= b.patch
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `version` satisfy `range`?
|
||||
* @param {string} version an exact x.y.z
|
||||
* @param {string} range `*` | `x.y.z` | `^x.y.z` | `~x.y.z`
|
||||
* @returns {boolean} false for anything unparseable, on either side
|
||||
*/
|
||||
function satisfies(version, range) {
|
||||
const v = parse(version)
|
||||
if (!v) return false
|
||||
const raw = String(range).trim()
|
||||
if (raw === '*') return true
|
||||
|
||||
const op = raw[0] === '^' || raw[0] === '~' ? raw[0] : ''
|
||||
const b = parse(op ? raw.slice(1) : raw)
|
||||
if (!b) return false
|
||||
|
||||
if (op === '') return v.major === b.major && v.minor === b.minor && v.patch === b.patch
|
||||
if (!gte(v, b)) return false
|
||||
// ^ allows minor+patch within the same major; ~ allows patch within the same minor.
|
||||
if (op === '^') return v.major === b.major
|
||||
return v.major === b.major && v.minor === b.minor
|
||||
}
|
||||
|
||||
module.exports = { satisfies, parse }
|
||||
14
server/src/modules/version.js
Normal file
14
server/src/modules/version.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// The module API version — the single number a module's `coreApi` range is
|
||||
// checked against (docs/website/MODULE_API.md §1.1).
|
||||
//
|
||||
// Bump minor when a member is ADDED to ctx or a new register* call appears;
|
||||
// major when one is removed, its signature changes, or its behaviour changes
|
||||
// without a signature change. A core-internal refactor behind an unchanged
|
||||
// member is not a bump.
|
||||
//
|
||||
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
|
||||
// has nothing to say about a website module) and from any module's own version.
|
||||
|
||||
const MODULE_API_VERSION = '1.0.0'
|
||||
|
||||
module.exports = { MODULE_API_VERSION }
|
||||
@@ -1,3 +1,5 @@
|
||||
const fs = require('fs')
|
||||
|
||||
const posts = require('../../../model/posts/posts.model')
|
||||
const wiki = require('../../../model/wiki/wiki.model')
|
||||
const settings = require('../../../model/settings/settings.model')
|
||||
@@ -9,6 +11,11 @@ const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
||||
const newsGump = require('../../../utils/newsGump')
|
||||
const pushDispatch = require('../../../utils/pushDispatch')
|
||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
||||
const { validateThemeVisual } = require('../../../utils/themeResolve')
|
||||
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
|
||||
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../../../utils/navOverrides')
|
||||
const htmlShell = require('../../../utils/htmlShell')
|
||||
|
||||
const log = require('../../../utils/logger')('admin')
|
||||
|
||||
@@ -529,8 +536,61 @@ async function updateSettings(req, res) {
|
||||
if (typeof updates.homepage_teaser === 'string') {
|
||||
updates.homepage_teaser = cleanBody(updates.homepage_teaser)
|
||||
}
|
||||
// theme_visual is JSON whose values become CSS custom properties, so every
|
||||
// one has to come from the closed sets in config/themePresets.js. The read
|
||||
// path drops anything invalid anyway (THEMING_AND_NAV.md §4.4), but silently
|
||||
// storing a value that will never apply is a bad admin experience — reject it
|
||||
// with the offending field named instead. Accepts an object or the stringified
|
||||
// form, and stores it stringified either way, since settings.value is TEXT.
|
||||
if ('theme_visual' in updates) {
|
||||
const raw = updates.theme_visual
|
||||
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||
if (typeof raw === 'string' && parsed === null) {
|
||||
return res.status(400).json({ message: 'theme_visual must be a JSON object' })
|
||||
}
|
||||
const check = validateThemeVisual(parsed)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates.theme_visual = JSON.stringify(parsed)
|
||||
}
|
||||
// brand_assets holds the only settings values written straight into HTML the
|
||||
// browser then fetches (an <img src>, a <link rel="icon">, an og:image), so
|
||||
// the accepted shape is narrow — see utils/brandAssets.js. Cleared slots are
|
||||
// dropped rather than stored as null, keeping "a field is absent" the single
|
||||
// meaning of "falls back to BRAND_* env".
|
||||
if ('brand_assets' in updates) {
|
||||
const raw = updates.brand_assets
|
||||
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||
if (typeof raw === 'string' && parsed === null) {
|
||||
return res.status(400).json({ message: 'brand_assets must be a JSON object' })
|
||||
}
|
||||
const check = validateBrandAssets(parsed)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates.brand_assets = JSON.stringify(resolveBrandAssets(parsed))
|
||||
}
|
||||
// The three nav rows are JSON too, and without this they would reach
|
||||
// settingsDb.set as objects and be stored as the string "[object Object]".
|
||||
// Shape only — whether a key names a route the nav actually declares is the
|
||||
// client's question, and utils/navOverrides.js says why. Resolved on the way
|
||||
// in so the stored row carries no dead fields, and so `hidden` can never land
|
||||
// on the nav editor's own row.
|
||||
for (const key of NAV_KEYS) {
|
||||
if (!(key in updates)) continue
|
||||
const raw = updates[key]
|
||||
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||
if (typeof raw === 'string' && parsed === null) {
|
||||
return res.status(400).json({ message: `${key} must be a JSON object` })
|
||||
}
|
||||
const check = validateNavOverrides(parsed, key)
|
||||
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||
updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
|
||||
}
|
||||
try {
|
||||
await settings.setMany(updates, req.user.id)
|
||||
// The HTML shell is templated from brand_assets and theme_visual, and is
|
||||
// cached per process (utils/htmlShell.js) — a write that can change it has
|
||||
// to say so, or the favicon an admin just uploaded appears only after the
|
||||
// cache's TTL.
|
||||
if ('brand_assets' in updates || 'theme_visual' in updates) htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
|
||||
return res.json(await settings.getAll())
|
||||
} catch (err) {
|
||||
@@ -539,6 +599,115 @@ async function updateSettings(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Brand assets ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Per-slot rules applied on top of the shared multer allowlist. The allowlist
|
||||
// itself is never widened (§9: "no second upload path with weaker validation") —
|
||||
// these only ever tighten it:
|
||||
//
|
||||
// • favicon — PNG only. .ico would mean adding a new type to MIME_EXT, and the
|
||||
// fact that the stored extension comes from that map is exactly what makes
|
||||
// the upload path safe (§4.10). Every browser this app supports takes a PNG
|
||||
// icon. Small cap: a favicon is a handful of KB.
|
||||
// • logo — a header mark next to the site title, not a page image.
|
||||
// • hero — a full-bleed background, so it keeps the shared ceiling.
|
||||
//
|
||||
// The cap is checked after multer has written the file rather than by a second
|
||||
// multer instance: one upload config, one allowlist, and the oversized file is
|
||||
// unlinked before we answer.
|
||||
const ASSET_RULES = {
|
||||
logo: { maxBytes: 1024 * 1024, mimetypes: null, label: 'Logo' },
|
||||
hero: { maxBytes: 8 * 1024 * 1024, mimetypes: null, label: 'Hero image' },
|
||||
favicon: { maxBytes: 512 * 1024, mimetypes: ['image/png'], label: 'Favicon' },
|
||||
}
|
||||
|
||||
const prettyBytes = (n) => (n >= 1024 * 1024 ? `${Math.round(n / (1024 * 1024))} MB` : `${Math.round(n / 1024)} KB`)
|
||||
|
||||
// Best-effort cleanup of a file we have decided not to keep. A failure here is
|
||||
// a stray file in /uploads, not something the caller can act on.
|
||||
async function discardUpload(file) {
|
||||
try {
|
||||
await fs.promises.unlink(file.path)
|
||||
} catch (err) {
|
||||
log.error('discardUpload', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/settings/brand-asset/:slot — upload one brand asset and point the
|
||||
* brand_assets row at it in the same call.
|
||||
*
|
||||
* One call rather than "upload, then PUT the settings row": a half-completed
|
||||
* save would otherwise leave a file in /uploads that nothing references, and the
|
||||
* per-slot rules above need the slot at upload time anyway. Admin-only, matching
|
||||
* the gate on the settings it writes — POST /admin/uploads is reachable by
|
||||
* editors, who have no business changing the site's identity.
|
||||
*/
|
||||
async function uploadBrandAsset(req, res) {
|
||||
const { slot } = req.params
|
||||
const rules = ASSET_RULES[slot]
|
||||
if (!rules) {
|
||||
if (req.file) await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `Unknown brand asset '${slot}'` })
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
|
||||
if (rules.mimetypes && !rules.mimetypes.includes(req.file.mimetype)) {
|
||||
await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `${rules.label} must be a PNG image` })
|
||||
}
|
||||
if (req.file.size > rules.maxBytes) {
|
||||
await discardUpload(req.file)
|
||||
return res.status(400).json({ message: `${rules.label} must be ${prettyBytes(rules.maxBytes)} or smaller` })
|
||||
}
|
||||
|
||||
const url = `/uploads/${req.file.filename}`
|
||||
try {
|
||||
// Read-modify-write the row: uploading a logo must not clear a hero the
|
||||
// admin set earlier (§6.3). Resolved on the way in, so a hand-edited row
|
||||
// with one bad slot does not block setting another.
|
||||
const current = resolveBrandAssets(parseJsonSetting(await settings.get('brand_assets')))
|
||||
const next = { ...current, [slot]: url }
|
||||
await settings.set('brand_assets', JSON.stringify(next), req.user.id)
|
||||
htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.brandAsset', detail: { slot, url } })
|
||||
return res.status(201).json({ url, brand_assets: next })
|
||||
} catch (err) {
|
||||
log.error('uploadBrandAsset', err)
|
||||
// The row is the point of the call; a stored file nothing points at is
|
||||
// litter, so it goes back out with the error.
|
||||
await discardUpload(req.file)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Delete one settings row — the "reset to defaults" primitive.
|
||||
//
|
||||
// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the
|
||||
// hardcoded NAV arrays; the *absence* of the row is what selects them
|
||||
// (docs/website/THEMING_AND_NAV.md §2). Resetting therefore has to delete, not
|
||||
// store a copy of the defaults, or the next change to a default would not reach
|
||||
// an instance that had ever pressed reset.
|
||||
//
|
||||
// The key allowlist is the point of the route: an unrestricted DELETE would let
|
||||
// a stray request drop site_mode or the uo-link config, where absence means
|
||||
// something else entirely. Deleting a key that is not set succeeds — reset is
|
||||
// idempotent and the UI should not have to know whether a row exists.
|
||||
async function deleteSetting(req, res) {
|
||||
const { key } = req.params
|
||||
if (!settings.DELETABLE_KEYS.includes(key)) {
|
||||
return res.status(400).json({ message: 'Setting is not resettable' })
|
||||
}
|
||||
try {
|
||||
await settings.remove(key)
|
||||
if (key === 'brand_assets' || key === 'theme_visual') htmlShell.invalidate()
|
||||
await activity.log({ req, action: 'settings.reset', detail: { key } })
|
||||
return res.json({ message: 'Setting reset to default' })
|
||||
} catch (err) {
|
||||
log.error('deleteSetting', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activity log ──────────────────────────────────────────────────────
|
||||
async function listActivity(req, res) {
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200)
|
||||
@@ -764,6 +933,9 @@ module.exports = {
|
||||
deleteWikiCategory,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
deleteSetting,
|
||||
uploadBrandAsset,
|
||||
ASSET_RULES,
|
||||
listActivity,
|
||||
listUsers,
|
||||
createUser,
|
||||
|
||||
@@ -15,6 +15,7 @@ const express = require('express')
|
||||
|
||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||
const noindex = require('../../../middleware/noindex')
|
||||
const modules = require('../../../modules/loader')
|
||||
|
||||
const accountRouter = require('./account.router')
|
||||
const usersRouter = require('./users.router')
|
||||
@@ -74,6 +75,11 @@ adminRouter.use('/email', emailRouter)
|
||||
adminRouter.use('/discord-bot', discordBotRouter)
|
||||
adminRouter.use('/settings', settingsRouter)
|
||||
|
||||
// Installed modules' admin routers. Already behind this group's
|
||||
// noindex/isLoggedIn/staffOnly gate — a module adds per-route gates on top and
|
||||
// never re-implements the tier gate (docs/website/MODULE_API.md §2.4).
|
||||
modules.mountInto('admin', adminRouter)
|
||||
|
||||
// The two singletons that own no path segment of their own: GET /dashboard and
|
||||
// PUT /site-mode. Mounted at the group root, last, exactly where the residual
|
||||
// admin.routes.js used to sit — safe because dashboard.router.js declares no
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./admin.controller')
|
||||
const { upload } = require('./imageUpload')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
|
||||
const settingsRouter = express.Router()
|
||||
@@ -33,6 +34,7 @@ settingsRouter.put(
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Update site settings (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.description = 'Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
@@ -41,5 +43,42 @@ settingsRouter.put(
|
||||
adminOnly,
|
||||
ctrl.updateSettings,
|
||||
)
|
||||
// Upload one brand asset (logo/hero/favicon) and point brand_assets at it in the
|
||||
// same call — see the controller for why it is one call and not "upload, then
|
||||
// PUT". Uses the shared multer config (one upload directory, one mimetype
|
||||
// allowlist); the per-slot PNG rule and size caps are applied in the handler.
|
||||
settingsRouter.post(
|
||||
'/brand-asset/:slot',
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Upload a brand asset and set it as the override (admin only)'
|
||||
// #swagger.description = 'Stores the image and writes the brand_assets settings row in one call, so an upload never leaves an unreferenced file. Favicons must be PNG (max 512 KB); logos max 1 MB; heroes max 8 MB. Absent slots keep falling back to the BRAND_* env defaults — uploading a logo does not clear a hero.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.parameters['slot'] = { in: 'path', required: true, description: 'Which asset to replace', schema: { type: 'string', enum: ['logo', 'hero', 'favicon'] } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Stored file URL and the updated overrides', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string", example: "/uploads/1712345678901-ab12cd34.png" }, brand_assets: { type: "object", properties: { logo: { type: "string" }, hero: { type: "string" }, favicon: { type: "string" } } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'No file, unknown slot, disallowed type, or over the slot size cap', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
upload.single('image'),
|
||||
ctrl.uploadBrandAsset,
|
||||
)
|
||||
// Reset one setting to its default by deleting the row. Only the keys whose
|
||||
// default lives outside the store (theming, nav, hero draft) are deletable —
|
||||
// the controller holds the allowlist.
|
||||
settingsRouter.delete(
|
||||
'/:key',
|
||||
// #swagger.tags = ['Admin · Settings']
|
||||
// #swagger.summary = 'Reset one setting to its default (admin only)'
|
||||
// #swagger.description = 'Deletes the settings row so the surface falls back to its BRAND_* env / theme.css / hardcoded default. Restricted to the resettable keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft). Idempotent: resetting a key that was never set succeeds.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.parameters['key'] = { in: 'path', required: true, description: 'Settings key to reset', schema: { type: 'string' } } */
|
||||
/* #swagger.responses[200] = { description: 'Setting reset', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Setting is not resettable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
ctrl.deleteSetting,
|
||||
)
|
||||
|
||||
module.exports = settingsRouter
|
||||
|
||||
@@ -24,6 +24,8 @@ const { body, param } = require('express-validator')
|
||||
|
||||
const shardOps = require('./shardOps.controller')
|
||||
const shardVisibility = require('./shardVisibility.controller')
|
||||
const shardAtlas = require('./shardAtlas.controller')
|
||||
const shardClilocs = require('./shardClilocs.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const { requireRole } = require('../../../utils/auth')
|
||||
const validate = require('../../../middleware/validate')
|
||||
@@ -235,6 +237,123 @@ shardRouter.get(
|
||||
shardOps.listHouses,
|
||||
)
|
||||
|
||||
// ── Spawn atlas (admin only) ──────────────────────────────────────────
|
||||
// Operating the atlas import. Admin-only rather than moderator: it reads a path
|
||||
// on the server's filesystem and replaces every atlas table, which is closer to
|
||||
// a deploy action than to moderation.
|
||||
//
|
||||
// These routes sit under /admin/shard even though the public ones deliberately
|
||||
// do NOT sit under /public/shard. That is not an inconsistency: the public split
|
||||
// says "this data does not come from the sidecar", while the admin panel is
|
||||
// simply part of shard administration and belongs beside the rest of it.
|
||||
shardRouter.get(
|
||||
'/atlas',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
|
||||
// #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardAtlas.getStatus,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/atlas/import',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Re-import the spawn atlas from the ServUO tree (admin only)'
|
||||
// #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
body('force').optional().isBoolean(),
|
||||
validate,
|
||||
shardAtlas.importAtlas,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/atlas/approve',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)'
|
||||
// #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
shardAtlas.approve,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/atlas/reject',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Reject a staged atlas refresh (admin only)'
|
||||
// #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardAtlas.reject,
|
||||
)
|
||||
shardRouter.put(
|
||||
'/atlas/path',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Set the ServUO tree the atlas reads from (admin only)'
|
||||
// #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||
adminOnly,
|
||||
body('path').isString().isLength({ max: 512 }),
|
||||
validate,
|
||||
shardAtlas.setPath,
|
||||
)
|
||||
|
||||
// ── Cliloc table (admin only) ─────────────────────────────────────────────
|
||||
// UO's id → display-string map, converted once by the operator from their own
|
||||
// client (docs/website/CLILOCS.md). Sits beside the atlas for the same reason:
|
||||
// it is static content derived from operator-supplied files rather than anything
|
||||
// the sidecar sends, and operating it is shard administration.
|
||||
//
|
||||
// There is deliberately NO public counterpart. The table is never served as a
|
||||
// table — 123k rows would dwarf any page that used it, and the Android client
|
||||
// consumes the same already-resolved JSON. Names are applied server-side to the
|
||||
// responses that need them.
|
||||
shardRouter.get(
|
||||
'/clilocs',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)'
|
||||
// #swagger.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardClilocs.getStatus,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/clilocs/import',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Re-import the cliloc table from its source files (admin only)'
|
||||
// #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
body('force').optional().isBoolean(),
|
||||
body('approve').optional().isBoolean(),
|
||||
validate,
|
||||
shardClilocs.importClilocs,
|
||||
)
|
||||
shardRouter.put(
|
||||
'/clilocs/path',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Set the cliloc source the site reads from (admin only)'
|
||||
// #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||
adminOnly,
|
||||
body('path').isString().isLength({ max: 512 }),
|
||||
validate,
|
||||
shardClilocs.setPath,
|
||||
)
|
||||
|
||||
// ── Feature visibility (admin only) ───────────────────────────────────
|
||||
// Who can see which shard surface, and which sensitive fields within it. This
|
||||
// decides what ANONYMOUS visitors get, so it sits above the moderator tier.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user