Compare commits
41 Commits
a3407ae654
...
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 | |||
| f3d084e046 | |||
| a4ef9d676d | |||
| 2801ec8f4d | |||
| 353cce9f26 | |||
| 7b98f1a778 | |||
| 61d6bfaca2 | |||
| 6b1396dd2f | |||
| f30ea66fce | |||
| cd56af3f12 | |||
| f3450686e0 |
@@ -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.
|
# token). These URLs are just defaults; the admin can override them at runtime.
|
||||||
UOLINK_BASE_URL=http://127.0.0.1:8080
|
UOLINK_BASE_URL=http://127.0.0.1:8080
|
||||||
UOLINK_WS_URL=ws://127.0.0.1:8080/ws
|
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 ───
|
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
|
||||||
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
|
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
# Gate every pull request into `main` on a fast, DB-free check suite so a broken
|
# Gate every pull request into `main` or `edge` on a fast, DB-free check suite so
|
||||||
# build or failing test can't reach the deployable branch. Complements
|
# 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
|
# build-images.yml, which runs only AFTER merge (on push to main) to publish
|
||||||
# images — this one runs BEFORE merge.
|
# 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):
|
# Enforcement (one-time, in the Gitea UI):
|
||||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||||
# • Enable Status Check
|
# • Enable Status Check
|
||||||
@@ -10,6 +17,10 @@
|
|||||||
# Note: Gitea only lists a context in its dropdown after it has reported once,
|
# 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
|
# so let this workflow run on one PR first. The `PR Checks / *` glob matches
|
||||||
# without needing the dropdown.
|
# 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
|
# 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
|
# only Node (no Docker socket), and the server tests stub their models + point the
|
||||||
@@ -19,7 +30,7 @@ name: PR Checks
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, edge]
|
||||||
|
|
||||||
# A newer push to the same PR cancels the in-flight run.
|
# A newer push to the same PR cancels the in-flight run.
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|||||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -21,6 +21,24 @@ uploads/
|
|||||||
server/logs/
|
server/logs/
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
|
||||||
|
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
|
||||||
|
# not ours to redistribute. The images live under server/uploads/atlas/, already
|
||||||
|
# ignored above; this is the slug -> file-name map pointing at them.
|
||||||
|
# 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 material (extracted from the provided archives)
|
||||||
_reference/
|
_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
|
itself is never exposed to the internet — only the sidecar is, and only the website's backend talks
|
||||||
to it.
|
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
|
### How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,13 +1,83 @@
|
|||||||
// Branding for the Discord bot. Mirrors the server's BRAND_* scheme so embeds and
|
// 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
|
// logs carry the instance identity. Kept minimal — the bot only needs the name
|
||||||
// and the accent color (as an int for discord.js embeds).
|
// 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()
|
require('dotenv').config()
|
||||||
|
|
||||||
const name = process.env.BRAND_NAME || 'Runic Gateway'
|
const siteApi = require('./site/siteApiClient')
|
||||||
const accentHex = process.env.BRAND_ACCENT_COLOR || '#7f99bd'
|
const createLogger = require('./utils/logger')
|
||||||
const accentInt = (() => {
|
|
||||||
const n = parseInt(String(accentHex).replace('#', ''), 16)
|
|
||||||
return Number.isNaN(n) ? 0x7f99bd : n
|
|
||||||
})()
|
|
||||||
|
|
||||||
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}`)
|
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()
|
await bootstrap()
|
||||||
|
|
||||||
setupShutdown(server)
|
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) {
|
function getNewsPost(idOrSlug) {
|
||||||
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
return call(`/posts/news/${encodeURIComponent(idOrSlug)}`)
|
||||||
}
|
}
|
||||||
@@ -39,4 +48,4 @@ function searchWiki(query) {
|
|||||||
return call(`/wiki?q=${encodeURIComponent(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." />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
62
client/package-lock.json
generated
62
client/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "runic-gateway-client",
|
"name": "runic-gateway-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"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-image": "^2.27.2",
|
||||||
"@tiptap/extension-link": "^2.27.2",
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
"@tiptap/extension-text-align": "^2.27.2",
|
"@tiptap/extension-text-align": "^2.27.2",
|
||||||
@@ -306,6 +309,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||||
@@ -2488,6 +2544,12 @@
|
|||||||
"@popperjs/core": "^2.9.0"
|
"@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": {
|
"node_modules/uc.micro": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-image": "^2.27.2",
|
||||||
"@tiptap/extension-link": "^2.27.2",
|
"@tiptap/extension-link": "^2.27.2",
|
||||||
"@tiptap/extension-text-align": "^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 RequireAuth from './components/RequireAuth.jsx'
|
||||||
import RequirePlayer from './components/RequirePlayer.jsx'
|
import RequirePlayer from './components/RequirePlayer.jsx'
|
||||||
import RoleGate from './components/RoleGate.jsx'
|
import RoleGate from './components/RoleGate.jsx'
|
||||||
|
import { routesFor } from './modules/registry.js'
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
import Portal from './routes/public/Portal.jsx'
|
import Portal from './routes/public/Portal.jsx'
|
||||||
@@ -22,6 +23,10 @@ import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
|||||||
import Guilds from './routes/public/Guilds.jsx'
|
import Guilds from './routes/public/Guilds.jsx'
|
||||||
import Governors from './routes/public/Governors.jsx'
|
import Governors from './routes/public/Governors.jsx'
|
||||||
import Houses from './routes/public/Houses.jsx'
|
import Houses from './routes/public/Houses.jsx'
|
||||||
|
import Rules from './routes/public/Rules.jsx'
|
||||||
|
import Leaderboards from './routes/public/Leaderboards.jsx'
|
||||||
|
import Market from './routes/public/Market.jsx'
|
||||||
|
import MarketVendor from './routes/public/MarketVendor.jsx'
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import Wiki from './routes/wiki/Wiki.jsx'
|
||||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||||
import CmsPage from './routes/public/CmsPage.jsx'
|
import CmsPage from './routes/public/CmsPage.jsx'
|
||||||
@@ -35,11 +40,15 @@ import PagesAdmin from './routes/admin/views/PagesAdmin.jsx'
|
|||||||
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
|
import PageBuilder from './routes/admin/views/PageBuilder.jsx'
|
||||||
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
import WikiAdmin from './routes/admin/views/WikiAdmin.jsx'
|
||||||
import HeroEditor from './routes/admin/views/HeroEditor.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 SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||||
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
|
import 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 ShardOps from './routes/admin/views/ShardOps.jsx'
|
||||||
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
||||||
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
|
||||||
@@ -97,8 +106,20 @@ export default function App() {
|
|||||||
<Route path="/site/guilds" element={<Guilds />} />
|
<Route path="/site/guilds" element={<Guilds />} />
|
||||||
<Route path="/site/governors" element={<Governors />} />
|
<Route path="/site/governors" element={<Governors />} />
|
||||||
<Route path="/site/houses" element={<Houses />} />
|
<Route path="/site/houses" element={<Houses />} />
|
||||||
|
<Route path="/site/rules" element={<Rules />} />
|
||||||
|
<Route path="/site/leaderboards" element={<Leaderboards />} />
|
||||||
|
<Route path="/site/market" element={<Market />} />
|
||||||
|
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
|
{/* Installed modules' public pages, namespaced `/<id>/…` (§2.8).
|
||||||
|
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
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||||
above (React Router ranks static routes over this dynamic one). */}
|
above (React Router ranks static routes over this dynamic one). */}
|
||||||
<Route path="/:slug" element={<CmsPage />} />
|
<Route path="/:slug" element={<CmsPage />} />
|
||||||
@@ -125,6 +146,28 @@ export default function App() {
|
|||||||
<Route path="pages/:id" element={<PageBuilder />} />
|
<Route path="pages/:id" element={<PageBuilder />} />
|
||||||
<Route path="wiki" element={<WikiAdmin />} />
|
<Route path="wiki" element={<WikiAdmin />} />
|
||||||
<Route path="hero" element={<HeroEditor />} />
|
<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="settings" element={<SettingsAdmin />} />
|
||||||
<Route
|
<Route
|
||||||
path="moderation"
|
path="moderation"
|
||||||
@@ -142,6 +185,8 @@ export default function App() {
|
|||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||||
<Route path="shard" element={<ShardAdmin />} />
|
<Route path="shard" element={<ShardAdmin />} />
|
||||||
|
<Route path="shard-visibility" element={<ShardVisibility />} />
|
||||||
|
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
|
||||||
<Route
|
<Route
|
||||||
path="shard-ops"
|
path="shard-ops"
|
||||||
element={
|
element={
|
||||||
@@ -165,6 +210,17 @@ export default function App() {
|
|||||||
<Route path="users/:id" element={<UserDetail />} />
|
<Route path="users/:id" element={<UserDetail />} />
|
||||||
<Route path="invites" element={<InvitesAdmin />} />
|
<Route path="invites" element={<InvitesAdmin />} />
|
||||||
<Route path="account" element={<AccountAdmin />} />
|
<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 path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Route>
|
</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 = {
|
export const api = {
|
||||||
// ----- auth -----
|
// ----- auth -----
|
||||||
me: () => req('/auth/me'),
|
me: () => req('/auth/me'),
|
||||||
@@ -97,6 +107,14 @@ export const api = {
|
|||||||
generateRecoveryCodes: (currentPassword) =>
|
generateRecoveryCodes: (currentPassword) =>
|
||||||
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { 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 -----
|
// ----- public -----
|
||||||
publicSettings: () => req('/public/settings'),
|
publicSettings: () => req('/public/settings'),
|
||||||
status: () => req('/public/status'),
|
status: () => req('/public/status'),
|
||||||
@@ -147,6 +165,75 @@ export const api = {
|
|||||||
},
|
},
|
||||||
presence: () => req('/public/shard/presence'),
|
presence: () => req('/public/shard/presence'),
|
||||||
houses: () => req('/public/shard/houses'),
|
houses: () => req('/public/shard/houses'),
|
||||||
|
// Protocol 3.0: the shard's published ruleset. Resolves to null when the
|
||||||
|
// shard has never published one — a real answer, not an error.
|
||||||
|
ruleset: () => req('/public/shard/ruleset'),
|
||||||
|
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
|
||||||
|
// `board` 404s for a system the shard has never published.
|
||||||
|
points: () => req('/public/shard/points'),
|
||||||
|
pointsBoard: (system) => req(`/public/shard/points/${encodeURIComponent(system)}`),
|
||||||
|
// Protocol 3.0: the player-vendor marketplace. Rate-limited server-side, so
|
||||||
|
// the page debounces its search box rather than firing per keystroke.
|
||||||
|
market: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
if (opts.minPrice != null && opts.minPrice !== '') qs.set('minPrice', opts.minPrice)
|
||||||
|
if (opts.maxPrice != null && opts.maxPrice !== '') qs.set('maxPrice', opts.maxPrice)
|
||||||
|
if (opts.itemId != null && opts.itemId !== '') qs.set('itemId', opts.itemId)
|
||||||
|
if (opts.map) qs.set('map', opts.map)
|
||||||
|
if (opts.region) qs.set('region', opts.region)
|
||||||
|
if (opts.sort) qs.set('sort', opts.sort)
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
if (opts.offset) qs.set('offset', opts.offset)
|
||||||
|
return req(`/public/shard/market${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
marketMeta: () => req('/public/shard/market/meta'),
|
||||||
|
marketVendor: (serial, opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
if (opts.offset) qs.set('offset', opts.offset)
|
||||||
|
return req(`/public/shard/market/vendors/${encodeURIComponent(serial)}${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
// Which shard surfaces this caller may reach, plus the audience rung they
|
||||||
|
// resolved to. Drives nav so we never render a link that would 403.
|
||||||
|
features: () => req('/public/shard/features'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----- spawn atlas (Protocol 3.0 Part C) -----
|
||||||
|
// Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately
|
||||||
|
// not under /shard, because nothing here depends on the sidecar and the pages
|
||||||
|
// stay populated while the shard is offline.
|
||||||
|
atlas: {
|
||||||
|
creatures: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.limit) qs.set('limit', opts.limit)
|
||||||
|
if (opts.offset) qs.set('offset', opts.offset)
|
||||||
|
return req(`/public/atlas/creatures${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
creature: (slug, opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.points) qs.set('points', opts.points)
|
||||||
|
return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
regions: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
return req(`/public/atlas/regions${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
landmarks: (opts = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (opts.facet) qs.set('facet', opts.facet)
|
||||||
|
if (opts.q) qs.set('q', opts.q)
|
||||||
|
return req(`/public/atlas/landmarks${withQs(qs.toString())}`)
|
||||||
|
},
|
||||||
|
// The CONFIGURED altar roster, not the live board — see shard.champs() for
|
||||||
|
// "which spawn is on level 3 right now".
|
||||||
|
champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`),
|
||||||
|
meta: () => req('/public/atlas/meta'),
|
||||||
},
|
},
|
||||||
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
|
// 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
|
// fetch-only, so SSE subscribers build the URL from here. The admin stream
|
||||||
@@ -211,6 +298,20 @@ export const api = {
|
|||||||
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
|
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
|
||||||
getSettings: () => req('/admin/settings'),
|
getSettings: () => req('/admin/settings'),
|
||||||
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
|
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}`),
|
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
|
||||||
botActivity: () => req('/admin/bot-activity'),
|
botActivity: () => req('/admin/bot-activity'),
|
||||||
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
|
||||||
@@ -346,6 +447,25 @@ export const api = {
|
|||||||
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||||
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||||
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
// Per-feature shard visibility: who may see which shard surface, and which
|
||||||
|
// sensitive fields within it. Admin only — it decides what ANONYMOUS
|
||||||
|
// visitors get. acct/webId are admin-only always and the API rejects any
|
||||||
|
// attempt to configure them.
|
||||||
|
getShardVisibility: () => req('/admin/shard/visibility'),
|
||||||
|
saveShardVisibility: (features) =>
|
||||||
|
req('/admin/shard/visibility', { method: 'PUT', body: { features } }),
|
||||||
|
|
||||||
|
// ----- spawn atlas operation (admin only) -----
|
||||||
|
// The atlas re-derives itself from the ServUO tree on every boot; these are
|
||||||
|
// for applying a map change without a restart, and for the approve/reject
|
||||||
|
// decision on a refresh that would remove a facet.
|
||||||
|
atlas: {
|
||||||
|
status: () => req('/admin/shard/atlas'),
|
||||||
|
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
|
||||||
|
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
|
||||||
|
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
|
||||||
|
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
||||||
|
},
|
||||||
|
|
||||||
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
|
||||||
// `actor` is stamped server-side from the session — never sent from here.
|
// `actor` is stamped server-side from the session — never sent from here.
|
||||||
|
|||||||
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' }
|
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
|
// 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
|
// 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
|
// literal string.
|
||||||
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
//
|
||||||
// de-duped list of human-readable title chips.
|
// `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) {
|
function displayTitles(titles) {
|
||||||
if (!titles) return []
|
if (!titles) return []
|
||||||
const out = []
|
const out = []
|
||||||
if (titles.fameKarma) out.push(titles.fameKarma)
|
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||||
if (titles.skill) out.push(titles.skill)
|
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
|
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||||
// Prefer the selected reward title; fall back to the first literal one.
|
// Prefer the selected reward title; fall back to the first one that resolved.
|
||||||
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
// The `??` matters: a selected title whose cliloc did not resolve must fall
|
||||||
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
// 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))]
|
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)' }) {
|
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -75,6 +140,11 @@ export default function CharacterSheet({ char, moderation = false }) {
|
|||||||
.filter((s) => (s.value || s.base || 0) > 0)
|
.filter((s) => (s.value || s.base || 0) > 0)
|
||||||
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||||
const equipment = char.equipment || []
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||||
@@ -173,17 +243,37 @@ export default function CharacterSheet({ char, moderation = false }) {
|
|||||||
</section>
|
</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 */}
|
||||||
{equipment.length > 0 && (
|
{equipment.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<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 }}>
|
<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)' }} />
|
<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 style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{it.layer || 'Item'}</div>
|
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
|
||||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>id {it.itemId}{it.hue ? ` · hue ${it.hue}` : ''}</div>
|
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
|
||||||
</div>
|
</div>
|
||||||
{it.mods && Object.keys(it.mods).length > 0 && (
|
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||||
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
<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>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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,22 +1,41 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { Link, NavLink } from 'react-router-dom'
|
import { Link, NavLink } from 'react-router-dom'
|
||||||
import MoonDot from './MoonDot.jsx'
|
import MoonDot from './MoonDot.jsx'
|
||||||
|
import BrandLogo from './BrandLogo.jsx'
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../contexts/SiteContext.jsx'
|
import { useSite } from '../contexts/SiteContext.jsx'
|
||||||
|
import { useShardFeatures, canSee } from '../lib/useShardFeatures.js'
|
||||||
|
import NavDropdown from './NavDropdown.jsx'
|
||||||
|
import { 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
|
// One consistent top nav for the whole public site. Every page gets the same
|
||||||
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
// main links plus an auth-aware entry on the right (Sign in / My Account / Admin).
|
||||||
const NAV = [
|
//
|
||||||
|
// Entries carrying a `feature` are shard surfaces an admin can disable or gate
|
||||||
|
// to a higher audience (Admin -> Shard Visibility). They are hidden when this
|
||||||
|
// viewer can't reach them, so we never render a link that would 403. The gate
|
||||||
|
// itself is server-side; this is only about not advertising a dead end.
|
||||||
|
//
|
||||||
|
// Exported because Admin -> Navigation edits this list. It stays declared here,
|
||||||
|
// with this component as its owner: the editor may only relabel, reorder and
|
||||||
|
// hide what it finds, and `to`/`feature` are never its to change (§7).
|
||||||
|
export const NAV = [
|
||||||
{ label: 'Home', to: '/', end: true },
|
{ label: 'Home', to: '/', end: true },
|
||||||
{ label: 'News', to: '/site/news' },
|
{ label: 'News', to: '/site/news' },
|
||||||
{ label: 'Screenshots', to: '/site/screenshots' },
|
{ label: 'Screenshots', to: '/site/screenshots' },
|
||||||
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
{ label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
{ label: 'Newsletter', to: '/site/newsletter' },
|
{ label: 'Newsletter', to: '/site/newsletter' },
|
||||||
{ label: 'Wiki', to: '/wiki' },
|
{ label: 'Wiki', to: '/wiki' },
|
||||||
{ label: 'Shard', to: '/site/shard' },
|
{ label: 'Shard', to: '/site/shard', feature: 'status' },
|
||||||
{ label: 'Champions', to: '/site/champs' },
|
{ label: 'Champions', to: '/site/champs', feature: 'champs' },
|
||||||
{ label: 'Guilds', to: '/site/guilds' },
|
{ label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||||
{ label: 'Governors', to: '/site/governors' },
|
{ label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||||
{ label: 'Houses', to: '/site/houses' },
|
{ label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||||
|
{ label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
||||||
|
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
||||||
|
{ label: 'Market', to: '/site/market', feature: 'market' },
|
||||||
{ label: 'About', to: '/site/about' },
|
{ label: 'About', to: '/site/about' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -28,7 +47,40 @@ const linkStyle = ({ isActive }) => ({
|
|||||||
|
|
||||||
export default function SiteHeader() {
|
export default function SiteHeader() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
const { siteTitle } = useSite()
|
const { siteTitle, settings } = useSite()
|
||||||
|
const shardFeatures = useShardFeatures()
|
||||||
|
|
||||||
|
// An admin may relabel, reorder and hide these entries from Admin →
|
||||||
|
// Navigation, and may group them into dropdown sections alongside links of
|
||||||
|
// 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.
|
// Where the auth entry points: staff → admin, player → portal, else sign in.
|
||||||
let account
|
let account
|
||||||
@@ -56,15 +108,20 @@ export default function SiteHeader() {
|
|||||||
className="display"
|
className="display"
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '1.2rem', letterSpacing: '0.05em', color: 'var(--accent-bright)', textDecoration: 'none', fontWeight: 600 }}
|
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 />
|
<MoonDot />
|
||||||
{siteTitle}
|
{siteTitle}
|
||||||
</Link>
|
</Link>
|
||||||
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
<nav style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||||
{NAV.map((l) => (
|
{nav.map((l) =>
|
||||||
<NavLink key={l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
l.kind === 'section' ? (
|
||||||
{l.label}
|
<NavDropdown key={l.id} label={l.label} items={l.items} linkStyle={linkStyle} />
|
||||||
</NavLink>
|
) : (
|
||||||
))}
|
<NavLink key={l.kind === 'link' ? l.id : l.to} to={l.to} end={l.end} className="pill" style={linkStyle}>
|
||||||
|
{l.label}
|
||||||
|
</NavLink>
|
||||||
|
),
|
||||||
|
)}
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={account.to}
|
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 { api } from '../api/client.js'
|
||||||
|
import { applyThemeTokens } from '../lib/themeVars.js'
|
||||||
|
|
||||||
const SiteContext = createContext(null)
|
const SiteContext = createContext(null)
|
||||||
|
|
||||||
@@ -7,11 +8,16 @@ const SiteContext = createContext(null)
|
|||||||
export function SiteProvider({ children }) {
|
export function SiteProvider({ children }) {
|
||||||
const [settings, setSettings] = useState({})
|
const [settings, setSettings] = useState({})
|
||||||
const [loading, setLoading] = useState(true)
|
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 () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.publicSettings()
|
const data = await api.publicSettings()
|
||||||
setSettings(data || {})
|
setSettings(data || {})
|
||||||
|
setSettled(true)
|
||||||
} catch {
|
} catch {
|
||||||
setSettings({})
|
setSettings({})
|
||||||
} finally {
|
} finally {
|
||||||
@@ -25,11 +31,38 @@ export function SiteProvider({ children }) {
|
|||||||
|
|
||||||
const brand = useMemo(() => settings.brand || {}, [settings])
|
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,
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (brand.accent) document.documentElement.style.setProperty('--accent', brand.accent)
|
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
|
// 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).
|
// 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
|
||||||
58
client/src/lib/useShardFeatures.js
Normal file
58
client/src/lib/useShardFeatures.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../api/client.js'
|
||||||
|
|
||||||
|
// Which shard surfaces the current viewer may reach, from
|
||||||
|
// GET /public/shard/features. Admins configure this per feature (Admin → Shard
|
||||||
|
// Visibility), so the nav can't be a static list any more.
|
||||||
|
//
|
||||||
|
// This is PRESENTATION only. The gate is server-side: a disabled feature 404s
|
||||||
|
// and an out-of-rung one 403s whether or not the link is rendered. So while the
|
||||||
|
// answer is still in flight we return `null` and callers show their default set
|
||||||
|
// — better a link that briefly 403s than a nav that flickers in on every load.
|
||||||
|
//
|
||||||
|
// Cached module-level: the answer is per-viewer but stable for a session, and
|
||||||
|
// every consumer would otherwise refetch it on mount.
|
||||||
|
let cached = null
|
||||||
|
let inFlight = null
|
||||||
|
|
||||||
|
export function resetShardFeatures() {
|
||||||
|
cached = null
|
||||||
|
inFlight = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShardFeatures() {
|
||||||
|
const [features, setFeatures] = useState(cached)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cached) return undefined
|
||||||
|
let alive = true
|
||||||
|
inFlight =
|
||||||
|
inFlight ||
|
||||||
|
api.shard
|
||||||
|
.features()
|
||||||
|
.then((data) => {
|
||||||
|
cached = { level: data.level, set: new Set(data.features || []) }
|
||||||
|
return cached
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// A failed lookup must not blank the nav — fall back to "show
|
||||||
|
// everything" and let the server do the gating.
|
||||||
|
cached = null
|
||||||
|
inFlight = null
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
inFlight.then((result) => {
|
||||||
|
if (alive) setFeatures(result)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return features
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: true when `name` is visible, or when we don't know yet.
|
||||||
|
export function canSee(features, name) {
|
||||||
|
return !features || features.set.has(name)
|
||||||
|
}
|
||||||
@@ -2,12 +2,37 @@ import React from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
import { publishSharedDependencies } from './modules/shared.js'
|
||||||
import './styles/theme.css'
|
import './styles/theme.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
||||||
<React.StrictMode>
|
// Installed modules are `<script type="module" src="/modules/<id>/entry.js">`
|
||||||
<BrowserRouter>
|
// tags the server injects into <head> (server/src/utils/htmlShell.js); module
|
||||||
<App />
|
// scripts are deferred, so they run after this bundle and resolve their
|
||||||
</BrowserRouter>
|
// externals against the global this call sets up.
|
||||||
</React.StrictMode>,
|
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 { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||||
|
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||||
|
|
||||||
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
// Small inline stroke icons (16px, currentColor) — same style as ProviderIcon.
|
||||||
// One shared frame keeps them terse; each item just supplies its path(s).
|
// One shared frame keeps them terse; each item just supplies its path(s).
|
||||||
@@ -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 IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
|
||||||
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></Icon>
|
||||||
const IconShard = () => <Icon><path d="M12 2l7 6-7 14-7-14z" /><path d="M5 8h14" /></Icon>
|
const 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
|
// Nav is grouped into collapsible categories. A group with no `title` renders
|
||||||
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
// its items ungrouped (Dashboard at top, Account at bottom). Each item's `roles`
|
||||||
// (when present) matches server-side enforcement so the sidebar never shows a
|
// (when present) matches server-side enforcement so the sidebar never shows a
|
||||||
// link that would 403; an item without `roles` is visible to everyone.
|
// link that would 403; an item without `roles` is visible to everyone.
|
||||||
// Moderators are further confined to just their section + account (see below).
|
// 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: [
|
items: [
|
||||||
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
|
{ to: '/admin', label: 'Dashboard', end: true, icon: IconHome, roles: ['admin', 'editor', 'moderator'] },
|
||||||
@@ -74,10 +83,14 @@ const NAV = [
|
|||||||
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
|
||||||
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
|
||||||
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
|
||||||
|
{ 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/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
|
||||||
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
|
||||||
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
{ to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] },
|
||||||
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
|
{ to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] },
|
||||||
|
{ to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] },
|
||||||
|
{ to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] },
|
||||||
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
{ to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -91,6 +104,35 @@ const NAV = [
|
|||||||
|
|
||||||
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
const COLLAPSE_KEY = 'admin.nav.collapsed'
|
||||||
|
|
||||||
|
// Moderators only get the moderation section (Discord + in-game ops) + their
|
||||||
|
// own account security.
|
||||||
|
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
||||||
|
|
||||||
|
// The one row an override may never hide: the nav editor itself, which is the
|
||||||
|
// 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 = {
|
const TITLES = {
|
||||||
'/admin': 'Dashboard',
|
'/admin': 'Dashboard',
|
||||||
'/admin/posts': 'Posts',
|
'/admin/posts': 'Posts',
|
||||||
@@ -102,10 +144,14 @@ const TITLES = {
|
|||||||
'/admin/shard-ops': 'In-Game Ops',
|
'/admin/shard-ops': 'In-Game Ops',
|
||||||
'/admin/houses': 'House Registry',
|
'/admin/houses': 'House Registry',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
|
'/admin/appearance': 'Appearance',
|
||||||
|
'/admin/navigation': 'Navigation',
|
||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
'/admin/bot-activity': 'Web Bot Activity',
|
'/admin/bot-activity': 'Web Bot Activity',
|
||||||
'/admin/discord-bot': 'Discord Bot',
|
'/admin/discord-bot': 'Discord Bot',
|
||||||
'/admin/shard': 'Shard (uo-link)',
|
'/admin/shard': 'Shard (uo-link)',
|
||||||
|
'/admin/shard-visibility': 'Shard Visibility',
|
||||||
|
'/admin/shard-atlas': 'Spawn Atlas',
|
||||||
'/admin/characters': 'My Characters',
|
'/admin/characters': 'My Characters',
|
||||||
'/admin/auth-providers': 'Authentication',
|
'/admin/auth-providers': 'Authentication',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
@@ -137,6 +183,7 @@ const navBtnBase = {
|
|||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const { mode, siteTitle } = useSite()
|
const { mode, siteTitle } = useSite()
|
||||||
|
const navOverrides = useNavOverrides()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title = TITLES[location.pathname] || sectionTitle(location.pathname)
|
const title = TITLES[location.pathname] || sectionTitle(location.pathname)
|
||||||
@@ -144,20 +191,21 @@ export default function AdminLayout() {
|
|||||||
const wide = location.pathname === '/admin/hero'
|
const wide = location.pathname === '/admin/hero'
|
||||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||||
|
|
||||||
// Moderators only get the moderation section (Discord + in-game ops) + their
|
|
||||||
// own account security.
|
|
||||||
const isModerator = user?.role === 'moderator'
|
const isModerator = user?.role === 'moderator'
|
||||||
const MOD_PATHS = ['/admin/moderation', '/admin/moderation/appeals', '/admin/shard-ops', '/admin/houses', '/admin/account']
|
|
||||||
const visible = (item) => {
|
// An admin may relabel, reorder, hide and regroup these rows from Admin →
|
||||||
if (item.roles && !item.roles.includes(user?.role)) return false
|
// Navigation. The merge runs FIRST and the role filter after it, so the filter
|
||||||
if (isModerator) return MOD_PATHS.includes(item.to)
|
// stays the boundary: an override cannot show a moderator a row their role
|
||||||
return true
|
// gate hides, whatever it says. With no stored row applyNavOverrides returns
|
||||||
}
|
// NAV itself and this is exactly the code that ran before the feature.
|
||||||
// Drop items the current role can't see, then drop any now-empty group so an
|
const navGroups = useMemo(
|
||||||
// empty category header never renders.
|
() =>
|
||||||
const navGroups = NAV
|
applyNavOverrides(NAV, keepEditorReachable(navOverrides.nav_admin))
|
||||||
.map((g) => ({ ...g, items: g.items.filter(visible) }))
|
.map((g) => ({ ...g, items: g.items.filter((item) => navItemVisibleTo(item, user?.role)) }))
|
||||||
.filter((g) => g.items.length > 0)
|
// 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
|
// Accordion: track which titled categories are collapsed. Persist across
|
||||||
// reloads; default all-open. The group holding the active route auto-opens.
|
// reloads; default all-open. The group holding the active route auto-opens.
|
||||||
@@ -224,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 }}>
|
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<BrandLogo height={24} />
|
||||||
<MoonDot />
|
<MoonDot />
|
||||||
<div>
|
<div>
|
||||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||||
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
|
import TrustLimitModal from '../../components/security/TrustLimitModal.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
@@ -181,6 +182,10 @@ export default function AdminLogin() {
|
|||||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||||
<div style={{ marginBottom: 14 }}>
|
<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} />
|
<MoonDot size={15} glow={0.55} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
<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 [baseUrl, setBaseUrl] = useState('')
|
||||||
const [wsUrl, setWsUrl] = useState('')
|
const [wsUrl, setWsUrl] = useState('')
|
||||||
const [token, setToken] = useState('')
|
const [token, setToken] = useState('')
|
||||||
const [protocol, setProtocol] = useState(1)
|
const [protocol, setProtocol] = useState(3)
|
||||||
const [enabled, setEnabled] = useState(false)
|
const [enabled, setEnabled] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
@@ -172,7 +172,7 @@ export default function ShardAdmin() {
|
|||||||
if (!initializedRef.current) {
|
if (!initializedRef.current) {
|
||||||
setBaseUrl(c.baseUrl || '')
|
setBaseUrl(c.baseUrl || '')
|
||||||
setWsUrl(c.wsUrl || '')
|
setWsUrl(c.wsUrl || '')
|
||||||
setProtocol(c.protocol || 1)
|
setProtocol(c.protocol || 3)
|
||||||
setEnabled(c.enabled)
|
setEnabled(c.enabled)
|
||||||
initializedRef.current = true
|
initializedRef.current = true
|
||||||
}
|
}
|
||||||
|
|||||||
325
client/src/routes/admin/views/ShardVisibility.jsx
Normal file
325
client/src/routes/admin/views/ShardVisibility.jsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// ── Admin · Shard visibility ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Who may see which shard surface, and which sensitive fields within it.
|
||||||
|
// Admin-only, because this decides what ANONYMOUS visitors get.
|
||||||
|
//
|
||||||
|
// Two things the UI must communicate honestly, because they are not negotiable
|
||||||
|
// server-side (see docs/link/v3.md §3.4):
|
||||||
|
// • acct / webId are admin-only always and are not listed as editable fields.
|
||||||
|
// • an event kind the server doesn't know about never reaches anyone below
|
||||||
|
// admin, whatever is set here.
|
||||||
|
//
|
||||||
|
// Defaults reproduce the behavior the site had before this panel existed, so a
|
||||||
|
// fresh install shows "everything as it was" rather than an empty form.
|
||||||
|
|
||||||
|
const RUNG_LABEL = {
|
||||||
|
anonymous: 'Everyone',
|
||||||
|
logged_in: 'Signed in',
|
||||||
|
player: 'Linked players',
|
||||||
|
staff: 'Staff',
|
||||||
|
admin: 'Admins only',
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUNG_HINT = {
|
||||||
|
anonymous: 'Visible to anyone, signed in or not.',
|
||||||
|
logged_in: 'Any signed-in account, linked or not.',
|
||||||
|
player: 'Accounts with a linked game account. Staff always qualify.',
|
||||||
|
staff: 'Admins and moderators.',
|
||||||
|
admin: 'Admins only.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_LABEL = {
|
||||||
|
status: 'Shard status',
|
||||||
|
activity: 'Activity feed',
|
||||||
|
champs: 'Champion spawns',
|
||||||
|
guilds: 'Guilds',
|
||||||
|
governors: 'Town governors',
|
||||||
|
houses: 'Houses / IDOC',
|
||||||
|
presence: 'Players online',
|
||||||
|
ruleset: 'Shard rules',
|
||||||
|
atlas: 'Spawn atlas',
|
||||||
|
leaderboards: 'Leaderboards',
|
||||||
|
market: 'Marketplace',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_HINT = {
|
||||||
|
status: 'Connection state, online count, gold-supply series.',
|
||||||
|
activity: 'Deaths, kills, skill gains, quests, logins.',
|
||||||
|
champs: 'The live champion / mini-champ / sea-boss board.',
|
||||||
|
guilds: 'Guild rosters, alliances and leaders.',
|
||||||
|
governors: 'City Loyalty governors, elections and term history.',
|
||||||
|
houses: 'Houses in danger (IDOC). Owner and price are separate fields below.',
|
||||||
|
presence: 'Population aggregate and the staff-online widget.',
|
||||||
|
ruleset: 'Skill/stat caps, house limits, vet rewards and the rest of the ruleset.',
|
||||||
|
atlas: 'The spawn atlas and bestiary. Static shard content, not live state.',
|
||||||
|
leaderboards: 'Point and loyalty standings across every points system.',
|
||||||
|
market: 'The shard-wide player-vendor index.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_LABEL = {
|
||||||
|
owner: 'House owner',
|
||||||
|
price: 'House price',
|
||||||
|
location: 'In-game location (map + coordinates)',
|
||||||
|
connect: 'Server connect address',
|
||||||
|
// Keyed on the WIRE field, which for a leaderboard entry is `name` — the
|
||||||
|
// projection matches literal JSON keys, so the rule cannot be spelled after the
|
||||||
|
// field's meaning. The label is what carries the meaning to the admin.
|
||||||
|
name: 'Character names on leaderboards',
|
||||||
|
ownerName: 'Vendor owner name',
|
||||||
|
// One rule, one key — `location` is a nested object on both the wire frame and
|
||||||
|
// the stored read model precisely so that hiding it takes the facet, the
|
||||||
|
// coordinates, the region and the house together.
|
||||||
|
ownerSerial: 'Vendor owner character id',
|
||||||
|
}
|
||||||
|
|
||||||
|
function RungSelect({ value, onChange, ladder, disabled }) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
style={{ maxWidth: 200 }}
|
||||||
|
>
|
||||||
|
{ladder.map((rung) => (
|
||||||
|
<option key={rung} value={rung}>
|
||||||
|
{RUNG_LABEL[rung] || rung}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureRow({ name, settings, defaults, ladder, onPatch }) {
|
||||||
|
const fields = Object.entries(settings.fields || {})
|
||||||
|
const changed =
|
||||||
|
defaults &&
|
||||||
|
(settings.enabled !== defaults.enabled ||
|
||||||
|
settings.audience !== defaults.audience ||
|
||||||
|
settings.stream !== defaults.stream ||
|
||||||
|
JSON.stringify(settings.fields) !== JSON.stringify(defaults.fields))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 16,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
opacity: settings.enabled ? 1 : 0.62,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||||
|
{FEATURE_LABEL[name] || name}
|
||||||
|
{changed && (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{ marginLeft: 8, fontSize: '0.62rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
changed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="sans" style={{ margin: '4px 0 0', fontSize: '0.82rem', color: 'var(--muted)', lineHeight: 1.5 }}>
|
||||||
|
{FEATURE_HINT[name]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
className="sans"
|
||||||
|
style={{ flex: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enabled}
|
||||||
|
onChange={(e) => onPatch(name, { enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-end' }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
<span className="field-label">Who can see it</span>
|
||||||
|
<RungSelect
|
||||||
|
value={settings.audience}
|
||||||
|
ladder={ladder}
|
||||||
|
disabled={!settings.enabled}
|
||||||
|
onChange={(audience) => onPatch(name, { audience })}
|
||||||
|
/>
|
||||||
|
<span className="sans dim" style={{ display: 'block', marginTop: 4, fontSize: '0.75rem' }}>
|
||||||
|
{RUNG_HINT[settings.audience]}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="sans"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: '0.86rem', color: 'var(--ink)', paddingBottom: 22 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.stream}
|
||||||
|
disabled={!settings.enabled}
|
||||||
|
onChange={(e) => onPatch(name, { stream: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Live updates
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.length > 0 && (
|
||||||
|
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 12 }}>
|
||||||
|
<span className="field-label" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
Sensitive fields
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||||||
|
{fields.map(([field, rung]) => (
|
||||||
|
<label key={field} style={{ display: 'block' }}>
|
||||||
|
<span className="sans dim" style={{ display: 'block', fontSize: '0.78rem', marginBottom: 4 }}>
|
||||||
|
{FIELD_LABEL[field] || field}
|
||||||
|
</span>
|
||||||
|
<RungSelect
|
||||||
|
value={rung}
|
||||||
|
ladder={ladder}
|
||||||
|
disabled={!settings.enabled}
|
||||||
|
onChange={(level) =>
|
||||||
|
onPatch(name, { fieldRules: { ...settings.fields, [field]: level } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShardVisibility() {
|
||||||
|
const [config, setConfig] = useState(null)
|
||||||
|
const [defaults, setDefaults] = useState(null)
|
||||||
|
const [ladder, setLadder] = useState([])
|
||||||
|
const [lockedFields, setLockedFields] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const data = await api.admin.getShardVisibility()
|
||||||
|
setConfig(data.features)
|
||||||
|
setDefaults(data.defaults)
|
||||||
|
setLadder(data.ladder || [])
|
||||||
|
setLockedFields(data.lockedFields || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not load visibility settings.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
function patch(name, changes) {
|
||||||
|
setMsg('')
|
||||||
|
setConfig((prev) => {
|
||||||
|
const next = { ...prev[name], ...changes }
|
||||||
|
// `fieldRules` in the API is `fields` in the effective config.
|
||||||
|
if (changes.fieldRules) {
|
||||||
|
next.fields = changes.fieldRules
|
||||||
|
delete next.fieldRules
|
||||||
|
}
|
||||||
|
return { ...prev, [name]: next }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
setMsg('')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const body = {}
|
||||||
|
for (const [name, s] of Object.entries(config)) {
|
||||||
|
body[name] = {
|
||||||
|
enabled: s.enabled,
|
||||||
|
audience: s.audience,
|
||||||
|
stream: s.stream,
|
||||||
|
fieldRules: s.fields || {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = await api.admin.saveShardVisibility(body)
|
||||||
|
setConfig(data.features)
|
||||||
|
setMsg('Saved. Changes take effect within a few seconds, including on open live streams.')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not save.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToDefaults() {
|
||||||
|
setMsg('')
|
||||||
|
setConfig(structuredClone(defaults))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error && !config) return <ErrorState message={error} onRetry={load} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<header>
|
||||||
|
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||||
|
Shard visibility
|
||||||
|
</h2>
|
||||||
|
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||||
|
Choose who can see each shard surface on the public site, and how much detail they get.
|
||||||
|
Turning a feature off hides it entirely — its pages return “not found” rather than
|
||||||
|
revealing that it exists. “Live updates” controls whether the feature streams changes in
|
||||||
|
real time; the pages still work without it, they just refresh on load.
|
||||||
|
</p>
|
||||||
|
{lockedFields.length > 0 && (
|
||||||
|
<p className="sans dim" style={{ margin: '8px 0 0', fontSize: '0.82rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||||
|
Not configurable: <strong style={{ color: 'var(--ink)' }}>{lockedFields.join(', ')}</strong> —
|
||||||
|
game account names and website user ids are never shown below admin, on any surface. They
|
||||||
|
aren’t visible in game either, so publishing them would disclose something the shard
|
||||||
|
itself doesn’t.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{Object.entries(config).map(([name, settings]) => (
|
||||||
|
<FeatureRow
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
settings={settings}
|
||||||
|
defaults={defaults?.[name]}
|
||||||
|
ladder={ladder}
|
||||||
|
onPatch={patch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={save} disabled={saving} className="btn btn-primary btn-sq">
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
<button onClick={resetToDefaults} disabled={saving} className="btn btn-sq">
|
||||||
|
Restore defaults
|
||||||
|
</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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 { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
import { applyNavOverrides } from '../../lib/navOverrides.js'
|
||||||
|
import { useNavOverrides } from '../../lib/useNavOverrides.js'
|
||||||
|
|
||||||
// Shared shell for the logged-in player portal. Uses the same sidebar shell as
|
// Shared shell for the logged-in player portal. Uses the same sidebar shell as
|
||||||
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
|
// Admin (icon nav, sticky content header, footer sign-out) so the two logged-in
|
||||||
@@ -30,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 IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
|
||||||
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
|
||||||
|
|
||||||
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: '/player', label: 'Characters', end: true, icon: IconUser },
|
||||||
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
|
||||||
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
{ to: '/account', label: 'Account', end: true, icon: IconGear },
|
||||||
@@ -60,6 +68,8 @@ const navBtnBase = {
|
|||||||
export default function PlayerPortalLayout() {
|
export default function PlayerPortalLayout() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const { siteTitle } = useSite()
|
const { siteTitle } = useSite()
|
||||||
|
const navOverrides = useNavOverrides()
|
||||||
|
const nav = useMemo(() => applyNavOverrides(NAV, navOverrides.nav_player), [navOverrides.nav_player])
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title =
|
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 }}>
|
<div style={{ padding: '22px 22px 18px', borderBottom: '1px solid var(--line-soft)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<BrandLogo height={24} />
|
||||||
<MoonDot />
|
<MoonDot />
|
||||||
<div>
|
<div>
|
||||||
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
<div className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', letterSpacing: '0.03em' }}>
|
||||||
@@ -98,7 +109,7 @@ export default function PlayerPortalLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4, overflowY: 'auto' }}>
|
||||||
{NAV.map((n) => (
|
{nav.map((n) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
// Centered card layout shared by the player login / register pages. `subtitle`
|
// 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={{ width: '100%', maxWidth: 400 }}>
|
||||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||||
<div style={{ marginBottom: 14 }}>
|
<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} />
|
<MoonDot size={15} glow={0.55} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
<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 { Link } from 'react-router-dom'
|
||||||
import MoonDot from '../../components/MoonDot.jsx'
|
import MoonDot from '../../components/MoonDot.jsx'
|
||||||
|
import BrandLogo from '../../components/BrandLogo.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
export default function Maintenance() {
|
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={{ maxWidth: 640, textShadow: '0 2px 22px rgba(0,0,0,0.85)' }}>
|
||||||
<div style={{ marginBottom: 26 }}>
|
<div style={{ marginBottom: 26 }}>
|
||||||
|
<BrandLogo height={40} style={{ margin: '0 auto 16px' }} />
|
||||||
<MoonDot size={18} glow={0.6} />
|
<MoonDot size={18} glow={0.6} />
|
||||||
</div>
|
</div>
|
||||||
<p className="eyebrow" style={{ color: '#c2d2e6', letterSpacing: '0.24em' }}>
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
341
client/src/routes/public/Rules.jsx
Normal file
341
client/src/routes/public/Rules.jsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// The shard ruleset. Loaded from /public/shard/ruleset, replaced wholesale by any
|
||||||
|
// world.ruleset frame on the live feed (the shard re-emits the entire ruleset, so
|
||||||
|
// there is nothing to merge — latest wins).
|
||||||
|
//
|
||||||
|
// Everything on this page is published BY THE SHARD from its own Config/*.cfg, so
|
||||||
|
// it cannot drift the way a hand-written rules page does. That is the whole point
|
||||||
|
// of the feature, and the page says so.
|
||||||
|
const RULESET_KINDS = new Set(['world.ruleset'])
|
||||||
|
|
||||||
|
// Skill and stat caps arrive in tenths, the way ServUO stores them: 1000 is 100.0
|
||||||
|
// skill. Showing the raw number would be actively misleading.
|
||||||
|
const tenths = (v) => (Number.isFinite(v) ? (v / 10).toFixed(1) : null)
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : null)
|
||||||
|
|
||||||
|
const pct = (v) => (Number.isFinite(v) ? `${v}%` : null)
|
||||||
|
|
||||||
|
// The systems block is a flat bag of booleans; these are their display names, and
|
||||||
|
// the order here is the order they render. A key the shard sends that we don't
|
||||||
|
// know about still renders, humanised, rather than being silently dropped — a new
|
||||||
|
// plugin must not go invisible against an older client.
|
||||||
|
const SYSTEM_LABELS = {
|
||||||
|
cityLoyalty: 'City Loyalty (governors)',
|
||||||
|
vvv: 'Vice vs Virtue',
|
||||||
|
factions: 'Factions',
|
||||||
|
siege: 'Siege ruleset',
|
||||||
|
chat: 'In-game chat',
|
||||||
|
store: 'Ultima Store',
|
||||||
|
dailyRares: 'Daily rares',
|
||||||
|
honesty: 'Honesty virtue',
|
||||||
|
shadowguard: 'Shadowguard',
|
||||||
|
treasureMaps: 'Treasure maps',
|
||||||
|
vetRewards: 'Veteran rewards',
|
||||||
|
testCenter: 'Test Center',
|
||||||
|
}
|
||||||
|
|
||||||
|
const humanise = (key) =>
|
||||||
|
key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())
|
||||||
|
|
||||||
|
function Panel({ title, children }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 18 }}>
|
||||||
|
<h2
|
||||||
|
className="display"
|
||||||
|
style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A label/value row. Rows whose value is null are dropped by the caller, so a
|
||||||
|
// block never renders a dangling label for something the shard didn't publish.
|
||||||
|
function Row({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '5px 0',
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
fontSize: '0.86rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="dim" style={{ minWidth: 0 }}>{label}</span>
|
||||||
|
<strong style={{ flex: 'none', color: 'var(--head)' }}>{value}</strong>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Rows({ items }) {
|
||||||
|
const rows = items.filter(([, value]) => value !== null && value !== undefined)
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<Row key={label} label={label} value={value} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemPill({ label, on }) {
|
||||||
|
const color = on ? '#8fdcae' : 'var(--muted)'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 7,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
padding: '5px 11px',
|
||||||
|
borderRadius: 999,
|
||||||
|
color,
|
||||||
|
background: on ? 'rgba(95,185,138,0.12)' : 'rgba(140,150,165,0.1)',
|
||||||
|
border: `1px solid ${on ? 'rgba(95,185,138,0.4)' : 'var(--line)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ width: 7, height: 7, borderRadius: '50%', background: color, flex: 'none' }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Systems({ systems }) {
|
||||||
|
// Known keys first in their declared order, then anything the shard added that
|
||||||
|
// this build doesn't know about.
|
||||||
|
const known = Object.keys(SYSTEM_LABELS).filter((k) => k in systems)
|
||||||
|
const extra = Object.keys(systems).filter((k) => !(k in SYSTEM_LABELS))
|
||||||
|
const keys = [...known, ...extra]
|
||||||
|
if (keys.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel title="Systems">
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{keys.map((k) => (
|
||||||
|
<SystemPill key={k} label={SYSTEM_LABELS[k] || humanise(k)} on={!!systems[k]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Caps({ caps }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Skill & stat caps">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Individual skill cap', tenths(caps.skill)],
|
||||||
|
['Total skill cap', tenths(caps.totalSkill)],
|
||||||
|
['Total stat cap', num(caps.stat)],
|
||||||
|
['Strength cap', num(caps.str)],
|
||||||
|
['Dexterity cap', num(caps.dex)],
|
||||||
|
['Intelligence cap', num(caps.int)],
|
||||||
|
['Strength max', num(caps.strMax)],
|
||||||
|
['Dexterity max', num(caps.dexMax)],
|
||||||
|
['Intelligence max', num(caps.intMax)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountsAndHousing({ accounts, housing, vetRewards }) {
|
||||||
|
const items = []
|
||||||
|
if (accounts) {
|
||||||
|
items.push(['Accounts per IP', num(accounts.perIp)])
|
||||||
|
items.push(['Character slots', num(accounts.charSlots)])
|
||||||
|
items.push([
|
||||||
|
'In-game account creation',
|
||||||
|
accounts.autoCreate === undefined ? null : accounts.autoCreate ? 'Enabled' : 'Website only',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (housing) items.push(['Houses per account', num(housing.accountHouseLimit)])
|
||||||
|
if (vetRewards?.enabled) {
|
||||||
|
items.push(['Veteran reward interval', vetRewards.rewardIntervalDays
|
||||||
|
? `${vetRewards.rewardIntervalDays} days`
|
||||||
|
: null])
|
||||||
|
}
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel title="Accounts & housing">
|
||||||
|
<Rows items={items} />
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Champions({ champions }) {
|
||||||
|
const t = champions.rankThresholds
|
||||||
|
return (
|
||||||
|
<Panel title="Champion spawns">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Power scrolls per spawn', num(champions.powerScrolls)],
|
||||||
|
['Stat scrolls per spawn', num(champions.statScrolls)],
|
||||||
|
['Scroll drop chance', pct(champions.scrollChance)],
|
||||||
|
['Transcendence chance', pct(champions.transcendenceChance)],
|
||||||
|
[
|
||||||
|
'Red skulls per rank',
|
||||||
|
Array.isArray(t) && t.length > 0 ? t.join(' · ') : null,
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Felucca({ loot }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Felucca bonuses">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Luck bonus', num(loot.feluccaLuckBonus)],
|
||||||
|
['Loot budget bonus', num(loot.feluccaBudgetBonus)],
|
||||||
|
['Max item properties', num(loot.feluccaMaxProps)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vendors({ vendors }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Vendors">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Restock delay', vendors.restockDelayMinutes
|
||||||
|
? `${vendors.restockDelayMinutes} min`
|
||||||
|
: null],
|
||||||
|
['Max items sold at once', num(vendors.maxSell)],
|
||||||
|
['Economy stock amount', num(vendors.economyStockAmount)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pvp({ vvv }) {
|
||||||
|
return (
|
||||||
|
<Panel title="Vice vs Virtue">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Starting silver', num(vvv.startSilver)],
|
||||||
|
['Enhanced rules', vvv.enhancedRules === undefined
|
||||||
|
? null
|
||||||
|
: vvv.enhancedRules ? 'On' : 'Off'],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Schedule({ schedule }) {
|
||||||
|
const items = []
|
||||||
|
if (schedule.autoSaveEnabled && schedule.autoSaveFrequencyMinutes) {
|
||||||
|
items.push(['World save', `every ${schedule.autoSaveFrequencyMinutes} min`])
|
||||||
|
} else if (schedule.autoSaveEnabled === false) {
|
||||||
|
items.push(['World save', 'Disabled'])
|
||||||
|
}
|
||||||
|
if (schedule.autoRestartEnabled) {
|
||||||
|
const h = String(schedule.autoRestartHour ?? 0).padStart(2, '0')
|
||||||
|
const m = String(schedule.autoRestartMinute ?? 0).padStart(2, '0')
|
||||||
|
items.push(['Automatic restart', `${h}:${m} server time`])
|
||||||
|
if (schedule.autoRestartFrequencyHours) {
|
||||||
|
items.push(['Restart interval', `every ${schedule.autoRestartFrequencyHours}h`])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel title="Save & restart schedule">
|
||||||
|
<Rows items={items} />
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Rules() {
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.ruleset())
|
||||||
|
const { events, connected } = useShardFeed({ filter: RULESET_KINDS, max: 4 })
|
||||||
|
|
||||||
|
// The newest world.ruleset on the feed wins outright over the fetched copy —
|
||||||
|
// the frame is a complete ruleset, not a delta.
|
||||||
|
const ruleset = useMemo(() => events[0] || data || null, [data, events])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Live"
|
||||||
|
title="Shard ruleset"
|
||||||
|
lead="Published by the server itself, straight from its configuration — so it cannot drift from how the shard actually plays."
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem',
|
||||||
|
color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||||
|
{connected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && <ErrorState message="Could not load the shard ruleset right now." />}
|
||||||
|
|
||||||
|
{!loading && !error && !ruleset && (
|
||||||
|
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<p className="sans dim" style={{ margin: 0 }}>
|
||||||
|
The shard has not published its ruleset yet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && ruleset && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Panel title="Shard">
|
||||||
|
<Rows
|
||||||
|
items={[
|
||||||
|
['Name', ruleset.shard || null],
|
||||||
|
['Expansion', ruleset.expansion || null],
|
||||||
|
['Connect', ruleset.connect || null],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{ruleset.systems && <Systems systems={ruleset.systems} />}
|
||||||
|
{ruleset.caps && <Caps caps={ruleset.caps} />}
|
||||||
|
<AccountsAndHousing
|
||||||
|
accounts={ruleset.accounts}
|
||||||
|
housing={ruleset.housing}
|
||||||
|
vetRewards={ruleset.vetRewards}
|
||||||
|
/>
|
||||||
|
{ruleset.champions && <Champions champions={ruleset.champions} />}
|
||||||
|
{ruleset.loot && <Felucca loot={ruleset.loot} />}
|
||||||
|
{ruleset.vendors && <Vendors vendors={ruleset.vendors} />}
|
||||||
|
{ruleset.vvv?.enabled && <Pvp vvv={ruleset.vvv} />}
|
||||||
|
{ruleset.schedule && <Schedule schedule={ruleset.schedule} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,6 +24,22 @@
|
|||||||
|
|
||||||
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
|
--shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3);
|
||||||
--panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b));
|
--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;
|
flex-direction: column;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-card);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background: var(--panel-grad);
|
background: var(--panel-grad);
|
||||||
@@ -123,19 +139,19 @@ a.card:focus-visible {
|
|||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-card);
|
||||||
background: var(--panel-grad);
|
background: var(--panel-grad);
|
||||||
}
|
}
|
||||||
.panel-flat {
|
.panel-flat {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-panel);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--panel-flat);
|
background: var(--panel-flat);
|
||||||
}
|
}
|
||||||
.note {
|
.note {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
background: rgba(19, 36, 60, 0.4);
|
background: rgba(19, 36, 60, 0.4);
|
||||||
padding: 18px 22px;
|
padding: 18px 22px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -168,12 +184,20 @@ a.card:focus-visible {
|
|||||||
/* ===== Pills / buttons ===== */
|
/* ===== Pills / buttons ===== */
|
||||||
.pill {
|
.pill {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: var(--radius-pill);
|
||||||
padding: 7px 14px;
|
padding: 7px 14px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: rgba(11, 22, 48, 0.5);
|
background: rgba(11, 22, 48, 0.5);
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 0.86rem;
|
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;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
@@ -186,7 +210,7 @@ a.card:focus-visible {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
border-radius: 999px;
|
border-radius: var(--radius-pill);
|
||||||
padding: 12px 26px;
|
padding: 12px 26px;
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
@@ -214,7 +238,7 @@ a.card:focus-visible {
|
|||||||
background: var(--blue);
|
background: var(--blue);
|
||||||
}
|
}
|
||||||
.btn-sq {
|
.btn-sq {
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
padding: 10px 18px;
|
padding: 10px 18px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -230,7 +254,7 @@ button[disabled] {
|
|||||||
.select {
|
.select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -312,7 +336,7 @@ button[disabled] {
|
|||||||
}
|
}
|
||||||
.prose img {
|
.prose img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +344,7 @@ button[disabled] {
|
|||||||
.rte {
|
.rte {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
.rte:focus-within {
|
.rte:focus-within {
|
||||||
@@ -407,7 +431,7 @@ button[disabled] {
|
|||||||
width: min(360px, calc(100% - 20px));
|
width: min(360px, calc(100% - 20px));
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
background: var(--panel-a);
|
background: var(--panel-a);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
@@ -449,7 +473,7 @@ button[disabled] {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 999px;
|
border-radius: var(--radius-pill);
|
||||||
background: rgba(127, 153, 189, 0.1);
|
background: rgba(127, 153, 189, 0.1);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
@@ -494,7 +518,7 @@ button[disabled] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
background: var(--panel-flat);
|
background: var(--panel-flat);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -532,7 +556,7 @@ button[disabled] {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
.diff-add {
|
.diff-add {
|
||||||
@@ -603,7 +627,7 @@ button[disabled] {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.badge {
|
.badge {
|
||||||
border-radius: 999px;
|
border-radius: var(--radius-pill);
|
||||||
padding: 3px 11px;
|
padding: 3px 11px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -780,7 +804,7 @@ button[disabled] {
|
|||||||
}
|
}
|
||||||
.page-image img {
|
.page-image img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -863,7 +887,7 @@ button[disabled] {
|
|||||||
}
|
}
|
||||||
.pb-column-editor {
|
.pb-column-editor {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: var(--panel-flat, transparent);
|
background: var(--panel-flat, transparent);
|
||||||
}
|
}
|
||||||
@@ -881,7 +905,7 @@ button[disabled] {
|
|||||||
}
|
}
|
||||||
.pb-subblock {
|
.pb-subblock {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -919,7 +943,7 @@ button[disabled] {
|
|||||||
border: 1px solid #6e3b38;
|
border: 1px solid #6e3b38;
|
||||||
background: rgba(110, 59, 56, 0.16);
|
background: rgba(110, 59, 56, 0.16);
|
||||||
color: #e6a9a3;
|
color: #e6a9a3;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
@@ -928,7 +952,7 @@ button[disabled] {
|
|||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
background: var(--blue);
|
background: var(--blue);
|
||||||
color: var(--accent-bright);
|
color: var(--accent-bright);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
@@ -960,7 +984,7 @@ button[disabled] {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px dashed var(--line);
|
border: 1px dashed var(--line);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-card);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.pb-canvas {
|
.pb-canvas {
|
||||||
@@ -970,7 +994,7 @@ button[disabled] {
|
|||||||
}
|
}
|
||||||
.pb-block-card {
|
.pb-block-card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-card);
|
||||||
background: var(--panel-flat, transparent);
|
background: var(--panel-flat, transparent);
|
||||||
}
|
}
|
||||||
.pb-block-card.is-dragging {
|
.pb-block-card.is-dragging {
|
||||||
@@ -1082,7 +1106,7 @@ button[disabled] {
|
|||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
background: var(--blue);
|
background: var(--blue);
|
||||||
color: var(--accent-bright);
|
color: var(--accent-bright);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-input);
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-size: 0.85rem;
|
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.equal(calls[0].opts.method, 'DELETE')
|
||||||
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
390
modules/uo/server/model/shardAtlas/shardAtlas.db.js
Normal file
390
modules/uo/server/model/shardAtlas/shardAtlas.db.js
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
const { pool, query } = require('../../core')
|
||||||
|
|
||||||
|
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
|
||||||
|
// empties and refills all six inside one transaction, and nothing else in the
|
||||||
|
// codebase writes to them. There are no foreign keys, consistent with every
|
||||||
|
// other shard_* table.
|
||||||
|
|
||||||
|
const BATCH = 500
|
||||||
|
|
||||||
|
const ATLAS_TABLES = [
|
||||||
|
'shard_spawn_point_types',
|
||||||
|
'shard_spawn_points',
|
||||||
|
'shard_spawn_creatures',
|
||||||
|
'shard_regions',
|
||||||
|
'shard_landmarks',
|
||||||
|
'shard_champion_spawns',
|
||||||
|
]
|
||||||
|
|
||||||
|
async function insertBatched(conn, sql, rows) {
|
||||||
|
for (let i = 0; i < rows.length; i += BATCH) {
|
||||||
|
await conn.batch(sql, rows.slice(i, i + BATCH))
|
||||||
|
}
|
||||||
|
return rows.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the entire atlas in one transaction.
|
||||||
|
*
|
||||||
|
* All-or-nothing on purpose: a failed reload must leave the previous atlas
|
||||||
|
* intact rather than a half-loaded world, since a partially-imported atlas is
|
||||||
|
* indistinguishable from a real one to anyone reading it.
|
||||||
|
*
|
||||||
|
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
|
||||||
|
* commits, which would defeat exactly that guarantee. At ~7k rows the cost of
|
||||||
|
* `DELETE` is irrelevant.
|
||||||
|
*/
|
||||||
|
async function replaceAtlas(atlas, art = {}) {
|
||||||
|
const conn = await pool.getConnection()
|
||||||
|
const counts = {}
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction()
|
||||||
|
|
||||||
|
for (const table of ATLAS_TABLES) await conn.query(`DELETE FROM ${table}`)
|
||||||
|
|
||||||
|
counts.creatures = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_spawn_creatures (slug, name, total, points, facets, art) VALUES (?,?,?,?,?,?)',
|
||||||
|
atlas.creatures.map((c) => [
|
||||||
|
c.slug,
|
||||||
|
c.name,
|
||||||
|
c.total ?? 0,
|
||||||
|
c.points ?? 0,
|
||||||
|
JSON.stringify(c.facets ?? {}),
|
||||||
|
art[c.slug] ?? null,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts.regions = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_regions (facet, name, type, priority, parent, rects) VALUES (?,?,?,?,?,?)',
|
||||||
|
atlas.regions.map((r) => [
|
||||||
|
r.facet,
|
||||||
|
r.name,
|
||||||
|
r.type || null,
|
||||||
|
r.priority ?? 0,
|
||||||
|
r.parent || null,
|
||||||
|
JSON.stringify(r.rects ?? []),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts.landmarks = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_landmarks (facet, name, grp, x, y, z) VALUES (?,?,?,?,?,?)',
|
||||||
|
atlas.landmarks.map((l) => [
|
||||||
|
l.facet,
|
||||||
|
l.name,
|
||||||
|
l.group || null,
|
||||||
|
l.x ?? 0,
|
||||||
|
l.y ?? 0,
|
||||||
|
l.z ?? 0,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts.champions = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_champion_spawns ' +
|
||||||
|
'(slug, name, grp, type, random_type, facet, x, y, z, radius, label) ' +
|
||||||
|
'VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
atlas.champions.map((c) => [
|
||||||
|
c.slug,
|
||||||
|
c.name,
|
||||||
|
c.group || null,
|
||||||
|
c.type || null,
|
||||||
|
c.randomType ? 1 : 0,
|
||||||
|
c.facet,
|
||||||
|
c.x ?? 0,
|
||||||
|
c.y ?? 0,
|
||||||
|
c.z ?? 0,
|
||||||
|
c.radius ?? 0,
|
||||||
|
c.label || null,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the
|
||||||
|
// join rows need to know them and `conn.batch()` reports no usable insertId
|
||||||
|
// for a multi-row insert. Safe because this transaction just emptied the
|
||||||
|
// table and nothing else writes to it.
|
||||||
|
counts.points = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_spawn_points ' +
|
||||||
|
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
||||||
|
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
||||||
|
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
atlas.points.map((p, i) => [
|
||||||
|
i + 1,
|
||||||
|
p.facet,
|
||||||
|
p.name,
|
||||||
|
p.x,
|
||||||
|
p.y,
|
||||||
|
p.width ?? 0,
|
||||||
|
p.height ?? 0,
|
||||||
|
p.range ?? 0,
|
||||||
|
p.maxCount ?? 0,
|
||||||
|
p.minDelay ?? 0,
|
||||||
|
p.maxDelay ?? 0,
|
||||||
|
p.todStart ?? 0,
|
||||||
|
p.todEnd ?? 0,
|
||||||
|
p.todMode ?? 0,
|
||||||
|
p.region,
|
||||||
|
p.landmark,
|
||||||
|
p.label || 'Wilderness',
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts.pointTypes = await insertBatched(
|
||||||
|
conn,
|
||||||
|
'INSERT INTO shard_spawn_point_types (point_id, slug, max_count) VALUES (?,?,?)',
|
||||||
|
atlas.pointTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.query(
|
||||||
|
'INSERT INTO shard_atlas_meta (id, payload) VALUES (1, ?) ' +
|
||||||
|
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||||
|
[JSON.stringify({ ...atlas.meta, importedCounts: counts })],
|
||||||
|
)
|
||||||
|
|
||||||
|
// A completed import answers whatever was pending.
|
||||||
|
await conn.query('DELETE FROM shard_atlas_pending')
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
return counts
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback().catch(() => {})
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
conn.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMeta() {
|
||||||
|
const rows = await query('SELECT payload, imported_at FROM shard_atlas_meta WHERE id = 1')
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||||
|
return { ...payload, importedAt: rows[0].imported_at }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Facet names currently loaded, used to detect a facet disappearing. */
|
||||||
|
async function getFacets() {
|
||||||
|
const rows = await query('SELECT DISTINCT facet FROM shard_spawn_points ORDER BY facet')
|
||||||
|
return rows.map((row) => row.facet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pending review ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getPending() {
|
||||||
|
const rows = await query('SELECT payload, status, detected_at FROM shard_atlas_pending WHERE id = 1')
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||||
|
return { ...payload, status: rows[0].status, detectedAt: rows[0].detected_at }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPending(payload, status = 'pending') {
|
||||||
|
return query(
|
||||||
|
'INSERT INTO shard_atlas_pending (id, status, payload) VALUES (1, ?, ?) ' +
|
||||||
|
'ON DUPLICATE KEY UPDATE status = VALUES(status), payload = VALUES(payload), ' +
|
||||||
|
'detected_at = CURRENT_TIMESTAMP',
|
||||||
|
[status, JSON.stringify(payload)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPending() {
|
||||||
|
return query('DELETE FROM shard_atlas_pending')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reads (the public /atlas surface) ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every read here is a plain indexed query over ~7k rows and is served entirely
|
||||||
|
// from MariaDB: the atlas is static shard content, so nothing on this path
|
||||||
|
// touches the sidecar and nothing degrades when the shard is down.
|
||||||
|
//
|
||||||
|
// A facet filter is expressed as EXISTS over the points, never as a JSON path
|
||||||
|
// built from caller input. `shard_spawn_creatures.facets` is a JSON object keyed
|
||||||
|
// by facet name, and matching a key means either concatenating the name into a
|
||||||
|
// path or handing it to JSON_SEARCH — whose search string treats `%` and `_` as
|
||||||
|
// wildcards, so `?facet=%` would quietly match everything. The join is exact and
|
||||||
|
// uses the indexes that already exist.
|
||||||
|
const CREATURE_FACET_EXISTS = `EXISTS (
|
||||||
|
SELECT 1 FROM shard_spawn_point_types t
|
||||||
|
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||||
|
WHERE t.slug = c.slug AND p.facet = ?
|
||||||
|
)`
|
||||||
|
|
||||||
|
// Build the WHERE for a creature search. `q` is a substring match on the display
|
||||||
|
// name — a LIKE scan, which is free at ~800 rows and, unlike FULLTEXT, has no
|
||||||
|
// minimum token length to break a search for "orc".
|
||||||
|
function creatureWhere({ q, facet }) {
|
||||||
|
const where = []
|
||||||
|
const params = []
|
||||||
|
if (q) {
|
||||||
|
where.push('c.name LIKE ?')
|
||||||
|
params.push(`%${q}%`)
|
||||||
|
}
|
||||||
|
if (facet) {
|
||||||
|
where.push(CREATURE_FACET_EXISTS)
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
return { sql: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countCreatures({ q = '', facet = '' } = {}) {
|
||||||
|
const { sql, params } = creatureWhere({ q, facet })
|
||||||
|
const rows = await query(`SELECT COUNT(*) AS n FROM shard_spawn_creatures c ${sql}`, params)
|
||||||
|
return rows[0] ? Number(rows[0].n) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||||
|
const { sql, params } = creatureWhere({ q, facet })
|
||||||
|
return query(
|
||||||
|
`SELECT c.slug, c.name, c.total, c.points, c.facets, c.art
|
||||||
|
FROM shard_spawn_creatures c
|
||||||
|
${sql}
|
||||||
|
ORDER BY c.total DESC, c.name ASC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...params, limit, offset],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCreature(slug) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT slug, name, total, points, facets, art FROM shard_spawn_creatures WHERE slug = ?',
|
||||||
|
[slug],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a creature spawns, grouped by resolved place.
|
||||||
|
*
|
||||||
|
* This is the answer the atlas exists to give — "lizardman → Shrines,
|
||||||
|
* Isamu-Jima, Yew" — so it is aggregated in SQL rather than by summing 6,455
|
||||||
|
* point rows in Node.
|
||||||
|
*/
|
||||||
|
function listCreaturePlaces(slug, { facet = '' } = {}) {
|
||||||
|
const params = [slug]
|
||||||
|
let facetSql = ''
|
||||||
|
if (facet) {
|
||||||
|
facetSql = 'AND p.facet = ?'
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT p.facet, p.label, COUNT(*) AS spawners, SUM(t.max_count) AS max_alive
|
||||||
|
FROM shard_spawn_point_types t
|
||||||
|
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||||
|
WHERE t.slug = ? ${facetSql}
|
||||||
|
GROUP BY p.facet, p.label
|
||||||
|
ORDER BY spawners DESC, p.facet ASC, p.label ASC`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The individual spawners for a creature, newest-largest first. Bounded. */
|
||||||
|
function listCreaturePoints(slug, { facet = '', limit = 200 } = {}) {
|
||||||
|
const params = [slug]
|
||||||
|
let facetSql = ''
|
||||||
|
if (facet) {
|
||||||
|
facetSql = 'AND p.facet = ?'
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
params.push(limit)
|
||||||
|
return query(
|
||||||
|
`SELECT p.id, p.facet, p.name, p.x, p.y, p.width, p.height, p.spawn_range,
|
||||||
|
p.min_delay, p.max_delay, p.tod_start, p.tod_end, p.tod_mode,
|
||||||
|
p.region, p.landmark, p.label, t.max_count
|
||||||
|
FROM shard_spawn_point_types t
|
||||||
|
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||||
|
WHERE t.slug = ? ${facetSql}
|
||||||
|
ORDER BY t.max_count DESC, p.facet ASC, p.label ASC, p.id ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every other creature sharing a spawner with this one. */
|
||||||
|
function listCreatureCompanions(slug, { limit = 24 } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT o.slug, c.name, COUNT(*) AS shared
|
||||||
|
FROM shard_spawn_point_types t
|
||||||
|
JOIN shard_spawn_point_types o ON o.point_id = t.point_id AND o.slug <> t.slug
|
||||||
|
JOIN shard_spawn_creatures c ON c.slug = o.slug
|
||||||
|
WHERE t.slug = ?
|
||||||
|
GROUP BY o.slug, c.name
|
||||||
|
ORDER BY shared DESC, c.name ASC
|
||||||
|
LIMIT ?`,
|
||||||
|
[slug, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRegions({ facet = '', q = '' } = {}) {
|
||||||
|
const where = []
|
||||||
|
const params = []
|
||||||
|
if (facet) {
|
||||||
|
where.push('facet = ?')
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
where.push('name LIKE ?')
|
||||||
|
params.push(`%${q}%`)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT facet, name, type, priority, parent, rects
|
||||||
|
FROM shard_regions
|
||||||
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||||
|
ORDER BY facet ASC, name ASC`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLandmarks({ facet = '', q = '' } = {}) {
|
||||||
|
const where = []
|
||||||
|
const params = []
|
||||||
|
if (facet) {
|
||||||
|
where.push('facet = ?')
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
where.push('(name LIKE ? OR grp LIKE ?)')
|
||||||
|
params.push(`%${q}%`, `%${q}%`)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT facet, name, grp, x, y, z
|
||||||
|
FROM shard_landmarks
|
||||||
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||||
|
ORDER BY facet ASC, grp ASC, name ASC`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listChampions({ facet = '' } = {}) {
|
||||||
|
const params = []
|
||||||
|
let where = ''
|
||||||
|
if (facet) {
|
||||||
|
where = 'WHERE facet = ?'
|
||||||
|
params.push(facet)
|
||||||
|
}
|
||||||
|
return query(
|
||||||
|
`SELECT slug, name, grp, type, random_type, facet, x, y, z, radius, label
|
||||||
|
FROM shard_champion_spawns
|
||||||
|
${where}
|
||||||
|
ORDER BY facet ASC, name ASC`,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
replaceAtlas,
|
||||||
|
getMeta,
|
||||||
|
getFacets,
|
||||||
|
getPending,
|
||||||
|
setPending,
|
||||||
|
clearPending,
|
||||||
|
countCreatures,
|
||||||
|
listCreatures,
|
||||||
|
getCreature,
|
||||||
|
listCreaturePlaces,
|
||||||
|
listCreaturePoints,
|
||||||
|
listCreatureCompanions,
|
||||||
|
listRegions,
|
||||||
|
listLandmarks,
|
||||||
|
listChampions,
|
||||||
|
}
|
||||||
485
modules/uo/server/model/shardAtlas/shardAtlas.model.js
Normal file
485
modules/uo/server/model/shardAtlas/shardAtlas.model.js
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const db = require('./shardAtlas.db')
|
||||||
|
const { settings } = require('../../core')
|
||||||
|
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||||
|
const {
|
||||||
|
AtlasSourceError,
|
||||||
|
PARSER_VERSION,
|
||||||
|
buildAtlas,
|
||||||
|
hashSources,
|
||||||
|
sameSources,
|
||||||
|
} = require('../../utils/spawnAtlasSource')
|
||||||
|
const log = require('../../core').logger('atlas')
|
||||||
|
|
||||||
|
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||||
|
//
|
||||||
|
// The tree is the single source of truth. Nothing is precomputed and committed,
|
||||||
|
// because a shard's maps change over its lifetime — facets get added, replaced
|
||||||
|
// or renamed — and a snapshot in the repo would go stale against the world
|
||||||
|
// players actually see. So the atlas is re-derived on every boot.
|
||||||
|
//
|
||||||
|
// Two rules govern the boot path:
|
||||||
|
//
|
||||||
|
// 1. **It never blocks startup.** No configured path, an unreadable path, a
|
||||||
|
// malformed file, a database error — all of it is caught and logged. The
|
||||||
|
// site comes up either way, serving whatever atlas it already had.
|
||||||
|
// 2. **A facet disappearing is not applied automatically.** Losing a facet is
|
||||||
|
// the signature of a half-copied or mid-update tree as much as of a real
|
||||||
|
// map change, and the two are indistinguishable from here. The refresh is
|
||||||
|
// staged for a human instead, and an admin approves or rejects it.
|
||||||
|
//
|
||||||
|
// Everything else — new facets, renamed regions, changed spawns — applies
|
||||||
|
// straight away, because none of it can silently destroy data an operator would
|
||||||
|
// miss.
|
||||||
|
|
||||||
|
const SETTING_KEY = 'spawn_atlas_servuo_path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the ServUO tree lives.
|
||||||
|
*
|
||||||
|
* The admin setting wins over the environment so an operator can point the
|
||||||
|
* atlas at a different tree without a redeploy, matching how the rest of the
|
||||||
|
* shard integration is admin-managed rather than env-configured. `SERVUO_PATH`
|
||||||
|
* remains as the deploy-time default, since the path usually describes a mount
|
||||||
|
* that the deployment sets up.
|
||||||
|
*/
|
||||||
|
async function getServuoPath() {
|
||||||
|
try {
|
||||||
|
const configured = await settings.get(SETTING_KEY)
|
||||||
|
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
||||||
|
} catch {
|
||||||
|
// Settings unavailable is not fatal — fall through to the env default.
|
||||||
|
}
|
||||||
|
const fromEnv = process.env.SERVUO_PATH
|
||||||
|
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setServuoPath(value, updatedBy = null) {
|
||||||
|
return settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional operator-supplied art map, `{ "<slug>": "<file under uploads/atlas/>" }`.
|
||||||
|
*
|
||||||
|
* Never committed and never shipped — creature sprites come out of the
|
||||||
|
* operator's own client `.mul`/`.uop` files, which are theirs, not ours to
|
||||||
|
* redistribute. Absent (the normal case) every `art` stays NULL and the UI
|
||||||
|
* renders text-only.
|
||||||
|
*/
|
||||||
|
function loadArtMap(dir = path.join(__dirname, '..', '..', '..', 'db', 'data')) {
|
||||||
|
try {
|
||||||
|
const file = path.join(dir, 'spawnAtlas.art.json')
|
||||||
|
if (!fs.existsSync(file)) return {}
|
||||||
|
const map = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||||
|
return map && typeof map === 'object' ? map : {}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('spawn atlas art map could not be read', { error: err.message })
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten each point's types into `shard_spawn_point_types` rows.
|
||||||
|
*
|
||||||
|
* A spawner may legitimately list the same type twice, and the primary key is
|
||||||
|
* (point_id, slug), so duplicates collapse to the larger max rather than
|
||||||
|
* failing the insert.
|
||||||
|
*/
|
||||||
|
function pointTypeRows(points) {
|
||||||
|
const rows = []
|
||||||
|
points.forEach((point, i) => {
|
||||||
|
const bySlug = new Map()
|
||||||
|
for (const entry of point.types ?? []) {
|
||||||
|
const slug = slugify(entry.type)
|
||||||
|
if (slug === '') continue
|
||||||
|
bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1))
|
||||||
|
}
|
||||||
|
for (const [slug, max] of bySlug) rows.push([i + 1, slug, max])
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAtlas(atlas) {
|
||||||
|
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the atlas from the configured ServUO tree.
|
||||||
|
*
|
||||||
|
* Returns a result describing what happened rather than throwing, so the caller
|
||||||
|
* — including the boot path — can log it and move on:
|
||||||
|
*
|
||||||
|
* `skipped` no path configured
|
||||||
|
* `unavailable` path configured but unreadable / missing required files
|
||||||
|
* `unchanged` source hashes match the loaded atlas; nothing parsed
|
||||||
|
* `imported` parsed and applied
|
||||||
|
* `needsReview` parsed, but a facet would be lost; staged for an admin
|
||||||
|
* `failed` parsed or applied and something went wrong
|
||||||
|
*
|
||||||
|
* `force` skips the hash check (an admin asking for a reimport) and `approve`
|
||||||
|
* additionally accepts facet loss (an admin approving a staged refresh).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Was the loaded atlas built by THIS parser?
|
||||||
|
*
|
||||||
|
* An atlas imported before `parserVersion` existed reports undefined, which is
|
||||||
|
* correctly "no" — those are exactly the ones carrying the old readings.
|
||||||
|
*/
|
||||||
|
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||||
|
|
||||||
|
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||||
|
// An explicit override wins outright — it is a one-off "use this tree", and it
|
||||||
|
// must not be silently overruled by the configured path the way an env default
|
||||||
|
// would be.
|
||||||
|
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||||
|
if (root === '') return { status: 'skipped', reason: 'no ServUO path configured' }
|
||||||
|
|
||||||
|
let hashes
|
||||||
|
try {
|
||||||
|
hashes = hashSources(root)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AtlasSourceError) {
|
||||||
|
return { status: 'unavailable', reason: err.message, code: err.code, path: root }
|
||||||
|
}
|
||||||
|
return { status: 'failed', reason: err.message, path: root }
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await db.getMeta().catch(() => null)
|
||||||
|
const loaded = meta?.source
|
||||||
|
? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256]))
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Two things make a loaded atlas stale: the tree changed, or the PARSER did.
|
||||||
|
// Only checking the tree would strand an install whose maps never change on
|
||||||
|
// whatever an older build derived — a corrected parse would ship and never
|
||||||
|
// reach the data.
|
||||||
|
if (!force && sameSources(hashes, loaded) && currentParser(meta)) {
|
||||||
|
return { status: 'unchanged', path: root }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rejected refresh must not re-prompt on every boot. It stays rejected until
|
||||||
|
// the tree changes again, at which point the hashes differ and it is a new
|
||||||
|
// decision.
|
||||||
|
const pending = await db.getPending().catch(() => null)
|
||||||
|
if (!approve && !force && pending?.status === 'rejected' && sameSources(hashes, pending.hashes)) {
|
||||||
|
return { status: 'unchanged', path: root, reason: 'refresh previously rejected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let atlas
|
||||||
|
try {
|
||||||
|
atlas = buildAtlas(root)
|
||||||
|
} catch (err) {
|
||||||
|
return { status: 'failed', reason: err.message, path: root }
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFacets = await db.getFacets().catch(() => [])
|
||||||
|
const incomingFacets = atlas.facets
|
||||||
|
const removedFacets = currentFacets.filter((facet) => !incomingFacets.includes(facet))
|
||||||
|
const addedFacets = incomingFacets.filter((facet) => !currentFacets.includes(facet))
|
||||||
|
|
||||||
|
// Losing a facet is indistinguishable here from a half-copied tree, so it is
|
||||||
|
// staged rather than applied — but startup is never blocked by it.
|
||||||
|
if (removedFacets.length > 0 && !approve) {
|
||||||
|
const summary = {
|
||||||
|
hashes,
|
||||||
|
path: root,
|
||||||
|
currentFacets,
|
||||||
|
incomingFacets,
|
||||||
|
removedFacets,
|
||||||
|
addedFacets,
|
||||||
|
counts: atlas.meta.counts,
|
||||||
|
}
|
||||||
|
await db.setPending(summary, 'pending').catch((err) => {
|
||||||
|
log.warn('could not stage spawn atlas refresh', { error: err.message })
|
||||||
|
})
|
||||||
|
return { status: 'needsReview', ...summary }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const counts = await applyAtlas(atlas)
|
||||||
|
return { status: 'imported', path: root, counts, addedFacets, removedFacets }
|
||||||
|
} catch (err) {
|
||||||
|
return { status: 'failed', reason: err.message, path: root }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin approved a staged refresh: apply it, facet loss and all. */
|
||||||
|
async function approvePending(options = {}) {
|
||||||
|
return refresh({ ...options, approve: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin rejected a staged refresh: keep the current atlas and remember the
|
||||||
|
* decision against those exact source hashes, so it does not re-prompt every
|
||||||
|
* boot. A further change to the tree produces different hashes and asks again.
|
||||||
|
*/
|
||||||
|
async function rejectPending() {
|
||||||
|
const pending = await db.getPending()
|
||||||
|
if (!pending) return { status: 'none' }
|
||||||
|
await db.setPending({ ...pending, rejectedAt: new Date().toISOString() }, 'rejected')
|
||||||
|
return { status: 'rejected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the admin panel needs to describe atlas state. */
|
||||||
|
async function status({ path: pathOverride = '' } = {}) {
|
||||||
|
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||||
|
const [meta, pending, facets] = await Promise.all([
|
||||||
|
db.getMeta().catch(() => null),
|
||||||
|
db.getPending().catch(() => null),
|
||||||
|
db.getFacets().catch(() => []),
|
||||||
|
])
|
||||||
|
|
||||||
|
let treeReadable = false
|
||||||
|
let drift = null
|
||||||
|
if (root !== '') {
|
||||||
|
try {
|
||||||
|
const hashes = hashSources(root)
|
||||||
|
treeReadable = true
|
||||||
|
const loaded = meta?.source
|
||||||
|
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
|
||||||
|
: null
|
||||||
|
// Same question `refresh` asks: an import picks something up when either
|
||||||
|
// the tree or the parser has moved on.
|
||||||
|
drift = !sameSources(hashes, loaded) || !currentParser(meta)
|
||||||
|
} catch {
|
||||||
|
treeReadable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: root !== '',
|
||||||
|
path: root,
|
||||||
|
treeReadable,
|
||||||
|
drift,
|
||||||
|
facets,
|
||||||
|
importedAt: meta?.importedAt ?? null,
|
||||||
|
counts: meta?.counts ?? null,
|
||||||
|
pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
|
||||||
|
* missing tree or a bad file can never stop the site coming up.
|
||||||
|
*/
|
||||||
|
async function refreshOnBoot() {
|
||||||
|
try {
|
||||||
|
const result = await refresh()
|
||||||
|
switch (result.status) {
|
||||||
|
case 'imported':
|
||||||
|
log.info('spawn atlas refreshed from ServUO tree', {
|
||||||
|
...result.counts,
|
||||||
|
added: result.addedFacets,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'needsReview':
|
||||||
|
log.warn(
|
||||||
|
'spawn atlas refresh staged for admin review — a facet would be removed; ' +
|
||||||
|
'the existing atlas is unchanged',
|
||||||
|
{ removed: result.removedFacets, added: result.addedFacets },
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'unavailable':
|
||||||
|
log.warn('spawn atlas source unavailable', { reason: result.reason, path: result.path })
|
||||||
|
break
|
||||||
|
case 'failed':
|
||||||
|
log.warn('spawn atlas refresh failed', { reason: result.reason })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('spawn atlas refresh errored', { error: err.message })
|
||||||
|
return { status: 'failed', reason: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The shapes the /public/atlas endpoints serve. Rows are camelCased here rather
|
||||||
|
// than in the controller, for the same reason shardState does it: the column
|
||||||
|
// names are an implementation detail of the import, and the browser contract
|
||||||
|
// should not move when a column is renamed.
|
||||||
|
|
||||||
|
const jsonOr = (value, fallback) => {
|
||||||
|
if (value == null) return fallback
|
||||||
|
if (typeof value !== 'string') return value
|
||||||
|
try {
|
||||||
|
return JSON.parse(value)
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shapeCreature = (row) => ({
|
||||||
|
slug: row.slug,
|
||||||
|
name: row.name,
|
||||||
|
// `total` is the summed MaxCount across every spawner (how many can be alive
|
||||||
|
// at once); `points` is how many spawners mention it. They answer different
|
||||||
|
// questions and the UI shows both.
|
||||||
|
total: row.total,
|
||||||
|
points: row.points,
|
||||||
|
facets: jsonOr(row.facets, {}),
|
||||||
|
art: row.art || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const shapePlace = (row) => ({
|
||||||
|
facet: row.facet,
|
||||||
|
label: row.label,
|
||||||
|
spawners: Number(row.spawners) || 0,
|
||||||
|
maxAlive: Number(row.max_alive) || 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const shapePoint = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
facet: row.facet,
|
||||||
|
name: row.name || null,
|
||||||
|
x: row.x,
|
||||||
|
y: row.y,
|
||||||
|
width: row.width,
|
||||||
|
height: row.height,
|
||||||
|
range: row.spawn_range,
|
||||||
|
maxCount: row.max_count,
|
||||||
|
minDelay: row.min_delay,
|
||||||
|
maxDelay: row.max_delay,
|
||||||
|
todStart: row.tod_start,
|
||||||
|
todEnd: row.tod_end,
|
||||||
|
todMode: row.tod_mode,
|
||||||
|
region: row.region || null,
|
||||||
|
landmark: row.landmark || null,
|
||||||
|
label: row.label,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated creature search. Returns the page plus the unpaginated total, so
|
||||||
|
* the UI can say "showing 50 of 800" without a second round trip.
|
||||||
|
*/
|
||||||
|
async function searchCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
db.listCreatures({ q, facet, limit, offset }),
|
||||||
|
db.countCreatures({ q, facet }),
|
||||||
|
])
|
||||||
|
return { total, limit, offset, creatures: rows.map(shapeCreature) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One creature: its totals, the places it spawns (the aggregate the atlas
|
||||||
|
* exists for), the individual spawners, and what else shares those spawners.
|
||||||
|
*
|
||||||
|
* `null` when the slug is unknown — the controller turns that into a 404.
|
||||||
|
*/
|
||||||
|
async function getCreature(slug, { facet = '', points = 200 } = {}) {
|
||||||
|
const row = await db.getCreature(slug)
|
||||||
|
if (!row) return null
|
||||||
|
const [places, pointRows, alsoHere] = await Promise.all([
|
||||||
|
db.listCreaturePlaces(slug, { facet }),
|
||||||
|
db.listCreaturePoints(slug, { facet, limit: points }),
|
||||||
|
db.listCreatureCompanions(slug),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
...shapeCreature(row),
|
||||||
|
places: places.map(shapePlace),
|
||||||
|
// `spawners`, not `points`: shapeCreature already uses `points` for the
|
||||||
|
// COUNT of spawners, and reusing the key for the list of them would make the
|
||||||
|
// same field a number on the search route and an array here.
|
||||||
|
spawners: pointRows.map(shapePoint),
|
||||||
|
// Bounded by the query, so a creature on hundreds of spawners returns a page
|
||||||
|
// rather than the world.
|
||||||
|
spawnersTruncated: pointRows.length >= points,
|
||||||
|
alsoHere: alsoHere.map((r) => ({
|
||||||
|
slug: r.slug,
|
||||||
|
name: r.name,
|
||||||
|
shared: Number(r.shared) || 0,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRegions(opts = {}) {
|
||||||
|
const rows = await db.listRegions(opts)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
facet: r.facet,
|
||||||
|
name: r.name,
|
||||||
|
type: r.type || null,
|
||||||
|
priority: r.priority,
|
||||||
|
parent: r.parent || null,
|
||||||
|
rects: jsonOr(r.rects, []),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLandmarks(opts = {}) {
|
||||||
|
const rows = await db.listLandmarks(opts)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
facet: r.facet,
|
||||||
|
name: r.name,
|
||||||
|
group: r.grp || null,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listChampions(opts = {}) {
|
||||||
|
const rows = await db.listChampions(opts)
|
||||||
|
return rows.map((r) => ({
|
||||||
|
slug: r.slug,
|
||||||
|
name: r.name,
|
||||||
|
group: r.grp || null,
|
||||||
|
// '' on the wire means "randomised at activation"; `randomType` says so
|
||||||
|
// explicitly rather than making the client infer it from an empty string.
|
||||||
|
type: r.type || null,
|
||||||
|
randomType: !!r.random_type,
|
||||||
|
facet: r.facet,
|
||||||
|
x: r.x,
|
||||||
|
y: r.y,
|
||||||
|
z: r.z,
|
||||||
|
radius: r.radius,
|
||||||
|
label: r.label || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is loaded: the facet list, the counts, and when it was imported.
|
||||||
|
*
|
||||||
|
* Deliberately does NOT report the source path, the per-file hashes or whether
|
||||||
|
* a refresh is pending. Those describe the operator's filesystem, and this is a
|
||||||
|
* public endpoint; the admin status route carries them instead.
|
||||||
|
*/
|
||||||
|
async function publicMeta() {
|
||||||
|
const [meta, facets] = await Promise.all([
|
||||||
|
db.getMeta().catch(() => null),
|
||||||
|
db.getFacets().catch(() => []),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
importedAt: meta?.importedAt ?? null,
|
||||||
|
generatedAt: meta?.generatedAt ?? null,
|
||||||
|
// The parse counts, not the row counts: `unresolvedPoints` is what lets the
|
||||||
|
// page state its own placement accuracy instead of implying it is complete.
|
||||||
|
counts: meta?.counts ?? null,
|
||||||
|
facets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listFacets = () => db.getFacets()
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
refresh,
|
||||||
|
refreshOnBoot,
|
||||||
|
approvePending,
|
||||||
|
rejectPending,
|
||||||
|
status,
|
||||||
|
getServuoPath,
|
||||||
|
setServuoPath,
|
||||||
|
pointTypeRows,
|
||||||
|
loadArtMap,
|
||||||
|
SETTING_KEY,
|
||||||
|
searchCreatures,
|
||||||
|
getCreature,
|
||||||
|
listRegions,
|
||||||
|
listLandmarks,
|
||||||
|
listChampions,
|
||||||
|
listFacets,
|
||||||
|
publicMeta,
|
||||||
|
}
|
||||||
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 }
|
||||||
601
modules/uo/server/test/spawnAtlas.parse.test.js
Normal file
601
modules/uo/server/test/spawnAtlas.parse.test.js
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const {
|
||||||
|
parseXml,
|
||||||
|
parseObjects2,
|
||||||
|
parsePoints,
|
||||||
|
parseRegions,
|
||||||
|
parseLocations,
|
||||||
|
parseChampions,
|
||||||
|
buildPlacementIndex,
|
||||||
|
resolveRegion,
|
||||||
|
facetKey,
|
||||||
|
buildFacetIndex,
|
||||||
|
resolveFacetName,
|
||||||
|
slugify,
|
||||||
|
decodeEntities,
|
||||||
|
} = require('../utils/spawnAtlasParse')
|
||||||
|
|
||||||
|
// These parsers are pure and fs-free precisely so this suite can run in CI,
|
||||||
|
// where there is no ServUO tree. Every fixture below is a literal excerpt of a
|
||||||
|
// real shard file, trimmed — not invented shapes.
|
||||||
|
|
||||||
|
// ── parseObjects2 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseObjects2: single type', () => {
|
||||||
|
const types = parseObjects2('Jacob:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1')
|
||||||
|
assert.deepEqual(types, [{ type: 'Jacob', max: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: splits six types on :OBJ= and keeps each MX', () => {
|
||||||
|
// Verbatim from trammel.xml — the case that a naive split(':') destroys.
|
||||||
|
const raw =
|
||||||
|
'Gazer:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||||
|
':OBJ=Giantspider:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||||
|
':OBJ=Harpy:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||||
|
':OBJ=Headlessone:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||||
|
':OBJ=Lizardman:MX=3:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
|
||||||
|
':OBJ=Mongbat:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1'
|
||||||
|
const types = parseObjects2(raw)
|
||||||
|
assert.equal(types.length, 6)
|
||||||
|
assert.deepEqual(
|
||||||
|
types.map((t) => t.type),
|
||||||
|
['Gazer', 'Giantspider', 'Harpy', 'Headlessone', 'Lizardman', 'Mongbat'],
|
||||||
|
)
|
||||||
|
// MX is per type, not per spawner: the lizardman entry carries 3.
|
||||||
|
assert.equal(types.find((t) => t.type === 'Lizardman').max, 3)
|
||||||
|
assert.equal(types.find((t) => t.type === 'Gazer').max, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: empty and whitespace values yield no types', () => {
|
||||||
|
assert.deepEqual(parseObjects2(''), [])
|
||||||
|
assert.deepEqual(parseObjects2(' '), [])
|
||||||
|
assert.deepEqual(parseObjects2(null), [])
|
||||||
|
assert.deepEqual(parseObjects2(undefined), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: strips XmlSpawner property directives after "/"', () => {
|
||||||
|
// Left in place these become creatures that do not exist.
|
||||||
|
assert.deepEqual(parseObjects2('Agralem/Name/Agralem:MX=1'), [{ type: 'Agralem', max: 1 }])
|
||||||
|
assert.deepEqual(parseObjects2('alchemist/z/-50:MX=1'), [{ type: 'alchemist', max: 1 }])
|
||||||
|
assert.deepEqual(parseObjects2('GargishRefugee/hue/34532'), [
|
||||||
|
{ type: 'GargishRefugee', max: 1 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: strips argument lists after ","', () => {
|
||||||
|
assert.deepEqual(parseObjects2('Fairy,{RND,4,8}:MX=1'), [{ type: 'Fairy', max: 1 }])
|
||||||
|
assert.deepEqual(parseObjects2('GargishRouser,1'), [{ type: 'GargishRouser', max: 1 }])
|
||||||
|
assert.deepEqual(parseObjects2('greatape,true'), [{ type: 'greatape', max: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: a directive-laden token slugs the same as the bare one', () => {
|
||||||
|
// The bug this closes: `Fairy` and `Fairy,{RND,4,8}` slugged apart and showed
|
||||||
|
// as two different creatures on the same page.
|
||||||
|
const bare = parseObjects2('Fairy:MX=1')[0]
|
||||||
|
const decorated = parseObjects2('Fairy,{RND,4,8}:MX=1')[0]
|
||||||
|
assert.equal(slugify(decorated.type), slugify(bare.type))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: strips a long EQUIP directive chain containing "<" and ">"', () => {
|
||||||
|
const raw =
|
||||||
|
'xmlquestnpc/UNEQUIP,Innertorso/UNEQUIP,MiddleTorso/EQUIP/<robe/loottype/blessed' +
|
||||||
|
'/itemid/8259>/blessed/true/name/lord blackthorne/z/:MX=1'
|
||||||
|
assert.deepEqual(parseObjects2(raw), [{ type: 'xmlquestnpc', max: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: a token that is only a directive yields nothing', () => {
|
||||||
|
assert.deepEqual(parseObjects2('/Name/Foo:MX=1'), [])
|
||||||
|
assert.deepEqual(parseObjects2(',1:MX=1'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseObjects2: a type with no MX token defaults to 1', () => {
|
||||||
|
assert.deepEqual(parseObjects2('Orc'), [{ type: 'Orc', max: 1 }])
|
||||||
|
assert.deepEqual(parseObjects2('Orc:SB=0:RT=0'), [{ type: 'Orc', max: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── parsePoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const POINTS_XML = `<Spawns>
|
||||||
|
<Points>
|
||||||
|
<Name>CovetousSpawner26</Name>
|
||||||
|
<UniqueId>001a34e5-0efa-46de-9c93-b6a163d96370</UniqueId>
|
||||||
|
<Map>Trammel</Map>
|
||||||
|
<X>5412</X>
|
||||||
|
<Y>1970</Y>
|
||||||
|
<Width>10</Width>
|
||||||
|
<Height>10</Height>
|
||||||
|
<Range>5</Range>
|
||||||
|
<MaxCount>3</MaxCount>
|
||||||
|
<MinDelay>5</MinDelay>
|
||||||
|
<MaxDelay>10</MaxDelay>
|
||||||
|
<ProximityTriggerSound>500</ProximityTriggerSound>
|
||||||
|
<TODStart>0</TODStart>
|
||||||
|
<TODEnd>0</TODEnd>
|
||||||
|
<TODMode>0</TODMode>
|
||||||
|
<IsRunning>True</IsRunning>
|
||||||
|
<Objects2>Lizardman:MX=3:SB=0</Objects2>
|
||||||
|
</Points>
|
||||||
|
<Points>
|
||||||
|
<Name>Disabled</Name>
|
||||||
|
<Map>Felucca</Map>
|
||||||
|
<X>100</X>
|
||||||
|
<Y>200</Y>
|
||||||
|
<MaxCount>1</MaxCount>
|
||||||
|
<IsRunning>False</IsRunning>
|
||||||
|
<Objects2>Orc:MX=1</Objects2>
|
||||||
|
</Points>
|
||||||
|
</Spawns>`
|
||||||
|
|
||||||
|
test('parsePoints: reads the kept fields and drops the rest', () => {
|
||||||
|
const points = parsePoints(POINTS_XML)
|
||||||
|
assert.equal(points.length, 2)
|
||||||
|
const covetous = points[0]
|
||||||
|
assert.equal(covetous.name, 'CovetousSpawner26')
|
||||||
|
assert.equal(covetous.facet, 'Trammel')
|
||||||
|
assert.equal(covetous.x, 5412)
|
||||||
|
assert.equal(covetous.y, 1970)
|
||||||
|
assert.equal(covetous.width, 10)
|
||||||
|
assert.equal(covetous.range, 5)
|
||||||
|
assert.equal(covetous.maxCount, 3)
|
||||||
|
// Delays are normalised to seconds; this record carries no DelayInSec, which
|
||||||
|
// means minutes.
|
||||||
|
assert.equal(covetous.minDelay, 300)
|
||||||
|
assert.equal(covetous.maxDelay, 600)
|
||||||
|
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
||||||
|
// Dropped fields must not survive into the artifact — this is what keeps it
|
||||||
|
// under 1 MB.
|
||||||
|
assert.equal(covetous.uniqueId, undefined)
|
||||||
|
assert.equal(covetous.proximityTriggerSound, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parsePoints: IsRunning is parsed so the build can drop dead spawners', () => {
|
||||||
|
const points = parsePoints(POINTS_XML)
|
||||||
|
assert.equal(points[0].running, true)
|
||||||
|
assert.equal(points[1].running, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parsePoints: facet comes from <Map>, never the file name', () => {
|
||||||
|
// Eodon.xml holds TerMur points; a file-name assumption would mislabel every
|
||||||
|
// one of them.
|
||||||
|
const points = parsePoints(
|
||||||
|
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
||||||
|
)
|
||||||
|
assert.equal(points[0].facet, 'TerMur')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parsePoints: a record with no <Map> is skipped rather than misfiled', () => {
|
||||||
|
const points = parsePoints('<Spawns><Points><Name>a</Name><X>1</X><Y>2</Y></Points></Spawns>')
|
||||||
|
assert.deepEqual(points, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parsePoints: empty document yields no points', () => {
|
||||||
|
assert.deepEqual(parsePoints('<Spawns></Spawns>'), [])
|
||||||
|
assert.deepEqual(parsePoints(''), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── parseRegions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const REGIONS_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ServerRegions>
|
||||||
|
<Facet name="Felucca">
|
||||||
|
<region type="GuardedRegion" priority="50" name="Moongates">
|
||||||
|
<!-- britain -->
|
||||||
|
<rect x="1330" y="1991" width="13" height="13" />
|
||||||
|
<rect x="761" y="741" width="19" height="21" />
|
||||||
|
</region>
|
||||||
|
<region type="MondainRegion" priority="50" name="Prism of Light">
|
||||||
|
<rect x="6400" y="0" width="221" height="255" />
|
||||||
|
<go x="6474" y="188" z="0" />
|
||||||
|
<music name="Dungeon9" />
|
||||||
|
<region type="CrystalField" name="Crystal Field">
|
||||||
|
<rect x="6506" y="83" width="7" height="7" />
|
||||||
|
<zrange min="-4" />
|
||||||
|
</region>
|
||||||
|
<region type="IcyRiver">
|
||||||
|
<rect x="6576" y="73" width="10" height="31" />
|
||||||
|
</region>
|
||||||
|
</region>
|
||||||
|
<region type="TownRegion" priority="10" name="Music Only">
|
||||||
|
<music name="Britain" />
|
||||||
|
</region>
|
||||||
|
</Facet>
|
||||||
|
</ServerRegions>`
|
||||||
|
|
||||||
|
test('parseRegions: flattens nested regions and collects rects', () => {
|
||||||
|
const regions = parseRegions(REGIONS_XML)
|
||||||
|
const byName = new Map(regions.map((r) => [r.name, r]))
|
||||||
|
assert.ok(byName.has('Moongates'))
|
||||||
|
assert.ok(byName.has('Prism of Light'))
|
||||||
|
assert.equal(byName.get('Moongates').rects.length, 2)
|
||||||
|
assert.deepEqual(byName.get('Moongates').rects[0], {
|
||||||
|
x: 1330,
|
||||||
|
y: 1991,
|
||||||
|
width: 13,
|
||||||
|
height: 13,
|
||||||
|
})
|
||||||
|
assert.equal(byName.get('Moongates').facet, 'Felucca')
|
||||||
|
assert.equal(byName.get('Moongates').type, 'GuardedRegion')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseRegions: a nested child records its parent', () => {
|
||||||
|
const regions = parseRegions(REGIONS_XML)
|
||||||
|
const crystal = regions.find((r) => r.name === 'Crystal Field')
|
||||||
|
assert.ok(crystal, 'the nested named region should be indexed')
|
||||||
|
assert.equal(crystal.parent, 'Prism of Light')
|
||||||
|
assert.equal(crystal.facet, 'Felucca')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseRegions: a child with no priority inherits its parent', () => {
|
||||||
|
// Defaulting to 0 instead would sort this specific room below every
|
||||||
|
// top-level region that contains it.
|
||||||
|
const crystal = parseRegions(REGIONS_XML).find((r) => r.name === 'Crystal Field')
|
||||||
|
assert.equal(crystal.priority, 50)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseRegions: unnamed regions are skipped but still walked', () => {
|
||||||
|
const regions = parseRegions(REGIONS_XML)
|
||||||
|
// IcyRiver has a type but no name — it cannot label anything.
|
||||||
|
assert.equal(regions.some((r) => r.type === 'IcyRiver'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseRegions: a named region with no rects is not indexed', () => {
|
||||||
|
// It can never contain a point, so indexing it only costs scan time.
|
||||||
|
assert.equal(parseRegions(REGIONS_XML).some((r) => r.name === 'Music Only'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── parseLocations ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LOCATIONS_XML = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
|
||||||
|
<places>
|
||||||
|
<parent name="Trammel">
|
||||||
|
<parent name="Dungeons">
|
||||||
|
<parent name="Covetous">
|
||||||
|
<child name="Entrance" x="2499" y="919" z="0" />
|
||||||
|
<child name="Level 1" x="5456" y="1863" z="0" />
|
||||||
|
</parent>
|
||||||
|
<parent name="Despise">
|
||||||
|
<child name="Level 3" x="5407" y="859" z="45" />
|
||||||
|
</parent>
|
||||||
|
</parent>
|
||||||
|
</parent>
|
||||||
|
</places>`
|
||||||
|
|
||||||
|
test('parseLocations: flattens to points carrying their group', () => {
|
||||||
|
const landmarks = parseLocations(LOCATIONS_XML)
|
||||||
|
assert.equal(landmarks.length, 3)
|
||||||
|
const level1 = landmarks.find((l) => l.name === 'Level 1')
|
||||||
|
assert.equal(level1.x, 5456)
|
||||||
|
assert.equal(level1.y, 1863)
|
||||||
|
assert.equal(level1.z, 0)
|
||||||
|
assert.equal(level1.facet, 'Trammel')
|
||||||
|
// "Covetous" is the useful label, not "Level 1".
|
||||||
|
assert.equal(level1.group, 'Covetous')
|
||||||
|
// The facet-level parent is dropped from the path.
|
||||||
|
assert.deepEqual(level1.path, ['Dungeons', 'Covetous'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Facet canonicalisation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Facets are NOT a fixed list — a shard may add, replace or rename them when its
|
||||||
|
// maps are updated, so nothing may hardcode the stock six. Reconciliation is by
|
||||||
|
// matching against whatever the shard's own files declare.
|
||||||
|
|
||||||
|
test('facetKey: collapses spelling differences to one key', () => {
|
||||||
|
assert.equal(facetKey('Ter Mur'), facetKey('TerMur'))
|
||||||
|
assert.equal(facetKey('ter-mur'), facetKey('TerMur'))
|
||||||
|
assert.equal(facetKey('Felucca'), 'felucca')
|
||||||
|
assert.equal(facetKey(''), '')
|
||||||
|
assert.equal(facetKey(null), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('facetKey: distinct facets keep distinct keys', () => {
|
||||||
|
assert.notEqual(facetKey('Felucca'), facetKey('Trammel'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFacetName: matches a loose spelling to the discovered canonical', () => {
|
||||||
|
// The canonical set comes from the shard's own spawn/region data, not a table.
|
||||||
|
const index = buildFacetIndex(['TerMur', 'Tokuno', 'Felucca'])
|
||||||
|
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
|
||||||
|
assert.equal(resolveFacetName('Tokuno Islands', index), 'Tokuno')
|
||||||
|
assert.equal(resolveFacetName('felucca', index), 'Felucca')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFacetName: works for facets that do not exist in stock UO', () => {
|
||||||
|
// The whole point: a shard running its own maps gets the same treatment as
|
||||||
|
// the stock ones, with no entry anywhere naming them.
|
||||||
|
const index = buildFacetIndex(['Sosaria', 'The Underdark'])
|
||||||
|
assert.equal(resolveFacetName('sosaria', index), 'Sosaria')
|
||||||
|
assert.equal(resolveFacetName('The Underdark', index), 'The Underdark')
|
||||||
|
assert.equal(resolveFacetName('the-underdark', index), 'The Underdark')
|
||||||
|
// Same shape as the real `Tokuno Islands` → `Tokuno` case.
|
||||||
|
assert.equal(resolveFacetName('Sosaria Isles', index), 'Sosaria')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFacetName: a merely similar name is NOT forced to match', () => {
|
||||||
|
// "Underdark Isles" is not a prefix of "The Underdark" in either direction.
|
||||||
|
// Keeping its own name is right — a wrong match would silently file a real
|
||||||
|
// custom facet's landmarks under the wrong facet.
|
||||||
|
const index = buildFacetIndex(['The Underdark'])
|
||||||
|
assert.equal(resolveFacetName('Underdark Isles', index), 'Underdark Isles')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFacetName: prefers the longer match when several could prefix', () => {
|
||||||
|
const index = buildFacetIndex(['Tokuno', 'TokunoDeep'])
|
||||||
|
assert.equal(resolveFacetName('TokunoDeep Reaches', index), 'TokunoDeep')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveFacetName: an unmatched facet keeps its own name', () => {
|
||||||
|
// Inventing a match would be worse than leaving a real custom facet alone.
|
||||||
|
const index = buildFacetIndex(['Felucca'])
|
||||||
|
assert.equal(resolveFacetName('Ilshenar', index), 'Ilshenar')
|
||||||
|
assert.equal(resolveFacetName('', index), '')
|
||||||
|
assert.equal(resolveFacetName(null, index), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildFacetIndex: first spelling wins and is stable', () => {
|
||||||
|
const index = buildFacetIndex(['TerMur', 'Ter Mur', 'ter-mur'])
|
||||||
|
assert.equal(index.size, 1)
|
||||||
|
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parsePoints and parseRegions report facet names verbatim', () => {
|
||||||
|
// <Map> and <Facet name> are the authority; they are never rewritten.
|
||||||
|
const points = parsePoints(
|
||||||
|
'<Spawns><Points><Name>a</Name><Map>Sosaria</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
||||||
|
)
|
||||||
|
assert.equal(points[0].facet, 'Sosaria')
|
||||||
|
const regions = parseRegions(
|
||||||
|
'<ServerRegions><Facet name="Sosaria"><region name="Town" priority="1">' +
|
||||||
|
'<rect x="0" y="0" width="10" height="10"/></region></Facet></ServerRegions>',
|
||||||
|
)
|
||||||
|
assert.equal(regions[0].facet, 'Sosaria')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('placement index buckets two spellings of one facet together', () => {
|
||||||
|
// This is the bug the key exists to prevent: unreconciled, the landmark bucket
|
||||||
|
// is keyed apart from the points looking it up, the fallback never fires, and
|
||||||
|
// every unregioned spawn on that facet silently reads "Wilderness".
|
||||||
|
const index = buildPlacementIndex(
|
||||||
|
[],
|
||||||
|
[{ facet: 'Ter Mur', name: 'Bank', group: 'Holy City', path: [], x: 1000, y: 1000, z: 0 }],
|
||||||
|
)
|
||||||
|
assert.equal(resolveRegion(1000, 1000, 'TerMur', index).landmark, 'Holy City')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── parseChampions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CHAMPIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<championSystem>
|
||||||
|
<!-- comment describing the schema -->
|
||||||
|
<spawn name="Deceit" group="FelDungeons" type="UnholyTerror">
|
||||||
|
<location x="5178" y="708" z="20" map="Felucca" radius="60" />
|
||||||
|
</spawn>
|
||||||
|
<spawn name="Wandering" group="FelDungeons">
|
||||||
|
<location x="100" y="200" z="0" map="Felucca" radius="40" />
|
||||||
|
</spawn>
|
||||||
|
</championSystem>`
|
||||||
|
|
||||||
|
test('parseChampions: reads altar name, type and location', () => {
|
||||||
|
const champs = parseChampions(CHAMPIONS_XML)
|
||||||
|
assert.equal(champs.length, 2)
|
||||||
|
assert.deepEqual(champs[0], {
|
||||||
|
name: 'Deceit',
|
||||||
|
group: 'FelDungeons',
|
||||||
|
type: 'UnholyTerror',
|
||||||
|
randomType: false,
|
||||||
|
facet: 'Felucca',
|
||||||
|
x: 5178,
|
||||||
|
y: 708,
|
||||||
|
z: 20,
|
||||||
|
radius: 60,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseChampions: a spawn with no type is flagged random, not blank', () => {
|
||||||
|
const champs = parseChampions(CHAMPIONS_XML)
|
||||||
|
assert.equal(champs[1].randomType, true)
|
||||||
|
assert.equal(champs[1].type, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── resolveRegion ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fixtureIndex() {
|
||||||
|
const regions = [
|
||||||
|
{
|
||||||
|
facet: 'Felucca',
|
||||||
|
name: 'Britain',
|
||||||
|
type: 'TownRegion',
|
||||||
|
priority: 10,
|
||||||
|
parent: null,
|
||||||
|
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
facet: 'Felucca',
|
||||||
|
name: 'Britain Bank',
|
||||||
|
type: 'TownRegion',
|
||||||
|
priority: 50,
|
||||||
|
parent: 'Britain',
|
||||||
|
rects: [{ x: 1400, y: 1400, width: 20, height: 20 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
facet: 'Felucca',
|
||||||
|
name: 'Wide Low Priority',
|
||||||
|
type: 'TownRegion',
|
||||||
|
priority: 10,
|
||||||
|
parent: null,
|
||||||
|
rects: [{ x: 1000, y: 1000, width: 2000, height: 2000 }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const landmarks = [
|
||||||
|
{ facet: 'Felucca', name: 'Level 1', group: 'Covetous', path: [], x: 5000, y: 5000, z: 0 },
|
||||||
|
{ facet: 'Felucca', name: 'Far Away', group: 'Vesper', path: [], x: 9000, y: 9000, z: 0 },
|
||||||
|
]
|
||||||
|
return buildPlacementIndex(regions, landmarks)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('resolveRegion: a contained point takes the region name', () => {
|
||||||
|
const result = resolveRegion(1100, 1100, 'Felucca', fixtureIndex())
|
||||||
|
assert.equal(result.region, 'Britain')
|
||||||
|
assert.equal(result.label, 'Britain')
|
||||||
|
assert.equal(result.landmark, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: higher priority wins over a containing region', () => {
|
||||||
|
const result = resolveRegion(1410, 1410, 'Felucca', fixtureIndex())
|
||||||
|
assert.equal(result.region, 'Britain Bank')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: equal priority breaks toward the smaller rect', () => {
|
||||||
|
// Both "Britain" (500x500) and "Wide Low Priority" (2000x2000) contain this
|
||||||
|
// point at priority 10; the specific one must win.
|
||||||
|
const result = resolveRegion(1200, 1200, 'Felucca', fixtureIndex())
|
||||||
|
assert.equal(result.region, 'Britain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: rects are half-open — the far edge is outside', () => {
|
||||||
|
const index = fixtureIndex()
|
||||||
|
// Britain spans x 1000..1499. 1499 is in, 1500 belongs to the next region.
|
||||||
|
assert.equal(resolveRegion(1499, 1499, 'Felucca', index).region, 'Britain')
|
||||||
|
assert.equal(resolveRegion(1500, 1500, 'Felucca', index).region, 'Wide Low Priority')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: falls back to the nearest landmark group', () => {
|
||||||
|
const result = resolveRegion(5050, 5050, 'Felucca', fixtureIndex())
|
||||||
|
assert.equal(result.region, null)
|
||||||
|
assert.equal(result.landmark, 'Covetous')
|
||||||
|
assert.equal(result.label, 'Covetous')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: a landmark beyond the radius yields Wilderness', () => {
|
||||||
|
// Without the radius cap the nearest landmark is always *some* landmark, and
|
||||||
|
// open countryside would get labelled with a dungeon across the map.
|
||||||
|
const result = resolveRegion(7000, 7000, 'Felucca', fixtureIndex())
|
||||||
|
assert.equal(result.landmark, null)
|
||||||
|
assert.equal(result.label, 'Wilderness')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: the radius is configurable', () => {
|
||||||
|
const wide = resolveRegion(7000, 7000, 'Felucca', fixtureIndex(), { landmarkRadius: 5000 })
|
||||||
|
assert.equal(wide.label, 'Covetous')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: an unknown facet degrades to Wilderness, not a throw', () => {
|
||||||
|
const result = resolveRegion(1100, 1100, 'Malas', fixtureIndex())
|
||||||
|
assert.equal(result.label, 'Wilderness')
|
||||||
|
assert.equal(result.region, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolveRegion: does not leak across facets', () => {
|
||||||
|
const index = buildPlacementIndex(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
facet: 'Trammel',
|
||||||
|
name: 'Britain',
|
||||||
|
type: 'TownRegion',
|
||||||
|
priority: 10,
|
||||||
|
parent: null,
|
||||||
|
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
assert.equal(resolveRegion(1100, 1100, 'Trammel', index).region, 'Britain')
|
||||||
|
assert.equal(resolveRegion(1100, 1100, 'Felucca', index).region, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Tokenizer edge cases ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parseXml: skips comments, declarations and DOCTYPE', () => {
|
||||||
|
const root = parseXml(
|
||||||
|
'<?xml version="1.0"?><!DOCTYPE r><r><!-- <fake a="b"/> --><a x="1"/></r>',
|
||||||
|
)
|
||||||
|
assert.equal(root.name, 'r')
|
||||||
|
assert.equal(root.children.length, 1)
|
||||||
|
assert.equal(root.children[0].name, 'a')
|
||||||
|
assert.equal(root.children[0].attrs.x, '1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseXml: a ">" inside an attribute value does not end the tag', () => {
|
||||||
|
const root = parseXml('<r><a name="1 > 0" b="2"/></r>')
|
||||||
|
assert.equal(root.children[0].attrs.name, '1 > 0')
|
||||||
|
assert.equal(root.children[0].attrs.b, '2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseXml: single-quoted attributes are read', () => {
|
||||||
|
const root = parseXml("<r><a name='Mondain' /></r>")
|
||||||
|
assert.equal(root.children[0].attrs.name, 'Mondain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseXml: a stray closing tag is ignored, not fatal', () => {
|
||||||
|
// Hand-maintained shard config: one malformed element should degrade to a
|
||||||
|
// missing element, not abort an otherwise good build.
|
||||||
|
const root = parseXml('<r><a/></b><c/></r>')
|
||||||
|
assert.equal(root.name, 'r')
|
||||||
|
assert.deepEqual(root.children.map((n) => n.name), ['a', 'c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseXml: empty or element-free input yields null', () => {
|
||||||
|
assert.equal(parseXml(''), null)
|
||||||
|
assert.equal(parseXml('<!-- only a comment -->'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decodeEntities: named, numeric and hex refs', () => {
|
||||||
|
assert.equal(decodeEntities("Mondain's Legacy"), "Mondain's Legacy")
|
||||||
|
assert.equal(decodeEntities('a & b'), 'a & b')
|
||||||
|
assert.equal(decodeEntities('<tag>'), '<tag>')
|
||||||
|
assert.equal(decodeEntities('AB'), 'AB')
|
||||||
|
// An unknown entity is left alone rather than silently eaten.
|
||||||
|
assert.equal(decodeEntities('&nosuch;'), '&nosuch;')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseXml: decodes entities in attribute values', () => {
|
||||||
|
const root = parseXml('<r><region name="Mondain's Legacy" /></r>')
|
||||||
|
assert.equal(root.children[0].attrs.name, "Mondain's Legacy")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── slugify ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('slugify: produces URL-safe keys', () => {
|
||||||
|
assert.equal(slugify('Lizardman'), 'lizardman')
|
||||||
|
assert.equal(slugify('Giant Spider'), 'giant-spider')
|
||||||
|
assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy')
|
||||||
|
assert.equal(slugify(' Orc '), 'orc')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Respawn delays: the unit is per record ──────────────────────────────────
|
||||||
|
// XmlSpawner writes minutes by default and switches to seconds only when a
|
||||||
|
// delay does not divide into whole minutes, flagged by DelayInSec. `5` therefore
|
||||||
|
// means five MINUTES on one spawner and five SECONDS on the next, and a reader
|
||||||
|
// assuming either unit is wrong about the other — silently, since both are
|
||||||
|
// plausible respawn times.
|
||||||
|
const DELAY_XML = `<Spawns>
|
||||||
|
<Points>
|
||||||
|
<Name>Minutes</Name>
|
||||||
|
<Map>Sosaria</Map>
|
||||||
|
<X>1</X><Y>1</Y>
|
||||||
|
<MinDelay>5</MinDelay>
|
||||||
|
<MaxDelay>10</MaxDelay>
|
||||||
|
<IsRunning>True</IsRunning>
|
||||||
|
<Objects2>Orc:MX=1</Objects2>
|
||||||
|
</Points>
|
||||||
|
<Points>
|
||||||
|
<Name>Seconds</Name>
|
||||||
|
<Map>Sosaria</Map>
|
||||||
|
<X>2</X><Y>2</Y>
|
||||||
|
<DelayInSec>True</DelayInSec>
|
||||||
|
<MinDelay>5</MinDelay>
|
||||||
|
<MaxDelay>10</MaxDelay>
|
||||||
|
<IsRunning>True</IsRunning>
|
||||||
|
<Objects2>Orc:MX=1</Objects2>
|
||||||
|
</Points>
|
||||||
|
</Spawns>`
|
||||||
|
|
||||||
|
test('parsePoints: DelayInSec decides the unit, and both come out in seconds', () => {
|
||||||
|
const [minutes, seconds] = parsePoints(DELAY_XML)
|
||||||
|
assert.equal(minutes.minDelay, 300)
|
||||||
|
assert.equal(minutes.maxDelay, 600)
|
||||||
|
assert.equal(seconds.minDelay, 5)
|
||||||
|
assert.equal(seconds.maxDelay, 10)
|
||||||
|
})
|
||||||
399
modules/uo/server/test/spawnAtlas.source.test.js
Normal file
399
modules/uo/server/test/spawnAtlas.source.test.js
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
// No dead-port pool trick here: a module test fabricates its ctx instead, so
|
||||||
|
// there is no database to point anywhere (see test/_ctx.js).
|
||||||
|
const fs = require('fs')
|
||||||
|
const os = require('os')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const { test, after, beforeEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const { installFakeCtx } = require('./_ctx')
|
||||||
|
|
||||||
|
installFakeCtx()
|
||||||
|
|
||||||
|
const {
|
||||||
|
AtlasSourceError,
|
||||||
|
aggregateCreatures,
|
||||||
|
displayName,
|
||||||
|
sameSources,
|
||||||
|
hashSources,
|
||||||
|
buildAtlas,
|
||||||
|
PARSER_VERSION,
|
||||||
|
} = require('../utils/spawnAtlasSource')
|
||||||
|
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||||
|
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
|
||||||
|
// The model captured this object at require time, so reassigning a method on it
|
||||||
|
// is how a test stubs core — the module equivalent of core's own tests
|
||||||
|
// monkey-patching a model.
|
||||||
|
const { settings } = require('../core')
|
||||||
|
|
||||||
|
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Deliberately uses facets that do NOT exist in stock UO. The atlas must not
|
||||||
|
// contain a built-in facet list anywhere: a shard may add facets, replace them
|
||||||
|
// outright, or rename them when its maps are updated, and everything has to keep
|
||||||
|
// working with no code change.
|
||||||
|
|
||||||
|
function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) {
|
||||||
|
fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true })
|
||||||
|
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
|
||||||
|
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
|
||||||
|
|
||||||
|
for (const facet of facets) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Spawns', `${facet}.xml`),
|
||||||
|
`<Spawns>
|
||||||
|
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
|
||||||
|
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
|
||||||
|
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
|
||||||
|
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
|
||||||
|
<MaxCount>1</MaxCount><IsRunning>True</IsRunning>
|
||||||
|
<Objects2>lizardman:MX=2:SB=0</Objects2></Points>
|
||||||
|
<Points><Name>${facet}Off</Name><Map>${facet}</Map><X>1</X><Y>1</Y>
|
||||||
|
<MaxCount>1</MaxCount><IsRunning>False</IsRunning>
|
||||||
|
<Objects2>Ghost:MX=1</Objects2></Points>
|
||||||
|
</Spawns>`,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
// The location file names its facet differently from <Map>, the real
|
||||||
|
// `Ter Mur` / `Tokuno Islands` drift.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`),
|
||||||
|
`<places><parent name="${facet} Isles"><parent name="Deep Cave">
|
||||||
|
<child name="Level 1" x="9010" y="9010" z="0" /></parent></parent></places>`,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Data', 'Regions.xml'),
|
||||||
|
`<ServerRegions>${facets
|
||||||
|
.map(
|
||||||
|
(facet) => `<Facet name="${facet}">
|
||||||
|
<region type="TownRegion" priority="10" name="${facet} City">
|
||||||
|
<rect x="1000" y="1000" width="500" height="500" />
|
||||||
|
</region></Facet>`,
|
||||||
|
)
|
||||||
|
.join('')}</ServerRegions>`,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (includeChampions) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'Config', 'ChampionSpawns.xml'),
|
||||||
|
`<championSystem><spawn name="Deep" group="G" type="Terror">
|
||||||
|
<location x="1100" y="1100" z="0" map="${facets[0]}" radius="40" />
|
||||||
|
</spawn></championSystem>`,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tempTree(options) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-'))
|
||||||
|
writeTree(root, options)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
|
||||||
|
|
||||||
|
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||||||
|
const atlas = buildAtlas(root)
|
||||||
|
assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark'])
|
||||||
|
assert.equal(atlas.meta.counts.facets, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: reconciles a location file that spells the facet differently', () => {
|
||||||
|
// "Sosaria Isles" inside the file vs <Map>Sosaria</Map> — the same drift that
|
||||||
|
// silently emptied the Ter Mur / Tokuno landmark buckets.
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
const atlas = buildAtlas(root)
|
||||||
|
assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria'])
|
||||||
|
// And the fallback actually fires, rather than the point reading Wilderness.
|
||||||
|
const far = atlas.points.find((p) => p.name === 'SosariaB')
|
||||||
|
assert.equal(far.landmark, 'Deep Cave')
|
||||||
|
assert.equal(far.label, 'Deep Cave')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: resolves a contained point to its region', () => {
|
||||||
|
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||||
|
const inCity = atlas.points.find((p) => p.name === 'SosariaA')
|
||||||
|
assert.equal(inCity.region, 'Sosaria City')
|
||||||
|
assert.equal(inCity.label, 'Sosaria City')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: drops spawners that are switched off in-world', () => {
|
||||||
|
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||||
|
assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false)
|
||||||
|
assert.equal(atlas.meta.counts.pointsDisabled, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: a champion altar resolves through the same placement index', () => {
|
||||||
|
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||||
|
assert.equal(atlas.champions[0].label, 'Sosaria City')
|
||||||
|
assert.equal(atlas.champions[0].facet, 'Sosaria')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: a tree with no champion file still builds', () => {
|
||||||
|
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false }))
|
||||||
|
assert.deepEqual(atlas.champions, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: missing path and empty path raise typed errors', () => {
|
||||||
|
assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH')
|
||||||
|
assert.throws(
|
||||||
|
() => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')),
|
||||||
|
(err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildAtlas: a directory with no spawn files raises rather than building empty', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-'))
|
||||||
|
fs.mkdirSync(path.join(root, 'Data'), { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '<ServerRegions/>', 'utf8')
|
||||||
|
assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Hashing ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('hashSources: stable across reads, changes when a file changes', () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
const first = hashSources(root)
|
||||||
|
assert.ok(sameSources(first, hashSources(root)))
|
||||||
|
|
||||||
|
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- edit -->', 'utf8')
|
||||||
|
assert.equal(sameSources(first, hashSources(root)), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sameSources: a missing or extra file is a difference', () => {
|
||||||
|
assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false)
|
||||||
|
assert.equal(sameSources({ a: '1' }, { a: '2' }), false)
|
||||||
|
assert.equal(sameSources({ a: '1' }, { a: '1' }), true)
|
||||||
|
assert.equal(sameSources(null, { a: '1' }), false)
|
||||||
|
assert.equal(sameSources({ a: '1' }, null), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Aggregation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const POINTS = [
|
||||||
|
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] },
|
||||||
|
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] },
|
||||||
|
{ facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
test('aggregateCreatures: sums each type’s own max and counts per facet', () => {
|
||||||
|
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
|
||||||
|
assert.equal(lizardman.total, 10)
|
||||||
|
assert.equal(lizardman.points, 3)
|
||||||
|
assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aggregateCreatures: differing case collapses to one creature', () => {
|
||||||
|
const creatures = aggregateCreatures(POINTS)
|
||||||
|
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
|
||||||
|
assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc'])
|
||||||
|
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('displayName: most common wins, ties break to the capitalised form', () => {
|
||||||
|
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
|
||||||
|
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
|
||||||
|
// Deterministic regardless of insertion order — a committed artifact is gone,
|
||||||
|
// but a spurious diff in the DB on every restart would be just as wrong.
|
||||||
|
assert.equal(
|
||||||
|
displayName(new Map([['abc', 1], ['abd', 1]])),
|
||||||
|
displayName(new Map([['abd', 1], ['abc', 1]])),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pointTypeRows: collapses a repeated type to the larger max', () => {
|
||||||
|
// The primary key is (point_id, slug), so a duplicate would otherwise fail the
|
||||||
|
// insert and take the whole transaction with it.
|
||||||
|
const rows = shardAtlas.pointTypeRows([
|
||||||
|
{ types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] },
|
||||||
|
])
|
||||||
|
assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pointTypeRows: point ids are 1-based and line up with insert order', () => {
|
||||||
|
const rows = shardAtlas.pointTypeRows([
|
||||||
|
{ types: [{ type: 'A', max: 1 }] },
|
||||||
|
{ types: [{ type: 'B', max: 1 }] },
|
||||||
|
])
|
||||||
|
assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── The refresh decision ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The boot path's two contracts: it never blocks startup, and it never applies a
|
||||||
|
// facet removal on its own.
|
||||||
|
|
||||||
|
let applied
|
||||||
|
let pendingRow
|
||||||
|
let facetsInDb
|
||||||
|
let metaRow
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
applied = null
|
||||||
|
pendingRow = null
|
||||||
|
facetsInDb = []
|
||||||
|
metaRow = null
|
||||||
|
atlasDb.replaceAtlas = async (atlas) => {
|
||||||
|
applied = atlas
|
||||||
|
return { points: atlas.points.length, creatures: atlas.creatures.length }
|
||||||
|
}
|
||||||
|
atlasDb.getMeta = async () => metaRow
|
||||||
|
atlasDb.getFacets = async () => facetsInDb
|
||||||
|
atlasDb.getPending = async () => pendingRow
|
||||||
|
atlasDb.setPending = async (payload, status) => {
|
||||||
|
pendingRow = { ...payload, status }
|
||||||
|
}
|
||||||
|
atlasDb.clearPending = async () => {
|
||||||
|
pendingRow = null
|
||||||
|
}
|
||||||
|
settings.get = async () => ''
|
||||||
|
process.env.SERVUO_PATH = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: no configured path is skipped, not an error', async () => {
|
||||||
|
const result = await shardAtlas.refresh()
|
||||||
|
assert.equal(result.status, 'skipped')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: an unreadable tree reports unavailable rather than throwing', async () => {
|
||||||
|
const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') })
|
||||||
|
assert.equal(result.status, 'unavailable')
|
||||||
|
assert.equal(result.code, 'NOT_FOUND')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: a fresh database imports', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.ok(applied)
|
||||||
|
assert.deepEqual(result.addedFacets, ['Sosaria'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
metaRow = buildAtlas(root).meta
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'unchanged')
|
||||||
|
assert.equal(applied, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// The hash gate alone would strand an install whose maps never change on
|
||||||
|
// whatever an older build derived: a corrected parse would ship and never reach
|
||||||
|
// the data, because the only thing compared is the tree.
|
||||||
|
test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 }
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.ok(applied)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: an atlas imported before parser versions existed is stale', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
const meta = buildAtlas(root).meta
|
||||||
|
delete meta.parserVersion
|
||||||
|
metaRow = meta
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: --force reimports an unchanged tree', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
metaRow = buildAtlas(root).meta
|
||||||
|
const result = await shardAtlas.refresh({ path: root, force: true })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.ok(applied)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: a NEW facet applies straight away', async () => {
|
||||||
|
// Additions cannot destroy anything an operator would miss.
|
||||||
|
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||||||
|
facetsInDb = ['Sosaria']
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.deepEqual(result.addedFacets, ['Underdark'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: a REMOVED facet is staged, not applied', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
facetsInDb = ['Sosaria', 'Underdark']
|
||||||
|
const result = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(result.status, 'needsReview')
|
||||||
|
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||||||
|
// The critical part: the existing atlas was left alone.
|
||||||
|
assert.equal(applied, null)
|
||||||
|
assert.equal(pendingRow.status, 'pending')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: approving applies the removal', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
facetsInDb = ['Sosaria', 'Underdark']
|
||||||
|
await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(applied, null)
|
||||||
|
|
||||||
|
const result = await shardAtlas.approvePending({ path: root })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.ok(applied)
|
||||||
|
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
facetsInDb = ['Sosaria', 'Underdark']
|
||||||
|
await shardAtlas.refresh({ path: root })
|
||||||
|
await shardAtlas.rejectPending()
|
||||||
|
assert.equal(pendingRow.status, 'rejected')
|
||||||
|
|
||||||
|
const again = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(again.status, 'unchanged')
|
||||||
|
assert.equal(applied, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: changing the tree asks again after a rejection', async () => {
|
||||||
|
const root = tempTree({ facets: ['Sosaria'] })
|
||||||
|
facetsInDb = ['Sosaria', 'Underdark']
|
||||||
|
await shardAtlas.refresh({ path: root })
|
||||||
|
await shardAtlas.rejectPending()
|
||||||
|
|
||||||
|
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- changed -->', 'utf8')
|
||||||
|
const again = await shardAtlas.refresh({ path: root })
|
||||||
|
assert.equal(again.status, 'needsReview')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refreshOnBoot: never throws, whatever goes wrong', async () => {
|
||||||
|
atlasDb.getMeta = async () => {
|
||||||
|
throw new Error('database is on fire')
|
||||||
|
}
|
||||||
|
atlasDb.getFacets = async () => {
|
||||||
|
throw new Error('still on fire')
|
||||||
|
}
|
||||||
|
atlasDb.replaceAtlas = async () => {
|
||||||
|
throw new Error('and the import too')
|
||||||
|
}
|
||||||
|
process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] })
|
||||||
|
|
||||||
|
const result = await shardAtlas.refreshOnBoot()
|
||||||
|
assert.equal(result.status, 'failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refreshOnBoot: a missing tree is survivable, not fatal', async () => {
|
||||||
|
process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz')
|
||||||
|
const result = await shardAtlas.refreshOnBoot()
|
||||||
|
assert.equal(result.status, 'unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refresh: an explicit path overrides the configured one', async () => {
|
||||||
|
const configured = tempTree({ facets: ['Configured'] })
|
||||||
|
const override = tempTree({ facets: ['Override'] })
|
||||||
|
settings.get = async () => configured
|
||||||
|
|
||||||
|
const result = await shardAtlas.refresh({ path: override })
|
||||||
|
assert.equal(result.status, 'imported')
|
||||||
|
assert.deepEqual(result.addedFacets, ['Override'])
|
||||||
|
})
|
||||||
686
modules/uo/server/utils/spawnAtlasParse.js
Normal file
686
modules/uo/server/utils/spawnAtlasParse.js
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
// Spawn atlas parsers — pure functions over strings, no `fs`, no dependencies.
|
||||||
|
//
|
||||||
|
// These back the CLI build script (`scripts/buildSpawnAtlas.js`), which is the
|
||||||
|
// only thing that reads a ServUO tree. Keeping every parser pure and fs-free is
|
||||||
|
// what lets the test suite cover them in CI, where no ServUO tree exists: the
|
||||||
|
// tests hand these functions literal XML strings.
|
||||||
|
//
|
||||||
|
// Four source shapes, two very different parsing strategies:
|
||||||
|
//
|
||||||
|
// Spawns/*.xml ~10.5 MB across 13 files, FLAT <Points> records
|
||||||
|
// → streaming regex, never a DOM. See parsePoints().
|
||||||
|
// Data/Regions.xml 129 KB, genuinely nested <region> inside <region>
|
||||||
|
// Data/Locations/*.xml nested <parent>/<child>
|
||||||
|
// Config/ChampionSpawns.xml 4.8 KB, <spawn>/<location>
|
||||||
|
// → the small recursive tokenizer below.
|
||||||
|
//
|
||||||
|
// The server has zero XML dependencies and this adds none. The tokenizer is
|
||||||
|
// deliberately a *subset* parser: it handles the constructs these four files
|
||||||
|
// actually use (elements, attributes, self-closing tags, comments, the XML
|
||||||
|
// declaration, CDATA, the five predefined entities plus numeric refs) and
|
||||||
|
// nothing else. It is not a general-purpose XML parser and must not be reused
|
||||||
|
// as one — no namespaces, no DTDs, no entity declarations.
|
||||||
|
|
||||||
|
// ── Entities ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NAMED_ENTITIES = {
|
||||||
|
amp: '&',
|
||||||
|
lt: '<',
|
||||||
|
gt: '>',
|
||||||
|
quot: '"',
|
||||||
|
apos: "'",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region and location names carry apostrophes ("Mondain's Legacy", "Wrong's
|
||||||
|
// Level 3"), so entity decoding is load-bearing here, not decorative.
|
||||||
|
function decodeEntities(text) {
|
||||||
|
if (!text.includes('&')) return text
|
||||||
|
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body) => {
|
||||||
|
if (body[0] === '#') {
|
||||||
|
const code =
|
||||||
|
body[1] === 'x' || body[1] === 'X'
|
||||||
|
? Number.parseInt(body.slice(2), 16)
|
||||||
|
: Number.parseInt(body.slice(1), 10)
|
||||||
|
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
||||||
|
}
|
||||||
|
const named = NAMED_ENTITIES[body.toLowerCase()]
|
||||||
|
return named === undefined ? match : named
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The tokenizer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ATTR_RE = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g
|
||||||
|
|
||||||
|
function parseAttrs(source) {
|
||||||
|
const attrs = {}
|
||||||
|
ATTR_RE.lastIndex = 0
|
||||||
|
let match
|
||||||
|
while ((match = ATTR_RE.exec(source)) !== null) {
|
||||||
|
const raw = match[3] !== undefined ? match[3] : match[4]
|
||||||
|
attrs[match[1]] = decodeEntities(raw)
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a small nested XML document into `{ name, attrs, children, text }`.
|
||||||
|
*
|
||||||
|
* Intended for Regions.xml / Locations / ChampionSpawns.xml only — never for
|
||||||
|
* the multi-megabyte Spawns files. Returns the root element, or `null` for a
|
||||||
|
* document with no elements.
|
||||||
|
*
|
||||||
|
* Mismatched or stray closing tags are ignored rather than thrown on: these are
|
||||||
|
* hand-maintained shard config files, and one malformed region should degrade
|
||||||
|
* to a missing region, not abort a build that is otherwise fine.
|
||||||
|
*/
|
||||||
|
function parseXml(source) {
|
||||||
|
const text = String(source)
|
||||||
|
const root = { name: '#document', attrs: {}, children: [], text: '' }
|
||||||
|
const stack = [root]
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
const lt = text.indexOf('<', i)
|
||||||
|
if (lt === -1) {
|
||||||
|
appendText(stack[stack.length - 1], text.slice(i))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (lt > i) appendText(stack[stack.length - 1], text.slice(i, lt))
|
||||||
|
|
||||||
|
// Comment, declaration/DOCTYPE, or CDATA — skipped wholesale.
|
||||||
|
if (text.startsWith('<!--', lt)) {
|
||||||
|
const end = text.indexOf('-->', lt + 4)
|
||||||
|
i = end === -1 ? text.length : end + 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (text.startsWith('<![CDATA[', lt)) {
|
||||||
|
const end = text.indexOf(']]>', lt + 9)
|
||||||
|
const stop = end === -1 ? text.length : end
|
||||||
|
appendRawText(stack[stack.length - 1], text.slice(lt + 9, stop))
|
||||||
|
i = end === -1 ? text.length : end + 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (text.startsWith('<?', lt)) {
|
||||||
|
const end = text.indexOf('?>', lt + 2)
|
||||||
|
i = end === -1 ? text.length : end + 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (text.startsWith('<!', lt)) {
|
||||||
|
const end = text.indexOf('>', lt + 2)
|
||||||
|
i = end === -1 ? text.length : end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const gt = findTagEnd(text, lt)
|
||||||
|
if (gt === -1) {
|
||||||
|
// Unterminated tag: nothing sane is left to read.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const inner = text.slice(lt + 1, gt)
|
||||||
|
|
||||||
|
if (inner[0] === '/') {
|
||||||
|
const name = inner.slice(1).trim()
|
||||||
|
// Pop to the nearest matching open element. If there is no match the tag
|
||||||
|
// is stray and we drop it rather than unwinding the whole stack.
|
||||||
|
for (let depth = stack.length - 1; depth > 0; depth -= 1) {
|
||||||
|
if (stack[depth].name === name) {
|
||||||
|
stack.length = depth
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = gt + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfClosing = inner.endsWith('/')
|
||||||
|
const body = selfClosing ? inner.slice(0, -1) : inner
|
||||||
|
const space = body.search(/\s/)
|
||||||
|
const name = (space === -1 ? body : body.slice(0, space)).trim()
|
||||||
|
const node = {
|
||||||
|
name,
|
||||||
|
attrs: space === -1 ? {} : parseAttrs(body.slice(space)),
|
||||||
|
children: [],
|
||||||
|
text: '',
|
||||||
|
}
|
||||||
|
stack[stack.length - 1].children.push(node)
|
||||||
|
if (!selfClosing) stack.push(node)
|
||||||
|
i = gt + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return root.children.length > 0 ? root.children[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// `>` inside a quoted attribute value must not end the tag.
|
||||||
|
function findTagEnd(text, from) {
|
||||||
|
let quote = null
|
||||||
|
for (let i = from + 1; i < text.length; i += 1) {
|
||||||
|
const ch = text[i]
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) quote = null
|
||||||
|
} else if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch
|
||||||
|
} else if (ch === '>') {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendText(node, chunk) {
|
||||||
|
if (chunk.trim() === '') return
|
||||||
|
appendRawText(node, decodeEntities(chunk))
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRawText(node, chunk) {
|
||||||
|
node.text = node.text ? `${node.text}${chunk}` : chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
function childrenNamed(node, name) {
|
||||||
|
if (!node || !node.children) return []
|
||||||
|
return node.children.filter((child) => child.name === name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Facet names ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Facets are NOT a fixed list. A shard may add facets, replace them wholesale,
|
||||||
|
// or rename them when its maps are updated, so nothing here may name Felucca,
|
||||||
|
// Trammel or any other stock facet. The facet set is whatever the shard's own
|
||||||
|
// files say it is, discovered at parse time.
|
||||||
|
//
|
||||||
|
// The complication is that the sources disagree about spelling for the SAME
|
||||||
|
// facet and nothing in the files reconciles them: `Spawns/*.xml` `<Map>` and
|
||||||
|
// `Regions.xml` `<Facet name>` say `TerMur`, while `Data/Locations/*.xml` spells
|
||||||
|
// it `Ter Mur` and calls Tokuno `Tokuno Islands`. Left unreconciled this fails
|
||||||
|
// silently — the landmark bucket is keyed differently from the points looking it
|
||||||
|
// up, so the fallback never fires and every unregioned spawn on those facets
|
||||||
|
// reads "Wilderness".
|
||||||
|
//
|
||||||
|
// Reconciliation is therefore done by MATCHING, not by a lookup table:
|
||||||
|
// `facetKey()` collapses spelling differences, and `resolveFacetName()` matches
|
||||||
|
// a loosely-spelled name against the canonical set discovered from the shard's
|
||||||
|
// own data. A facet nobody else mentions keeps its own name rather than being
|
||||||
|
// dropped.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse a facet name to a comparison key: lowercase, alphanumerics only.
|
||||||
|
* `TerMur`, `Ter Mur` and `ter-mur` all key alike.
|
||||||
|
*/
|
||||||
|
function facetKey(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a key → canonical-spelling lookup from the authoritative facet names.
|
||||||
|
*
|
||||||
|
* The authority is what the spawn records and region definitions actually say,
|
||||||
|
* since those are the names the atlas keys everything on. Later names do not
|
||||||
|
* overwrite earlier ones, so the first source wins consistently.
|
||||||
|
*/
|
||||||
|
function buildFacetIndex(names) {
|
||||||
|
const index = new Map()
|
||||||
|
for (const name of names) {
|
||||||
|
const key = facetKey(name)
|
||||||
|
if (key !== '' && !index.has(key)) index.set(key, String(name).trim())
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a loosely-spelled facet name against the discovered canonical set.
|
||||||
|
*
|
||||||
|
* Tried in order: exact key match (`Ter Mur` → `TerMur`), then a prefix match in
|
||||||
|
* either direction (`Tokuno Islands` → `Tokuno`), longest candidate first so a
|
||||||
|
* more specific facet wins over a shorter one that merely prefixes it.
|
||||||
|
*
|
||||||
|
* A name matching nothing is returned trimmed rather than dropped — on a shard
|
||||||
|
* with a custom facet that is a real facet the atlas simply has no spawns for
|
||||||
|
* yet, and inventing a match would be worse than leaving it alone.
|
||||||
|
*/
|
||||||
|
function resolveFacetName(value, index) {
|
||||||
|
const raw = String(value ?? '').trim()
|
||||||
|
const key = facetKey(raw)
|
||||||
|
if (key === '') return ''
|
||||||
|
if (index.has(key)) return index.get(key)
|
||||||
|
|
||||||
|
let best = null
|
||||||
|
for (const [candidateKey, canonical] of index) {
|
||||||
|
if (!key.startsWith(candidateKey) && !candidateKey.startsWith(key)) continue
|
||||||
|
if (best === null || candidateKey.length > facetKey(best).length) best = canonical
|
||||||
|
}
|
||||||
|
return best ?? raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Small coercions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toInt(value, fallback = 0) {
|
||||||
|
const n = Number.parseInt(value, 10)
|
||||||
|
return Number.isFinite(n) ? n : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBool(value) {
|
||||||
|
return String(value).trim().toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL-safe slug used as the creature primary key and in `/atlas/:slug`.
|
||||||
|
* Spawn type tokens are C# class names, so they are already ASCII-ish; this
|
||||||
|
* mainly lowercases and collapses punctuation.
|
||||||
|
*/
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Objects2 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `<Objects2>` value into `[{ type, max }]`.
|
||||||
|
*
|
||||||
|
* The format is one or more segments joined by `:OBJ=`, each segment being
|
||||||
|
* `Type:MX=n:SB=0:RT=0:...` — the type is the token before the first `:`, and
|
||||||
|
* every following token is a `KEY=value` pair. Verified against trammel.xml,
|
||||||
|
* where a single point carries six types:
|
||||||
|
*
|
||||||
|
* Giantserpent:MX=1:...:OBJ=Giantspider:MX=1:...:OBJ=Boar:MX=1:...
|
||||||
|
*
|
||||||
|
* Splitting on `:` alone would shred this, which is why the `:OBJ=` split comes
|
||||||
|
* first. `MX` is that type's own max count and is what the atlas displays;
|
||||||
|
* every other flag (spawn/trigger/refractory bookkeeping) is dropped.
|
||||||
|
*
|
||||||
|
* The type token itself may carry XmlSpawner directives appended to the class
|
||||||
|
* name — property assignments after `/` and an amount/argument list after `,`:
|
||||||
|
*
|
||||||
|
* Agralem/Name/Agralem alchemist/z/-50 Fairy,{RND,4,8}
|
||||||
|
* GargishRefugee/hue/34532 greatape,true GargishRouser,1
|
||||||
|
*
|
||||||
|
* Taken literally these produce creatures that do not exist ("alchemist/z/-50")
|
||||||
|
* AND split real ones in two, because `Fairy` and `Fairy,{RND,4,8}` slug apart —
|
||||||
|
* 71 of 845 entries were affected before this was stripped. Only the leading
|
||||||
|
* class name identifies the creature, so everything from the first `/` or `,`
|
||||||
|
* is dropped.
|
||||||
|
*/
|
||||||
|
/** Reduce an XmlSpawner type token to the bare class name. */
|
||||||
|
function stripSpawnerDirectives(token) {
|
||||||
|
const cut = String(token).search(/[/,]/)
|
||||||
|
return (cut === -1 ? String(token) : String(token).slice(0, cut)).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjects2(value) {
|
||||||
|
const source = String(value ?? '').trim()
|
||||||
|
if (source === '') return []
|
||||||
|
|
||||||
|
return source
|
||||||
|
.split(':OBJ=')
|
||||||
|
.map((segment) => {
|
||||||
|
const tokens = segment.split(':')
|
||||||
|
const type = stripSpawnerDirectives(tokens.shift() ?? '')
|
||||||
|
if (type === '') return null
|
||||||
|
let max = 1
|
||||||
|
for (const token of tokens) {
|
||||||
|
const eq = token.indexOf('=')
|
||||||
|
if (eq === -1) continue
|
||||||
|
if (token.slice(0, eq).trim().toUpperCase() === 'MX') {
|
||||||
|
max = toInt(token.slice(eq + 1), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type, max }
|
||||||
|
})
|
||||||
|
.filter((entry) => entry !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spawns/*.xml ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const POINT_RE = /<Points>([\s\S]*?)<\/Points>/g
|
||||||
|
|
||||||
|
function tagValue(block, name) {
|
||||||
|
const match = block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))
|
||||||
|
return match ? decodeEntities(match[1]).trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `Spawns/<facet>.xml` file into spawn point records.
|
||||||
|
*
|
||||||
|
* Deliberately regex/streaming and NOT `parseXml` — these files total ~10.5 MB
|
||||||
|
* and putting them through a DOM builder would allocate a node per element for
|
||||||
|
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
|
||||||
|
* flat, so a per-record regex sweep is both correct and cheap.
|
||||||
|
*
|
||||||
|
* Only the fields the site can actually show are kept. Everything to do with
|
||||||
|
* triggering, refractory windows, proximity, sequential spawning, sounds and
|
||||||
|
* `UniqueId` is dropped here rather than downstream — that is what holds the
|
||||||
|
* committed artifact under 1 MB.
|
||||||
|
*
|
||||||
|
* NOTE: the facet comes from each record's own `<Map>`, never from the file
|
||||||
|
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
||||||
|
* carry TerMur/Trammel points, so there are 13 files but only 6 facets.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* A spawner's respawn window, in seconds.
|
||||||
|
*
|
||||||
|
* `DelayInSec` decides the unit of `MinDelay`/`MaxDelay`; absent (older files)
|
||||||
|
* it is false, which is minutes — the same default XmlSpawner assumes.
|
||||||
|
*/
|
||||||
|
function delaySeconds(block) {
|
||||||
|
const scale = toBool(tagValue(block, 'DelayInSec')) ? 1 : 60
|
||||||
|
return {
|
||||||
|
minDelay: toInt(tagValue(block, 'MinDelay')) * scale,
|
||||||
|
maxDelay: toInt(tagValue(block, 'MaxDelay')) * scale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePoints(source) {
|
||||||
|
const text = String(source)
|
||||||
|
const points = []
|
||||||
|
POINT_RE.lastIndex = 0
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = POINT_RE.exec(text)) !== null) {
|
||||||
|
const block = match[1]
|
||||||
|
// Reported exactly as written. `<Map>` is the authority the rest of the
|
||||||
|
// atlas keys on, so it is never rewritten.
|
||||||
|
const facet = tagValue(block, 'Map')
|
||||||
|
if (facet === '') continue
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
name: tagValue(block, 'Name'),
|
||||||
|
facet,
|
||||||
|
x: toInt(tagValue(block, 'X')),
|
||||||
|
y: toInt(tagValue(block, 'Y')),
|
||||||
|
width: toInt(tagValue(block, 'Width')),
|
||||||
|
height: toInt(tagValue(block, 'Height')),
|
||||||
|
range: toInt(tagValue(block, 'Range')),
|
||||||
|
maxCount: toInt(tagValue(block, 'MaxCount')),
|
||||||
|
// Normalised to SECONDS here, because the unit is per-record. XmlSpawner
|
||||||
|
// writes minutes by default and switches to seconds only when a spawner's
|
||||||
|
// delay does not divide into whole minutes, flagging that with
|
||||||
|
// `DelayInSec` (XmlSpawner2.cs:7462-7480, read back at :6345-6358). Taken
|
||||||
|
// literally the two are indistinguishable — a `5` means five minutes on
|
||||||
|
// one spawner and five seconds on the next — so a consumer that assumed
|
||||||
|
// either unit would be wrong about the other. Stock ServUO 57.4 has ~30
|
||||||
|
// second-flagged spawners, few enough to look like noise and quietly
|
||||||
|
// mislabel.
|
||||||
|
...delaySeconds(block),
|
||||||
|
// Time-of-day gating: TODMode 0 means "always", in which case the start
|
||||||
|
// and end values are meaningless and the site must not render them.
|
||||||
|
todStart: toInt(tagValue(block, 'TODStart')),
|
||||||
|
todEnd: toInt(tagValue(block, 'TODEnd')),
|
||||||
|
todMode: toInt(tagValue(block, 'TODMode')),
|
||||||
|
// A spawner switched off in-world spawns nothing; the build filters these
|
||||||
|
// out so the atlas describes what actually appears, not what is merely
|
||||||
|
// configured. Parsed here so the decision stays in the build script.
|
||||||
|
running: toBool(tagValue(block, 'IsRunning')),
|
||||||
|
types: parseObjects2(tagValue(block, 'Objects2')),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data/Regions.xml ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten `Data/Regions.xml` into `[{ facet, name, type, priority, parent, rects }]`.
|
||||||
|
*
|
||||||
|
* Regions nest: a `<region>` may contain further `<region>` elements, and the
|
||||||
|
* inner ones frequently omit `name` and `priority` (`<region type="CrystalField">`
|
||||||
|
* inside "Prism of Light"). Unnamed regions are skipped — they cannot label a
|
||||||
|
* spawn point — but their children are still walked, and a child that omits
|
||||||
|
* `priority` inherits its parent's rather than defaulting to 0, which would
|
||||||
|
* quietly sort it below every top-level region.
|
||||||
|
*/
|
||||||
|
function parseRegions(source) {
|
||||||
|
const root = parseXml(source)
|
||||||
|
const regions = []
|
||||||
|
if (!root) return regions
|
||||||
|
|
||||||
|
for (const facetNode of childrenNamed(root, 'Facet')) {
|
||||||
|
const facet = (facetNode.attrs.name || '').trim()
|
||||||
|
if (facet === '') continue
|
||||||
|
walkRegions(facetNode, facet, null, 0, regions)
|
||||||
|
}
|
||||||
|
return regions
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkRegions(node, facet, parentName, parentPriority, out) {
|
||||||
|
for (const regionNode of childrenNamed(node, 'region')) {
|
||||||
|
const name = regionNode.attrs.name || ''
|
||||||
|
const priority = Object.hasOwn(regionNode.attrs, 'priority')
|
||||||
|
? toInt(regionNode.attrs.priority, parentPriority)
|
||||||
|
: parentPriority
|
||||||
|
|
||||||
|
if (name !== '') {
|
||||||
|
const rects = childrenNamed(regionNode, 'rect').map((rect) => ({
|
||||||
|
x: toInt(rect.attrs.x),
|
||||||
|
y: toInt(rect.attrs.y),
|
||||||
|
width: toInt(rect.attrs.width),
|
||||||
|
height: toInt(rect.attrs.height),
|
||||||
|
}))
|
||||||
|
// A named region with no rects (some exist purely to carry music or a
|
||||||
|
// `go` point) can never contain anything, so it is not worth indexing.
|
||||||
|
if (rects.length > 0) {
|
||||||
|
out.push({
|
||||||
|
facet,
|
||||||
|
name,
|
||||||
|
type: regionNode.attrs.type || '',
|
||||||
|
priority,
|
||||||
|
parent: parentName,
|
||||||
|
rects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walkRegions(regionNode, facet, name === '' ? parentName : name, priority, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data/Locations/*.xml ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a `Data/Locations/<facet>.xml` into landmark points.
|
||||||
|
*
|
||||||
|
* The file nests `<parent>` arbitrarily deep and puts coordinates only on
|
||||||
|
* `<child>`: Trammel → Dungeons → Covetous → "Level 1". The outermost parent is
|
||||||
|
* the facet itself and is dropped from `path`; `group` is the innermost
|
||||||
|
* enclosing parent ("Covetous"), which is the label worth showing — "Covetous"
|
||||||
|
* reads better than "Level 1" when naming where a spawn is.
|
||||||
|
*/
|
||||||
|
function parseLocations(source, facetHint = '') {
|
||||||
|
const root = parseXml(source)
|
||||||
|
const landmarks = []
|
||||||
|
if (!root) return landmarks
|
||||||
|
|
||||||
|
for (const top of childrenNamed(root, 'parent')) {
|
||||||
|
// The file name (`Data/Locations/termur.xml`) is the more reliable signal
|
||||||
|
// and is preferred over the display label inside the file, which is where
|
||||||
|
// the `Ter Mur` / `Tokuno Islands` drift lives. Both are carried so the
|
||||||
|
// build can fall back to matching the label if the file name resolves to
|
||||||
|
// nothing — a shard may well name its files differently from its facets.
|
||||||
|
landmarks.push(
|
||||||
|
...collectLocations(top, facetHint || top.attrs.name || '', top.attrs.name || ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return landmarks
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectLocations(top, facet, label) {
|
||||||
|
const out = []
|
||||||
|
walkLocations(top, facet, [], out)
|
||||||
|
for (const landmark of out) landmark.facetLabel = label
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkLocations(node, facet, path, out) {
|
||||||
|
for (const child of childrenNamed(node, 'child')) {
|
||||||
|
const name = child.attrs.name || ''
|
||||||
|
if (name === '') continue
|
||||||
|
out.push({
|
||||||
|
facet,
|
||||||
|
name,
|
||||||
|
group: path.length > 0 ? path[path.length - 1] : name,
|
||||||
|
path: [...path],
|
||||||
|
x: toInt(child.attrs.x),
|
||||||
|
y: toInt(child.attrs.y),
|
||||||
|
z: toInt(child.attrs.z),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const parent of childrenNamed(node, 'parent')) {
|
||||||
|
const name = parent.attrs.name || ''
|
||||||
|
walkLocations(parent, facet, name === '' ? path : [...path, name], out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config/ChampionSpawns.xml ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse `Config/ChampionSpawns.xml` into champion altar records.
|
||||||
|
*
|
||||||
|
* This is the shard's *configured* champion roster — which altars exist, where,
|
||||||
|
* and which type each is pinned to. It is static content and distinct from the
|
||||||
|
* live `champ.update` feed the bridge already carries: this says "there is an
|
||||||
|
* Unholy Terror altar in Deceit", the feed says "it is on level 3 right now".
|
||||||
|
*
|
||||||
|
* A spawn with no `type` is randomised on every activation, which the site must
|
||||||
|
* render as "random" rather than as an empty type.
|
||||||
|
*/
|
||||||
|
function parseChampions(source) {
|
||||||
|
const root = parseXml(source)
|
||||||
|
const champions = []
|
||||||
|
if (!root) return champions
|
||||||
|
|
||||||
|
for (const spawnNode of childrenNamed(root, 'spawn')) {
|
||||||
|
const location = childrenNamed(spawnNode, 'location')[0]
|
||||||
|
const attrs = location ? location.attrs : {}
|
||||||
|
champions.push({
|
||||||
|
name: spawnNode.attrs.name || '',
|
||||||
|
group: spawnNode.attrs.group || '',
|
||||||
|
type: spawnNode.attrs.type || '',
|
||||||
|
randomType: !spawnNode.attrs.type,
|
||||||
|
facet: (attrs.map || '').trim(),
|
||||||
|
x: toInt(attrs.x),
|
||||||
|
y: toInt(attrs.y),
|
||||||
|
z: toInt(attrs.z),
|
||||||
|
radius: toInt(attrs.radius),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return champions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Placement ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_LANDMARK_RADIUS = 200
|
||||||
|
|
||||||
|
function inRect(x, y, rect) {
|
||||||
|
return (
|
||||||
|
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectArea(rect) {
|
||||||
|
return Math.max(1, rect.width) * Math.max(1, rect.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group parsed regions and landmarks by facet once, so the per-point resolve
|
||||||
|
* below is a scan of one facet instead of the whole world. With ~6,500 points
|
||||||
|
* and a few thousand rects this stays comfortably sub-second; there is no need
|
||||||
|
* for a spatial index and none is worth the complexity.
|
||||||
|
*/
|
||||||
|
function buildPlacementIndex(regions, landmarks) {
|
||||||
|
const byFacet = new Map()
|
||||||
|
// Keyed on facetKey(), not the raw name, so two spellings of one facet cannot
|
||||||
|
// land in separate buckets — the failure that silently emptied the landmark
|
||||||
|
// bucket for Ter Mur and Tokuno.
|
||||||
|
const facet = (name) => {
|
||||||
|
const key = facetKey(name)
|
||||||
|
if (!byFacet.has(key)) byFacet.set(key, { regions: [], landmarks: [] })
|
||||||
|
return byFacet.get(key)
|
||||||
|
}
|
||||||
|
for (const region of regions) facet(region.facet).regions.push(region)
|
||||||
|
for (const landmark of landmarks) facet(landmark.facet).landmarks.push(landmark)
|
||||||
|
return byFacet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a raw coordinate into a human place name.
|
||||||
|
*
|
||||||
|
* This is the transform the whole atlas exists for: it is what makes a row read
|
||||||
|
* "Lizardman — Despise, Felucca" instead of "Lizardman — 5411, 1234".
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. The highest-`priority` named region whose rect contains the point. Ties
|
||||||
|
* break toward the SMALLEST rect, so a specific room inside a dungeon wins
|
||||||
|
* over the dungeon-wide rect it sits in.
|
||||||
|
* 2. Otherwise the nearest landmark within `landmarkRadius` tiles, labelled by
|
||||||
|
* its group ("Covetous"), not the individual marker ("Level 1").
|
||||||
|
* 3. Otherwise "Wilderness". The radius cap is what keeps step 3 reachable —
|
||||||
|
* without it the nearest landmark is always *some* landmark, however far,
|
||||||
|
* and open countryside would get labelled with a dungeon on the far side
|
||||||
|
* of the map.
|
||||||
|
*/
|
||||||
|
function resolveRegion(x, y, facetName, index, options = {}) {
|
||||||
|
const radius = options.landmarkRadius ?? DEFAULT_LANDMARK_RADIUS
|
||||||
|
const bucket = index.get(facetKey(facetName))
|
||||||
|
const result = { region: null, landmark: null, label: 'Wilderness' }
|
||||||
|
if (!bucket) return result
|
||||||
|
|
||||||
|
let best = null
|
||||||
|
let bestPriority = -Infinity
|
||||||
|
let bestArea = Infinity
|
||||||
|
for (const region of bucket.regions) {
|
||||||
|
for (const rect of region.rects) {
|
||||||
|
if (!inRect(x, y, rect)) continue
|
||||||
|
const area = rectArea(rect)
|
||||||
|
if (region.priority > bestPriority || (region.priority === bestPriority && area < bestArea)) {
|
||||||
|
best = region
|
||||||
|
bestPriority = region.priority
|
||||||
|
bestArea = area
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
result.region = best.name
|
||||||
|
result.label = best.name
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearest = null
|
||||||
|
let nearestDistance = Infinity
|
||||||
|
const limit = radius * radius
|
||||||
|
for (const landmark of bucket.landmarks) {
|
||||||
|
const dx = landmark.x - x
|
||||||
|
const dy = landmark.y - y
|
||||||
|
const distance = dx * dx + dy * dy
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearest = landmark
|
||||||
|
nearestDistance = distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nearest && nearestDistance <= limit) {
|
||||||
|
result.landmark = nearest.group || nearest.name
|
||||||
|
result.label = result.landmark
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseXml,
|
||||||
|
parseObjects2,
|
||||||
|
parsePoints,
|
||||||
|
parseRegions,
|
||||||
|
parseLocations,
|
||||||
|
parseChampions,
|
||||||
|
buildPlacementIndex,
|
||||||
|
resolveRegion,
|
||||||
|
facetKey,
|
||||||
|
buildFacetIndex,
|
||||||
|
resolveFacetName,
|
||||||
|
slugify,
|
||||||
|
decodeEntities,
|
||||||
|
DEFAULT_LANDMARK_RADIUS,
|
||||||
|
}
|
||||||
336
modules/uo/server/utils/spawnAtlasSource.js
Normal file
336
modules/uo/server/utils/spawnAtlasSource.js
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
// Spawn atlas — the filesystem layer over a ServUO tree.
|
||||||
|
//
|
||||||
|
// `spawnAtlasParse.js` holds the pure parsers; this module is the only thing
|
||||||
|
// that touches a ServUO tree on disk, and it is shared by both callers:
|
||||||
|
//
|
||||||
|
// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`)
|
||||||
|
// - the CLI (`scripts/importSpawnAtlas.js`)
|
||||||
|
//
|
||||||
|
// The shard's own files are the single source of truth. Nothing is precomputed
|
||||||
|
// and committed, because a shard's maps change over its lifetime — facets get
|
||||||
|
// added, replaced or renamed — and a snapshot in the repo would silently go
|
||||||
|
// stale against the world players actually see.
|
||||||
|
//
|
||||||
|
// Reading and hashing the whole tree costs ~120 ms and a full parse ~400 ms, so
|
||||||
|
// the boot path hashes first and only parses when something actually changed.
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const {
|
||||||
|
parsePoints,
|
||||||
|
parseRegions,
|
||||||
|
parseLocations,
|
||||||
|
parseChampions,
|
||||||
|
buildPlacementIndex,
|
||||||
|
buildFacetIndex,
|
||||||
|
resolveFacetName,
|
||||||
|
resolveRegion,
|
||||||
|
facetKey,
|
||||||
|
slugify,
|
||||||
|
} = require('./spawnAtlasParse')
|
||||||
|
|
||||||
|
const REGIONS_FILE = path.join('Data', 'Regions.xml')
|
||||||
|
const LOCATIONS_DIR = path.join('Data', 'Locations')
|
||||||
|
const SPAWNS_DIR = 'Spawns'
|
||||||
|
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
|
||||||
|
|
||||||
|
class AtlasSourceError extends Error {
|
||||||
|
constructor(message, code) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'AtlasSourceError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reading ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sha256(text) {
|
||||||
|
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function listXml(dir) {
|
||||||
|
try {
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((name) => name.toLowerCase().endsWith('.xml'))
|
||||||
|
.sort()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIfPresent(file) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(file, 'utf8')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read every atlas source file under `root`.
|
||||||
|
*
|
||||||
|
* Returns `{ files: [{ label, text, sha256, bytes }] }`, labels being
|
||||||
|
* tree-relative and forward-slashed so a hash map compares equal across
|
||||||
|
* platforms — the same tree read on Windows and Linux must produce the same
|
||||||
|
* fingerprint or every boot would look like a change.
|
||||||
|
*/
|
||||||
|
function readSources(root) {
|
||||||
|
if (!root || String(root).trim() === '') {
|
||||||
|
throw new AtlasSourceError('No ServUO path configured', 'NO_PATH')
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
throw new AtlasSourceError(`ServUO path does not exist: ${root}`, 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = []
|
||||||
|
const push = (label, file) => {
|
||||||
|
const text = readIfPresent(file)
|
||||||
|
if (text === null) return false
|
||||||
|
files.push({ label, text, sha256: sha256(text), bytes: Buffer.byteLength(text, 'utf8') })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!push('Data/Regions.xml', path.join(root, REGIONS_FILE))) {
|
||||||
|
throw new AtlasSourceError(`Missing required file: ${REGIONS_FILE}`, 'NO_REGIONS')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of listXml(path.join(root, LOCATIONS_DIR))) {
|
||||||
|
push(`Data/Locations/${name}`, path.join(root, LOCATIONS_DIR, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnFiles = listXml(path.join(root, SPAWNS_DIR))
|
||||||
|
if (spawnFiles.length === 0) {
|
||||||
|
throw new AtlasSourceError(`No spawn files found in ${SPAWNS_DIR}`, 'NO_SPAWNS')
|
||||||
|
}
|
||||||
|
for (const name of spawnFiles) push(`Spawns/${name}`, path.join(root, SPAWNS_DIR, name))
|
||||||
|
|
||||||
|
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
|
||||||
|
|
||||||
|
return { files }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fingerprint of the tree: `{ "<label>": "<sha256>" }`.
|
||||||
|
*
|
||||||
|
* The boot path compares this against what was last imported and skips the
|
||||||
|
* parse entirely when it matches, which is the normal case on every restart
|
||||||
|
* that did not follow a map update.
|
||||||
|
*/
|
||||||
|
function hashSources(root) {
|
||||||
|
const { files } = readSources(root)
|
||||||
|
const hashes = {}
|
||||||
|
for (const file of files) hashes[file.label] = file.sha256
|
||||||
|
return hashes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped whenever the parser produces DIFFERENT data from IDENTICAL source
|
||||||
|
* files — a fixed misreading, a new field, a changed unit.
|
||||||
|
*
|
||||||
|
* Without it the hash gate is a trap: an install whose tree has not changed
|
||||||
|
* would keep serving what an older parser derived, indefinitely, because the
|
||||||
|
* only thing the boot path compares is the tree. The version is stored beside
|
||||||
|
* the source hashes and a mismatch counts as drift, so a deploy that corrects
|
||||||
|
* the parse actually reaches the data.
|
||||||
|
*
|
||||||
|
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
||||||
|
* seconds in the source, decided by `DelayInSec`).
|
||||||
|
*/
|
||||||
|
const PARSER_VERSION = 2
|
||||||
|
|
||||||
|
/** True when two source fingerprints describe the same tree. */
|
||||||
|
function sameSources(a, b) {
|
||||||
|
if (!a || !b) return false
|
||||||
|
const aKeys = Object.keys(a).sort()
|
||||||
|
const bKeys = Object.keys(b).sort()
|
||||||
|
if (aKeys.length !== bKeys.length) return false
|
||||||
|
return aKeys.every((key, i) => key === bKeys[i] && a[key] === b[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Aggregation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose one display spelling for a creature.
|
||||||
|
*
|
||||||
|
* Spawn files are not consistent about case — the same creature is `Lizardman`
|
||||||
|
* in one file and `lizardman` in another. Slugging collapses them correctly, but
|
||||||
|
* the display name would otherwise depend on file read order. Most frequent
|
||||||
|
* spelling wins; ties break toward more capitals, then alphabetically.
|
||||||
|
*/
|
||||||
|
function displayName(spellings) {
|
||||||
|
const capitals = (value) => (value.match(/[A-Z]/g) || []).length
|
||||||
|
return [...spellings.entries()].sort((a, b) => {
|
||||||
|
if (b[1] !== a[1]) return b[1] - a[1]
|
||||||
|
const caps = capitals(b[0]) - capitals(a[0])
|
||||||
|
if (caps !== 0) return caps
|
||||||
|
return a[0].localeCompare(b[0])
|
||||||
|
})[0][0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll spawn points up into per-type creature rows.
|
||||||
|
*
|
||||||
|
* `total` is the sum of each type's own max across every point that spawns it —
|
||||||
|
* how many of this creature the world holds at once. `facets` is a per-facet
|
||||||
|
* point count, so "where does this live" answers without touching the points.
|
||||||
|
*/
|
||||||
|
function aggregateCreatures(points) {
|
||||||
|
const creatures = new Map()
|
||||||
|
for (const point of points) {
|
||||||
|
for (const entry of point.types) {
|
||||||
|
const slug = slugify(entry.type)
|
||||||
|
if (slug === '') continue
|
||||||
|
let creature = creatures.get(slug)
|
||||||
|
if (!creature) {
|
||||||
|
creature = { slug, name: '', total: 0, points: 0, facets: {}, spellings: new Map() }
|
||||||
|
creatures.set(slug, creature)
|
||||||
|
}
|
||||||
|
creature.total += entry.max
|
||||||
|
creature.points += 1
|
||||||
|
creature.facets[point.facet] = (creature.facets[point.facet] || 0) + 1
|
||||||
|
creature.spellings.set(entry.type, (creature.spellings.get(entry.type) || 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...creatures.values()]
|
||||||
|
.map(({ spellings, ...creature }) => ({ ...creature, name: displayName(spellings) }))
|
||||||
|
.sort((a, b) => a.slug.localeCompare(b.slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a ServUO tree into the full atlas.
|
||||||
|
*
|
||||||
|
* Pure with respect to the database — it reads files and returns data; nothing
|
||||||
|
* here writes. `shardAtlas.model.js` decides what to do with the result.
|
||||||
|
*/
|
||||||
|
function buildAtlas(root, options = {}) {
|
||||||
|
const { files } = readSources(root)
|
||||||
|
const byLabel = new Map(files.map((file) => [file.label, file]))
|
||||||
|
const source = {}
|
||||||
|
for (const file of files) source[file.label] = { bytes: file.bytes, sha256: file.sha256 }
|
||||||
|
|
||||||
|
const regions = parseRegions(byLabel.get('Data/Regions.xml').text)
|
||||||
|
|
||||||
|
const rawLandmarks = []
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.label.startsWith('Data/Locations/')) continue
|
||||||
|
const basename = path.basename(file.label, '.xml')
|
||||||
|
rawLandmarks.push(...parseLocations(file.text, basename))
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPoints = []
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.label.startsWith('Spawns/')) continue
|
||||||
|
rawPoints.push(...parsePoints(file.text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The facet set is whatever THIS tree declares — never a built-in list. A
|
||||||
|
// shard may add facets, replace them outright, or rename them when its maps
|
||||||
|
// are updated, and the atlas has to follow without a code change. Spawn
|
||||||
|
// records and region definitions are the authority, because those are the
|
||||||
|
// names everything else is keyed on.
|
||||||
|
const facetIndex = buildFacetIndex([
|
||||||
|
...rawPoints.map((point) => point.facet),
|
||||||
|
...regions.map((region) => region.facet),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Landmark facets are then matched against that set, which is what absorbs the
|
||||||
|
// `Ter Mur` / `Tokuno Islands` spelling drift between Locations and <Map>.
|
||||||
|
const landmarks = rawLandmarks.map(({ facetLabel, ...landmark }) => {
|
||||||
|
const fromFile = resolveFacetName(landmark.facet, facetIndex)
|
||||||
|
const matchedFile = facetIndex.has(facetKey(fromFile))
|
||||||
|
const resolved = matchedFile ? fromFile : resolveFacetName(facetLabel, facetIndex)
|
||||||
|
return { ...landmark, facet: resolved || landmark.facet }
|
||||||
|
})
|
||||||
|
|
||||||
|
const placement = buildPlacementIndex(regions, landmarks)
|
||||||
|
const resolveOpts = options.landmarkRadius ? { landmarkRadius: options.landmarkRadius } : {}
|
||||||
|
|
||||||
|
const disabled = rawPoints.filter((point) => !point.running).length
|
||||||
|
const points = rawPoints
|
||||||
|
// A spawner switched off in-world produces nothing; advertising it would be
|
||||||
|
// a straight lie to a player planning a hunt.
|
||||||
|
.filter((point) => point.running)
|
||||||
|
// A spawner with no types is a placeholder — nothing to show.
|
||||||
|
.filter((point) => point.types.length > 0)
|
||||||
|
.map((point) => {
|
||||||
|
const place = resolveRegion(point.x, point.y, point.facet, placement, resolveOpts)
|
||||||
|
return {
|
||||||
|
name: point.name,
|
||||||
|
facet: point.facet,
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
width: point.width,
|
||||||
|
height: point.height,
|
||||||
|
range: point.range,
|
||||||
|
maxCount: point.maxCount,
|
||||||
|
minDelay: point.minDelay,
|
||||||
|
maxDelay: point.maxDelay,
|
||||||
|
todStart: point.todStart,
|
||||||
|
todEnd: point.todEnd,
|
||||||
|
todMode: point.todMode,
|
||||||
|
region: place.region,
|
||||||
|
landmark: place.landmark,
|
||||||
|
label: place.label,
|
||||||
|
types: point.types,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const championsFile = byLabel.get('Config/ChampionSpawns.xml')
|
||||||
|
const champions = (championsFile ? parseChampions(championsFile.text) : []).map((champ) => {
|
||||||
|
const facet = resolveFacetName(champ.facet, facetIndex) || champ.facet
|
||||||
|
return {
|
||||||
|
...champ,
|
||||||
|
facet,
|
||||||
|
slug: slugify(`${facet}-${champ.name}`),
|
||||||
|
label: resolveRegion(champ.x, champ.y, facet, placement, resolveOpts).label,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const creatures = aggregateCreatures(points)
|
||||||
|
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
||||||
|
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
parserVersion: PARSER_VERSION,
|
||||||
|
landmarkRadius: options.landmarkRadius ?? undefined,
|
||||||
|
counts: {
|
||||||
|
facets: facets.length,
|
||||||
|
points: points.length,
|
||||||
|
pointsDisabled: disabled,
|
||||||
|
creatures: creatures.length,
|
||||||
|
regions: regions.length,
|
||||||
|
landmarks: landmarks.length,
|
||||||
|
champions: champions.length,
|
||||||
|
unresolvedPoints: unresolved,
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
facets,
|
||||||
|
creatures,
|
||||||
|
regions,
|
||||||
|
landmarks,
|
||||||
|
champions,
|
||||||
|
points,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
AtlasSourceError,
|
||||||
|
PARSER_VERSION,
|
||||||
|
readSources,
|
||||||
|
hashSources,
|
||||||
|
sameSources,
|
||||||
|
buildAtlas,
|
||||||
|
aggregateCreatures,
|
||||||
|
displayName,
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
24
server/db/data/spawnAtlas.art.example.json
Normal file
24
server/db/data/spawnAtlas.art.example.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"_comment": [
|
||||||
|
"OPTIONAL operator-supplied creature art for the spawn atlas. Copy this file to",
|
||||||
|
"spawnAtlas.art.json (same directory) and edit it, then restart the server or run",
|
||||||
|
"`npm run atlas:import` — the art map is read on every atlas refresh.",
|
||||||
|
"",
|
||||||
|
"This project ships NO creature artwork and never will. UO sprites live in your",
|
||||||
|
"own client's .mul/.uop files and are yours to extract, not ours to redistribute.",
|
||||||
|
"If you want art on the atlas pages, export it yourself (UOFiddler, ClassicUO's",
|
||||||
|
"tooling, or any art extractor), drop the images under server/uploads/atlas/, and",
|
||||||
|
"map each creature slug to its file name here.",
|
||||||
|
"",
|
||||||
|
"Both spawnAtlas.art.json and server/uploads/ are gitignored, so neither the map",
|
||||||
|
"nor the images can be committed by accident.",
|
||||||
|
"",
|
||||||
|
"Keys are creature slugs, as reported by the atlas API and derived from the type",
|
||||||
|
"names in your own shard's Spawns/*.xml. Values are file names relative to",
|
||||||
|
"server/uploads/atlas/. Any creature with no entry here simply renders without",
|
||||||
|
"art — that is the default and fully supported state, not a degraded one."
|
||||||
|
],
|
||||||
|
"lizardman": "lizardman.png",
|
||||||
|
"orc": "orc.png",
|
||||||
|
"dragon": "dragon.png"
|
||||||
|
}
|
||||||
@@ -359,7 +359,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
|||||||
base_url VARCHAR(255) NULL,
|
base_url VARCHAR(255) NULL,
|
||||||
ws_url VARCHAR(255) NULL,
|
ws_url VARCHAR(255) NULL,
|
||||||
auth_token_enc TEXT 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,
|
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||||
status_detail VARCHAR(500) NULL,
|
status_detail VARCHAR(500) NULL,
|
||||||
@@ -597,6 +597,142 @@ CREATE TABLE IF NOT EXISTS shard_presence (
|
|||||||
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- The shard's published ruleset (Protocol 3.0 world.ruleset). Singleton row
|
||||||
|
-- (id = 1) holding the latest frame: expansion, which optional systems are on,
|
||||||
|
-- skill/stat caps, account and house limits, champion scroll rules, the
|
||||||
|
-- save/restart schedule. The shard re-emits it on every sidecar connect, so this
|
||||||
|
-- row is simply overwritten; `rev` is the shard's own FNV-1a of the body, which
|
||||||
|
-- distinguishes "same ruleset, re-sent on reconnect" from "an operator changed a
|
||||||
|
-- .cfg". No row at all means the shard has never published one — served as null,
|
||||||
|
-- which the rules page renders differently from a published ruleset.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_ruleset (
|
||||||
|
id INT PRIMARY KEY DEFAULT 1,
|
||||||
|
rev VARCHAR(32) NULL,
|
||||||
|
expansion VARCHAR(16) NULL, -- hoisted for cheap display
|
||||||
|
payload JSON NOT NULL, -- the whole world.ruleset frame
|
||||||
|
t BIGINT NULL, -- frame time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_shard_ruleset_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Points/loyalty leaderboards (Protocol 3.0 points.board). One row per point
|
||||||
|
-- system, keyed by the shard's own PointsType name. The shard publishes ~25 of
|
||||||
|
-- these (Queen's Loyalty, Void Pool, the nine city loyalties, …), each a standing
|
||||||
|
-- players accumulate over months.
|
||||||
|
--
|
||||||
|
-- The top-N list stays inside `payload` rather than being normalized into a
|
||||||
|
-- shard_points_entries table. It is a fixed-size list (10 by default) that is only
|
||||||
|
-- ever read whole, exactly like shard_governors.candidates — normalizing it would
|
||||||
|
-- buy nothing until something needs a per-character reverse lookup, and a
|
||||||
|
-- character's own standings already ride inside char.profile instead.
|
||||||
|
--
|
||||||
|
-- No delete path: the shard's set of systems is fixed at startup, so there is no
|
||||||
|
-- points.remove to mirror.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_points_boards (
|
||||||
|
system VARCHAR(48) PRIMARY KEY, -- PointsType name, e.g. QueensLoyalty
|
||||||
|
name VARCHAR(128) NULL, -- resolved display name, if the shard sent a literal
|
||||||
|
name_cliloc INT NULL, -- cliloc id when the name is a TextDefinition number
|
||||||
|
max_points BIGINT NULL,
|
||||||
|
players INT NULL, -- players actually holding points in this system
|
||||||
|
show_on_gump TINYINT(1) NOT NULL DEFAULT 1, -- the shard's own "is this player-facing?" flag
|
||||||
|
payload JSON NOT NULL, -- the whole points.board frame, incl. `top`
|
||||||
|
t BIGINT NULL, -- frame time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Player-vendor market index (Protocol 3.0 vendor.listing). One row per player
|
||||||
|
-- vendor and one per priced listing, so the site can offer the search the in-game
|
||||||
|
-- Vendor Search gump offers — from outside the game.
|
||||||
|
--
|
||||||
|
-- The shard sweeps vendors round-robin and emits one AUTHORITATIVE frame per
|
||||||
|
-- vendor, so ingest is delete-then-insert of that vendor's items inside one
|
||||||
|
-- transaction (see shardMarket.db.js). No foreign key from items to vendors, in
|
||||||
|
-- keeping with every other shard_* table: the ingest transaction is what keeps
|
||||||
|
-- them consistent, and an FK would turn a malformed frame into a failed write
|
||||||
|
-- rather than a dropped row.
|
||||||
|
--
|
||||||
|
-- Only vendors whose owner left the in-game Vendor Search flag ON are ever sent,
|
||||||
|
-- so a player who hid their shop in game is hidden here too — see BridgeMarket.cs.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_vendors (
|
||||||
|
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- "0x40001234"
|
||||||
|
shop_name VARCHAR(160) NULL,
|
||||||
|
owner_serial VARCHAR(20) NULL,
|
||||||
|
owner_name VARCHAR(64) NULL,
|
||||||
|
map VARCHAR(40) NULL,
|
||||||
|
x INT NULL,
|
||||||
|
y INT NULL,
|
||||||
|
z INT NULL,
|
||||||
|
region VARCHAR(80) NULL,
|
||||||
|
house VARCHAR(160) NULL, -- the house SIGN's name, not the house type
|
||||||
|
item_count INT NOT NULL DEFAULT 0, -- listings published in the frame
|
||||||
|
item_total INT NOT NULL DEFAULT 0, -- listings the shop actually holds
|
||||||
|
truncated TINYINT(1) NOT NULL DEFAULT 0, -- item_total > item_count
|
||||||
|
t BIGINT NULL, -- frame time, epoch ms
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_shard_vendors_owner (owner_name),
|
||||||
|
INDEX idx_shard_vendors_map (map),
|
||||||
|
INDEX idx_shard_vendors_region (region),
|
||||||
|
-- The market page's staleness banner is MIN(updated_at) over this column: the
|
||||||
|
-- round-robin sweep means the oldest row is how far behind the index can be.
|
||||||
|
INDEX idx_shard_vendors_updated (updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- One priced listing. Unlike the points board's top-N — a fixed-size list read
|
||||||
|
-- whole — these are the searchable rows the whole feature exists for, so they are
|
||||||
|
-- normalized rather than left inside a payload column, and there is no payload
|
||||||
|
-- column on shard_vendors at all.
|
||||||
|
--
|
||||||
|
-- `display_name` is DENORMALIZED at ingest: the shard sends `cliloc` (the item's
|
||||||
|
-- LabelNumber) and, rarely, a literal `name`, and resolving 50 clilocs per page
|
||||||
|
-- at query time would make the cliloc table a join on the hot path AND make
|
||||||
|
-- search-by-name impossible. Resolving once on write buys the index. It is
|
||||||
|
-- re-resolved in bulk after a cliloc import, because the diff sweep will not
|
||||||
|
-- re-send an unchanged shop just because the site learned what its items are
|
||||||
|
-- called.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_vendor_items (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vendor_serial VARCHAR(20) NOT NULL,
|
||||||
|
serial VARCHAR(20) NOT NULL,
|
||||||
|
item_id INT NOT NULL DEFAULT 0, -- ItemID (the art/graphic id)
|
||||||
|
hue INT NOT NULL DEFAULT 0,
|
||||||
|
amount INT NOT NULL DEFAULT 1,
|
||||||
|
price BIGINT NOT NULL DEFAULT 0,
|
||||||
|
name VARCHAR(160) NULL, -- the item's literal Name, null for most
|
||||||
|
cliloc INT NULL, -- LabelNumber, resolved against shard_clilocs
|
||||||
|
display_name VARCHAR(160) NULL, -- resolved at ingest; what search matches
|
||||||
|
child TINYINT(1) NOT NULL DEFAULT 0, -- priced by an enclosing container, not itself
|
||||||
|
INDEX idx_shard_vendor_items_vendor (vendor_serial),
|
||||||
|
INDEX idx_shard_vendor_items_price (price),
|
||||||
|
INDEX idx_shard_vendor_items_item (item_id),
|
||||||
|
INDEX idx_shard_vendor_items_name (display_name),
|
||||||
|
-- Search filters on name and sorts on price; the composite covers the common
|
||||||
|
-- "cheapest matching X" without a filesort over the whole table.
|
||||||
|
INDEX idx_shard_vendor_items_name_price (display_name, price)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Per-feature visibility for every shard-derived surface (Protocol 3.0). One row
|
||||||
|
-- per feature; an absent row means "use the compiled default", and the compiled
|
||||||
|
-- defaults reproduce the behavior that shipped before v3 — so an empty table is
|
||||||
|
-- a no-op. See utils/shardVisibility.js for the catalog and the ladder, and
|
||||||
|
-- docs/link/v3.md §3 for the contract.
|
||||||
|
--
|
||||||
|
-- audience the minimum rung on anonymous < logged_in < player < staff < admin
|
||||||
|
-- stream whether this feature's kinds fan out over SSE at all (the market
|
||||||
|
-- index ships with this off: no page needs a live firehose of
|
||||||
|
-- whole vendor inventories)
|
||||||
|
-- field_rules {"<field>": "<rung>"} for SENSITIVE fields only. `acct` and
|
||||||
|
-- `webId` are admin-only always and are rejected here — they are
|
||||||
|
-- not in-game visible and are deliberately not configurable.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_feature_visibility (
|
||||||
|
feature VARCHAR(48) NOT NULL PRIMARY KEY,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
audience VARCHAR(20) NOT NULL DEFAULT 'anonymous',
|
||||||
|
stream TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
field_rules JSON NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
|
-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone
|
||||||
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
|
-- by email at a pre-chosen access level; the invitee accepts via a tokened link,
|
||||||
-- which creates their website user at that role (and optionally a linked game
|
-- which creates their website user at that role (and optionally a linked game
|
||||||
@@ -999,6 +1135,39 @@ CREATE TABLE IF NOT EXISTS announce_jobs (
|
|||||||
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 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.
|
||||||
|
--
|
||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- Singleton (id = 1) describing the cliloc table currently loaded: the source
|
||||||
|
-- file, its sha256, the entry count and the parser version. The boot path
|
||||||
|
-- compares the stored hash against the file on disk and skips the parse when
|
||||||
|
-- they match, which is every restart that did not follow a client patch.
|
||||||
|
CREATE TABLE IF NOT EXISTS shard_cliloc_meta (
|
||||||
|
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||||
|
payload JSON NOT NULL,
|
||||||
|
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_shard_cliloc_meta_singleton CHECK (id = 1)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||||
@@ -1078,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
|
-- 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).
|
-- 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;
|
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');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"seed": "node db/seed.js",
|
"seed": "node db/seed.js",
|
||||||
"swagger": "node swagger/swagger.js",
|
"swagger": "node swagger/swagger.js",
|
||||||
"routes:manifest": "node scripts/routeManifest.js",
|
"routes:manifest": "node scripts/routeManifest.js",
|
||||||
|
"atlas:import": "node scripts/importSpawnAtlas.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -616,6 +616,25 @@
|
|||||||
"requireAuth"
|
"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",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/shard/account",
|
"path": "/api/v1/admin/shard/account",
|
||||||
@@ -636,6 +655,55 @@
|
|||||||
"requireAuth"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/audit",
|
"path": "/api/v1/admin/shard/audit",
|
||||||
@@ -678,6 +746,37 @@
|
|||||||
"validate"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/houses",
|
"path": "/api/v1/admin/shard/houses",
|
||||||
@@ -782,6 +881,26 @@
|
|||||||
"validate"
|
"validate"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/visibility",
|
||||||
|
"handlers": 2,
|
||||||
|
"gates": [
|
||||||
|
"noindex",
|
||||||
|
"requireAuth"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/shard/visibility",
|
||||||
|
"handlers": 4,
|
||||||
|
"gates": [
|
||||||
|
"noindex",
|
||||||
|
"requireAuth",
|
||||||
|
"middleware",
|
||||||
|
"validate"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/site-mode",
|
"path": "/api/v1/admin/site-mode",
|
||||||
@@ -1761,6 +1880,64 @@
|
|||||||
"validate"
|
"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",
|
"method": "POST",
|
||||||
"path": "/api/v1/public/contact",
|
"path": "/api/v1/public/contact",
|
||||||
@@ -1809,22 +1986,28 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/champs",
|
"path": "/api/v1/public/shard/champs",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/economy",
|
"path": "/api/v1/public/shard/economy",
|
||||||
"handlers": 3,
|
"handlers": 4,
|
||||||
"gates": [
|
"gates": [
|
||||||
"middleware",
|
"middleware",
|
||||||
"validate"
|
"validate"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/features",
|
||||||
|
"handlers": 1,
|
||||||
|
"gates": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/feed",
|
"path": "/api/v1/public/shard/feed",
|
||||||
"handlers": 4,
|
"handlers": 5,
|
||||||
"gates": [
|
"gates": [
|
||||||
"middleware",
|
"middleware",
|
||||||
"validate"
|
"validate"
|
||||||
@@ -1833,13 +2016,13 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/governors",
|
"path": "/api/v1/public/shard/governors",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/governors/:city/history",
|
"path": "/api/v1/public/shard/governors/:city/history",
|
||||||
"handlers": 4,
|
"handlers": 5,
|
||||||
"gates": [
|
"gates": [
|
||||||
"middleware",
|
"middleware",
|
||||||
"validate"
|
"validate"
|
||||||
@@ -1848,37 +2031,79 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/guilds",
|
"path": "/api/v1/public/shard/guilds",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/houses",
|
"path": "/api/v1/public/shard/houses",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/idoc",
|
"path": "/api/v1/public/shard/idoc",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/online",
|
"path": "/api/v1/public/shard/online",
|
||||||
"handlers": 1,
|
"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": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/presence",
|
"path": "/api/v1/public/shard/presence",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
|
"gates": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/ruleset",
|
||||||
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/status",
|
"path": "/api/v1/public/shard/status",
|
||||||
"handlers": 1,
|
"handlers": 2,
|
||||||
"gates": []
|
"gates": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1930,6 +2155,24 @@
|
|||||||
"gates": [
|
"gates": [
|
||||||
"siteMode"
|
"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": [
|
"internal": [
|
||||||
|
|||||||
@@ -249,6 +249,14 @@
|
|||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/settings"
|
"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",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/shard/account"
|
"path": "/api/v1/admin/shard/account"
|
||||||
@@ -257,6 +265,26 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/accounts"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/audit"
|
"path": "/api/v1/admin/shard/audit"
|
||||||
@@ -273,6 +301,18 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/char/:serial"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/houses"
|
"path": "/api/v1/admin/shard/houses"
|
||||||
@@ -313,6 +353,14 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/shard/vendors/:account"
|
"path": "/api/v1/admin/shard/vendors/:account"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/shard/visibility"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/shard/visibility"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/site-mode"
|
"path": "/api/v1/admin/site-mode"
|
||||||
@@ -705,6 +753,30 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/player/shard/vendors/:account"
|
"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",
|
"method": "POST",
|
||||||
"path": "/api/v1/public/contact"
|
"path": "/api/v1/public/contact"
|
||||||
@@ -737,6 +809,10 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/economy"
|
"path": "/api/v1/public/shard/economy"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/features"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/feed"
|
"path": "/api/v1/public/shard/feed"
|
||||||
@@ -761,14 +837,38 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/idoc"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/online"
|
"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",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/presence"
|
"path": "/api/v1/public/shard/presence"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/public/shard/ruleset"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/shard/status"
|
"path": "/api/v1/public/shard/status"
|
||||||
@@ -800,6 +900,14 @@
|
|||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/public/wiki/tags"
|
"path": "/api/v1/public/wiki/tags"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/settings/nav"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/settings/theme/options"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"internal": [
|
"internal": [
|
||||||
|
|||||||
127
server/scripts/importSpawnAtlas.js
Normal file
127
server/scripts/importSpawnAtlas.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
//
|
||||||
|
// Refresh the spawn atlas from a ServUO tree, from the command line.
|
||||||
|
//
|
||||||
|
// npm run atlas:import # use the configured path
|
||||||
|
// npm run atlas:import -- --servuo <path> # override it for this run
|
||||||
|
// npm run atlas:import -- --force # reimport even if unchanged
|
||||||
|
// npm run atlas:import -- --approve # apply a staged refresh
|
||||||
|
// npm run atlas:import -- --status # report without changing anything
|
||||||
|
//
|
||||||
|
// The server does this itself on every boot (see `shardAtlas.refreshOnBoot`), so
|
||||||
|
// this is for operators who want to apply a map change without a restart, and
|
||||||
|
// for approving a refresh that was staged because it would remove a facet.
|
||||||
|
//
|
||||||
|
// All the logic lives in `src/model/shardAtlas/shardAtlas.model.js`; this file
|
||||||
|
// is argument parsing and output formatting.
|
||||||
|
|
||||||
|
const db = () => require('../src/utils/db')
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {}
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const flag = argv[i]
|
||||||
|
if (flag === '--servuo') args.servuo = argv[++i]
|
||||||
|
else if (flag === '--force') args.force = true
|
||||||
|
else if (flag === '--approve') args.approve = true
|
||||||
|
else if (flag === '--reject') args.reject = true
|
||||||
|
else if (flag === '--status') args.status = true
|
||||||
|
else if (flag === '--help' || flag === '-h') args.help = true
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
const USAGE = `
|
||||||
|
Refresh the spawn atlas from a ServUO tree.
|
||||||
|
|
||||||
|
node scripts/importSpawnAtlas.js [options]
|
||||||
|
|
||||||
|
--servuo <path> Use this tree for this run instead of the configured path.
|
||||||
|
--force Reimport even when the source files are unchanged.
|
||||||
|
--approve Apply a refresh that was staged for removing a facet.
|
||||||
|
--reject Keep the current atlas and dismiss the staged refresh.
|
||||||
|
--status Report atlas and source state; change nothing.
|
||||||
|
|
||||||
|
With no options this imports only if the tree differs from what is loaded.
|
||||||
|
`
|
||||||
|
|
||||||
|
function describe(result) {
|
||||||
|
switch (result.status) {
|
||||||
|
case 'skipped':
|
||||||
|
return (
|
||||||
|
'No ServUO path configured — nothing to import.\n' +
|
||||||
|
'Set one with SERVUO_PATH, the admin panel, or --servuo <path>.\n'
|
||||||
|
)
|
||||||
|
case 'unavailable':
|
||||||
|
return `ServUO tree unavailable: ${result.reason}\n`
|
||||||
|
case 'unchanged':
|
||||||
|
return `Atlas is already up to date${result.reason ? ` (${result.reason})` : ''}.\n`
|
||||||
|
case 'needsReview': {
|
||||||
|
return (
|
||||||
|
'Refresh NOT applied — it would remove ' +
|
||||||
|
`${result.removedFacets.length} facet(s): ${result.removedFacets.join(', ')}.\n` +
|
||||||
|
'This is what a half-copied or mid-update tree looks like, so it has been\n' +
|
||||||
|
'staged for review. The current atlas is unchanged.\n' +
|
||||||
|
'Apply it with --approve, or dismiss it with --reject.\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'imported': {
|
||||||
|
const c = result.counts
|
||||||
|
const added = result.addedFacets?.length ? ` Added facets: ${result.addedFacets.join(', ')}.` : ''
|
||||||
|
const removed = result.removedFacets?.length
|
||||||
|
? ` Removed facets: ${result.removedFacets.join(', ')}.`
|
||||||
|
: ''
|
||||||
|
return (
|
||||||
|
`Atlas imported: ${c.points} points, ${c.creatures} creatures, ` +
|
||||||
|
`${c.pointTypes} point/type rows, ${c.regions} regions, ` +
|
||||||
|
`${c.landmarks} landmarks, ${c.champions} champion altars.${added}${removed}\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'failed':
|
||||||
|
return `Atlas refresh failed: ${result.reason}\n`
|
||||||
|
default:
|
||||||
|
return `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
if (args.help) {
|
||||||
|
process.stdout.write(USAGE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
|
||||||
|
|
||||||
|
// `--servuo` is a per-run override and deliberately does NOT persist to the
|
||||||
|
// configured path; changing where the atlas permanently reads from is an
|
||||||
|
// admin action, not a side effect of a one-off import.
|
||||||
|
const override = { path: args.servuo ?? '' }
|
||||||
|
|
||||||
|
if (args.status) {
|
||||||
|
process.stdout.write(`${JSON.stringify(await shardAtlas.status(override), null, 2)}\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (args.reject) {
|
||||||
|
process.stdout.write(`${JSON.stringify(await shardAtlas.rejectPending(), null, 2)}\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = args.approve
|
||||||
|
? await shardAtlas.approvePending(override)
|
||||||
|
: await shardAtlas.refresh({ ...override, force: Boolean(args.force) })
|
||||||
|
|
||||||
|
process.stdout.write(describe(result))
|
||||||
|
if (result.status === 'failed') process.exitCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
process.stderr.write(`atlas:import failed: ${err.message}\n`)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
.finally(() => db().close())
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { describe, parseArgs }
|
||||||
@@ -16,8 +16,10 @@ const brand = require('./config/brand')
|
|||||||
const csp = require('./config/csp')
|
const csp = require('./config/csp')
|
||||||
const { cspReportLimiter } = require('./middleware/rateLimit')
|
const { cspReportLimiter } = require('./middleware/rateLimit')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
|
const htmlShell = require('./utils/htmlShell')
|
||||||
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
||||||
const botScore = require('./middleware/botScore')
|
const botScore = require('./middleware/botScore')
|
||||||
|
const modules = require('./modules/loader')
|
||||||
|
|
||||||
const httpLog = createLogger('http')
|
const httpLog = createLogger('http')
|
||||||
const errLog = createLogger('error')
|
const errLog = createLogger('error')
|
||||||
@@ -96,31 +98,6 @@ const htmlEscape = (s) =>
|
|||||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
(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
|
// Uploaded images — always served, even during maintenance. Force nosniff so a
|
||||||
// stored file is never interpreted as anything other than its declared type
|
// 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
|
// (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) ─────────────────────────────────────────────
|
// ── API docs (Swagger UI) ─────────────────────────────────────────────
|
||||||
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
|
// 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/).
|
// 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'))) {
|
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
|
||||||
// Serve a branded copy of the index.html shell for every SPA route; assets keep
|
// Serve a branded copy of the index.html shell for every SPA route; assets keep
|
||||||
// their own cache-friendly static handler.
|
// 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.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 {
|
} else {
|
||||||
app.get('*', (req, res) =>
|
app.get('*', (req, res) =>
|
||||||
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.',
|
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
|
// 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
|
// 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
|
// a victim to load a page could otherwise use it as a log-flood amplifier. Generous
|
||||||
@@ -141,5 +154,6 @@ module.exports = {
|
|||||||
mobileSsoExchangeLimiter,
|
mobileSsoExchangeLimiter,
|
||||||
passwordResetRequestLimiter,
|
passwordResetRequestLimiter,
|
||||||
passwordResetConfirmLimiter,
|
passwordResetConfirmLimiter,
|
||||||
|
marketLimiter,
|
||||||
cspReportLimiter,
|
cspReportLimiter,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,12 @@ async function seedDefault(key, value) {
|
|||||||
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [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 settingsDb = require('./settings.db')
|
||||||
const brand = require('../../config/brand')
|
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.
|
// Keys safe to expose on the public site.
|
||||||
const PUBLIC_KEYS = [
|
const PUBLIC_KEYS = [
|
||||||
@@ -10,8 +13,27 @@ const PUBLIC_KEYS = [
|
|||||||
'contact_email',
|
'contact_email',
|
||||||
'site_title',
|
'site_title',
|
||||||
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
|
'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.
|
// Player self-registration mode. Stored under the 'player_registration' key.
|
||||||
// NOTE: the raw value is never exposed publicly — getPublic() derives boolean
|
// NOTE: the raw value is never exposed publicly — getPublic() derives boolean
|
||||||
// availability flags from it instead (see below).
|
// 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) {
|
async function get(key) {
|
||||||
return settingsDb.get(key)
|
return settingsDb.get(key)
|
||||||
}
|
}
|
||||||
@@ -78,6 +118,10 @@ async function set(key, value, updatedBy = null) {
|
|||||||
return settingsDb.set(key, value, updatedBy)
|
return settingsDb.set(key, value, updatedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remove(key) {
|
||||||
|
return settingsDb.remove(key)
|
||||||
|
}
|
||||||
|
|
||||||
async function setMany(obj, updatedBy = null) {
|
async function setMany(obj, updatedBy = null) {
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
await settingsDb.set(key, value, updatedBy)
|
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.
|
// 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'
|
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
|
||||||
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
|
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
|
||||||
// Instance branding (BRAND_* env defaults). The two admin-editable settings —
|
// The effective CSS custom properties for the admin's theme, or absent when
|
||||||
// site title and contact email — override the env value when set, so existing
|
// no theme_visual row exists (or nothing in it was usable). The SPA writes
|
||||||
// installs keep their DB-configured name; everything else comes from env.
|
// 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 = {
|
out.brand = {
|
||||||
name: out.site_title || brand.name,
|
name: out.site_title || brand.name,
|
||||||
shortName: brand.shortName,
|
shortName: brand.shortName,
|
||||||
@@ -116,10 +180,10 @@ async function getPublic() {
|
|||||||
description: brand.description,
|
description: brand.description,
|
||||||
contactEmail: out.contact_email || brand.contactEmail,
|
contactEmail: out.contact_email || brand.contactEmail,
|
||||||
url: brand.url,
|
url: brand.url,
|
||||||
accent: brand.accent,
|
accent: theme?.['--accent'] || brand.accent,
|
||||||
logo: brand.logo,
|
logo: brandAssets.logo || brand.logo,
|
||||||
hero: brand.hero,
|
hero: brandAssets.hero || brand.hero,
|
||||||
favicon: brand.favicon,
|
favicon: brandAssets.favicon || brand.favicon,
|
||||||
}
|
}
|
||||||
// Push-notification relay (M7). The client-facing ntfy base URL the app's
|
// Push-notification relay (M7). The client-facing ntfy base URL the app's
|
||||||
// embedded distributor registers its device topic against; null when push is
|
// embedded distributor registers its device topic against; null when push is
|
||||||
@@ -137,6 +201,41 @@ async function getPublic() {
|
|||||||
return out
|
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.
|
// The client-facing ntfy base URL (no trailing slash), or null when unset.
|
||||||
function publicNtfyUrl() {
|
function publicNtfyUrl() {
|
||||||
const explicit = (process.env.NTFY_PUBLIC_URL || '').trim()
|
const explicit = (process.env.NTFY_PUBLIC_URL || '').trim()
|
||||||
@@ -151,10 +250,16 @@ function publicNtfyUrl() {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
get,
|
get,
|
||||||
set,
|
set,
|
||||||
|
remove,
|
||||||
setMany,
|
setMany,
|
||||||
getAll,
|
getAll,
|
||||||
getPublic,
|
getPublic,
|
||||||
|
getShellBrand,
|
||||||
|
getNav,
|
||||||
|
getInstanceName,
|
||||||
PUBLIC_KEYS,
|
PUBLIC_KEYS,
|
||||||
|
THEMING_KEYS,
|
||||||
|
DELETABLE_KEYS,
|
||||||
REGISTRATION_KEY,
|
REGISTRATION_KEY,
|
||||||
REGISTRATION_MODES,
|
REGISTRATION_MODES,
|
||||||
getRegistrationMode,
|
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,
|
||||||
|
}
|
||||||
@@ -16,6 +16,11 @@ async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
|
|||||||
// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak
|
// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak
|
||||||
// staff/sensitive kinds. limit is clamped by the model.
|
// staff/sensitive kinds. limit is clamped by the model.
|
||||||
async function list({ kind, kinds, limit }) {
|
async function list({ kind, kinds, limit }) {
|
||||||
|
// An allowlist that resolved to NOTHING means "serve nothing" — never "serve
|
||||||
|
// everything". Falling through to the unfiltered query below would have turned
|
||||||
|
// a fully-gated visibility config into a full dump of the event log, staff
|
||||||
|
// audit and cheat detections included.
|
||||||
|
if (kinds && kinds.length === 0) return []
|
||||||
if (kinds && kinds.length) {
|
if (kinds && kinds.length) {
|
||||||
const placeholders = kinds.map(() => '?').join(', ')
|
const placeholders = kinds.map(() => '?').join(', ')
|
||||||
return query(
|
return query(
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
@@ -259,6 +259,69 @@ async function latestPresence() {
|
|||||||
return rows[0] || null
|
return rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shard ruleset (Protocol 3.0 world.ruleset) ─────────────────────────────
|
||||||
|
// Singleton, same shape as shard_presence: the shard re-emits the whole frame on
|
||||||
|
// every connect, so there is nothing to merge — the latest one wins outright.
|
||||||
|
async function setRuleset({ rev, expansion, payload, t }) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO shard_ruleset (id, rev, expansion, payload, t) VALUES (1, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE rev = VALUES(rev), expansion = VALUES(expansion),
|
||||||
|
payload = VALUES(payload), t = VALUES(t)`,
|
||||||
|
[rev ?? null, expansion ?? null, payload, Number.isFinite(t) ? t : null],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRuleset() {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT rev, expansion, payload, t, updated_at FROM shard_ruleset WHERE id = 1',
|
||||||
|
)
|
||||||
|
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 = {
|
module.exports = {
|
||||||
upsertOnline,
|
upsertOnline,
|
||||||
removeOnline,
|
removeOnline,
|
||||||
@@ -290,6 +353,11 @@ module.exports = {
|
|||||||
listGovernorTerms,
|
listGovernorTerms,
|
||||||
setPresence,
|
setPresence,
|
||||||
latestPresence,
|
latestPresence,
|
||||||
|
setRuleset,
|
||||||
|
getRuleset,
|
||||||
|
upsertPointsBoard,
|
||||||
|
listPointsBoards,
|
||||||
|
getPointsBoard,
|
||||||
upsertChamp,
|
upsertChamp,
|
||||||
removeChamp,
|
removeChamp,
|
||||||
clearChamps,
|
clearChamps,
|
||||||
|
|||||||
@@ -508,6 +508,79 @@ async function latestPresence() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shard ruleset (Protocol 3.0 world.ruleset) ─────────────────────────────
|
||||||
|
//
|
||||||
|
// The whole frame is stored in `payload` and served back whole. Nothing is
|
||||||
|
// normalized out of it: it is a flat description of config read as one page, and
|
||||||
|
// splitting it into columns would mean a schema change every time the shard grows
|
||||||
|
// a new block. `rev` and `expansion` are hoisted only because they are cheap to
|
||||||
|
// index/display, following shard_champs' payload-plus-hoisted-columns pattern.
|
||||||
|
async function setRuleset(ev) {
|
||||||
|
if (!ev) return
|
||||||
|
await db.setRuleset({
|
||||||
|
rev: ev.rev ?? null,
|
||||||
|
expansion: ev.expansion ?? null,
|
||||||
|
payload: JSON.stringify(ev),
|
||||||
|
t: ev.t,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored ruleset, or null when the shard has never published one (an old
|
||||||
|
// plugin, or Bridge.RulesetEnabled=false). Null is a real answer here — the page
|
||||||
|
// says "not published yet" rather than rendering an empty ruleset as if the shard
|
||||||
|
// had no rules — so it is deliberately not smoothed into {}.
|
||||||
|
async function getRuleset() {
|
||||||
|
const r = await db.getRuleset()
|
||||||
|
if (!r) return null
|
||||||
|
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||||
|
if (!payload) return null
|
||||||
|
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) {
|
function safeJson(s) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(s)
|
return JSON.parse(s)
|
||||||
@@ -557,4 +630,9 @@ module.exports = {
|
|||||||
replaceGovernors,
|
replaceGovernors,
|
||||||
setPresence,
|
setPresence,
|
||||||
latestPresence,
|
latestPresence,
|
||||||
|
setRuleset,
|
||||||
|
getRuleset,
|
||||||
|
upsertPointsBoard,
|
||||||
|
listPointsBoards,
|
||||||
|
getPointsBoard,
|
||||||
}
|
}
|
||||||
|
|||||||
37
server/src/model/shardVisibility/shardVisibility.db.js
Normal file
37
server/src/model/shardVisibility/shardVisibility.db.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
// 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 }
|
||||||
44
server/src/model/shardVisibility/shardVisibility.model.js
Normal file
44
server/src/model/shardVisibility/shardVisibility.model.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// ── Shard feature visibility (model) ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
|
||||||
|
// feature catalog, the locked fields, the kind→feature map — lives in
|
||||||
|
// utils/shardVisibility.js; this file only reads and writes rows.
|
||||||
|
|
||||||
|
const db = require('./shardVisibility.db')
|
||||||
|
|
||||||
|
// The `field_rules` JSON column comes back as a string on the mariadb driver.
|
||||||
|
function parseRules(raw) {
|
||||||
|
if (raw == null) return {}
|
||||||
|
if (typeof raw === 'object') return raw
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSafe = (row) =>
|
||||||
|
row && {
|
||||||
|
feature: row.feature,
|
||||||
|
enabled: !!row.enabled,
|
||||||
|
audience: row.audience,
|
||||||
|
stream: row.stream == null ? null : !!row.stream,
|
||||||
|
fieldRules: parseRules(row.field_rules),
|
||||||
|
updatedBy: row.updated_by,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAll() {
|
||||||
|
const rows = await db.listAll()
|
||||||
|
return rows.map(toSafe)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOne(feature) {
|
||||||
|
const rows = await db.getOne(feature)
|
||||||
|
return toSafe(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsert = (entry) => db.upsert(entry)
|
||||||
|
|
||||||
|
module.exports = { listAll, getOne, upsert }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user