Compare commits
38 Commits
a3407ae654
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ 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 Atlas from './routes/public/Atlas.jsx'
|
||||||
|
import AtlasCreature from './routes/public/AtlasCreature.jsx'
|
||||||
|
import Leaderboards from './routes/public/Leaderboards.jsx'
|
||||||
|
import Market from './routes/public/Market.jsx'
|
||||||
|
import MarketVendor from './routes/public/MarketVendor.jsx'
|
||||||
import Wiki from './routes/wiki/Wiki.jsx'
|
import 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 +41,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,6 +107,12 @@ 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/atlas" element={<Atlas />} />
|
||||||
|
<Route path="/site/atlas/:slug" element={<AtlasCreature />} />
|
||||||
|
<Route path="/site/leaderboards" element={<Leaderboards />} />
|
||||||
|
<Route path="/site/market" element={<Market />} />
|
||||||
|
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
|
||||||
<Route path="/wiki" element={<Wiki />} />
|
<Route path="/wiki" element={<Wiki />} />
|
||||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
{/* CMS pages: top-level /:slug, matched only after the named routes
|
{/* CMS pages: top-level /:slug, matched only after the named routes
|
||||||
@@ -125,6 +141,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 +180,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={
|
||||||
|
|||||||
@@ -97,6 +97,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 +155,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 +288,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 +437,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 { 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: 'Atlas', to: '/site/atlas', feature: 'atlas' },
|
||||||
|
{ label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
||||||
|
{ label: 'Market', to: '/site/market', feature: 'market' },
|
||||||
{ label: 'About', to: '/site/about' },
|
{ label: 'About', to: '/site/about' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -28,7 +47,24 @@ 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.
|
||||||
|
const nav = useMemo(() => {
|
||||||
|
const tree = buildPublicNav(NAV, parseJsonSetting(settings.nav_public))
|
||||||
|
return pruneNav(tree, (item) => !item.feature || canSee(shardFeatures, item.feature))
|
||||||
|
}, [settings.nav_public, shardFeatures])
|
||||||
|
|
||||||
// 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 +92,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)
|
||||||
|
}
|
||||||
@@ -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)' }}>
|
||||||
|
|||||||
310
client/src/routes/public/Atlas.jsx
Normal file
310
client/src/routes/public/Atlas.jsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// What the shard CONTAINS, as opposed to what it is doing: which creatures
|
||||||
|
// spawn, where, and which champion altars are configured. There is no live feed
|
||||||
|
// here and no `connected` indicator, deliberately — this is parsed from the
|
||||||
|
// shard's own files and stays complete while the shard is down.
|
||||||
|
//
|
||||||
|
// Facet names come from the shard's data, never from a list in this file. A
|
||||||
|
// shard running custom maps gets its own names in the filter with no code
|
||||||
|
// change (docs/link/v3.md §6.1 R2).
|
||||||
|
|
||||||
|
const PAGE = 50
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'creatures', label: 'Creatures' },
|
||||||
|
{ key: 'champions', label: 'Champion altars' },
|
||||||
|
{ key: 'places', label: 'Places' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function Chip({ active, onClick, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
padding: '5px 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||||
|
background: active ? 'var(--accent)' : 'transparent',
|
||||||
|
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreatureCard({ creature }) {
|
||||||
|
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
|
||||||
|
className="panel"
|
||||||
|
style={{
|
||||||
|
padding: '13px 15px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div
|
||||||
|
className="display"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.98rem',
|
||||||
|
color: 'var(--head)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{creature.name}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||||
|
{facets.length === 0
|
||||||
|
? '—'
|
||||||
|
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||||
|
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
|
||||||
|
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
|
||||||
|
{num(creature.points)} spawners
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The creature list owns its own paging rather than going through useAsync: a
|
||||||
|
// "load more" appends to what is already on screen, which a hook that resets to
|
||||||
|
// `{ loading: true, data: null }` on every dependency change cannot express.
|
||||||
|
function Creatures({ q, facet }) {
|
||||||
|
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
|
||||||
|
const [more, setMore] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (offset) => {
|
||||||
|
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
|
||||||
|
return page
|
||||||
|
},
|
||||||
|
[q, facet],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
setState({ loading: true, error: null, items: [], total: 0 })
|
||||||
|
load(0)
|
||||||
|
.then((page) => {
|
||||||
|
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
|
||||||
|
})
|
||||||
|
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
|
||||||
|
return () => {
|
||||||
|
alive = false
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
setMore(true)
|
||||||
|
try {
|
||||||
|
const page = await load(state.items.length)
|
||||||
|
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
|
||||||
|
} catch {
|
||||||
|
// A failed "load more" leaves what is already on screen alone; the button
|
||||||
|
// simply stays available to retry.
|
||||||
|
} finally {
|
||||||
|
setMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.loading) return <Loading />
|
||||||
|
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
|
||||||
|
if (state.items.length === 0) {
|
||||||
|
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||||
|
Showing {num(state.items.length)} of {num(state.total)}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{state.items.map((c) => (
|
||||||
|
<CreatureCard key={c.slug} creature={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{state.items.length < state.total && (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||||
|
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||||
|
{more ? 'Loading…' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CONFIGURED altar roster — where the altars are and what each summons. The
|
||||||
|
// live board ("it is on level 3 right now") is a different page, /site/champs,
|
||||||
|
// fed by the sidecar. Both exist; they are not the same thing.
|
||||||
|
function Champions({ facet }) {
|
||||||
|
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load the champion altars right now." />
|
||||||
|
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{data.map((champ) => (
|
||||||
|
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
|
||||||
|
{champ.label || champ.name}
|
||||||
|
</div>
|
||||||
|
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||||
|
{champ.facet}
|
||||||
|
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
|
||||||
|
{champ.randomType ? 'Random champion' : champ.type || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regions and landmarks together: both answer "where is that?", and splitting
|
||||||
|
// them into two tabs would make the visitor guess which list a name lives in.
|
||||||
|
function Places({ q, facet }) {
|
||||||
|
const { loading, error, data } = useAsync(
|
||||||
|
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
|
||||||
|
[q, facet],
|
||||||
|
)
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!data) return []
|
||||||
|
const [regions, landmarks] = data
|
||||||
|
return [
|
||||||
|
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
|
||||||
|
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
|
||||||
|
].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
if (error) return <ErrorState message="Could not load places right now." />
|
||||||
|
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
|
||||||
|
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Atlas() {
|
||||||
|
const [tab, setTab] = useState('creatures')
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [facet, setFacet] = useState('')
|
||||||
|
const meta = useAsync(() => api.atlas.meta())
|
||||||
|
|
||||||
|
// Debounced: typing "lizardman" should be one request, not nine.
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setQ(input.trim()), 250)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [input])
|
||||||
|
|
||||||
|
const facets = meta.data?.facets || []
|
||||||
|
const counts = meta.data?.counts || null
|
||||||
|
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Bestiary"
|
||||||
|
title="Spawn atlas"
|
||||||
|
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* The atlas is only as good as its placement rate, so the page states
|
||||||
|
it rather than implying every spawner resolved to a named place. */}
|
||||||
|
{counts && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||||
|
{num(counts.creatures)} creatures across {num(counts.points)} spawners
|
||||||
|
{Number.isFinite(counts.unresolvedPoints) && counts.points
|
||||||
|
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
|
||||||
|
: ''}
|
||||||
|
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||||
|
{t.label}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab !== 'champions' && (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
|
||||||
|
style={{ width: '100%', marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{facets.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||||
|
<Chip active={facet === ''} onClick={() => setFacet('')}>
|
||||||
|
All facets
|
||||||
|
</Chip>
|
||||||
|
{facets.map((f) => (
|
||||||
|
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
|
||||||
|
{f}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta.error && <ErrorState message="Could not load the atlas right now." />}
|
||||||
|
{!meta.error && !meta.loading && !imported && (
|
||||||
|
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!meta.error && imported && (
|
||||||
|
<>
|
||||||
|
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
|
||||||
|
{tab === 'champions' && <Champions facet={facet} />}
|
||||||
|
{tab === 'places' && <Places q={q} facet={facet} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
201
client/src/routes/public/AtlasCreature.jsx
Normal file
201
client/src/routes/public/AtlasCreature.jsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
||||||
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
||||||
|
import { useAsync } from '../../lib/useAsync.js'
|
||||||
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
|
// One creature: where it spawns, and what spawns alongside it.
|
||||||
|
//
|
||||||
|
// `places` is the point of the page — the aggregate that turns 62 raw
|
||||||
|
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
|
||||||
|
// available underneath for the reader who actually wants a coordinate, but they
|
||||||
|
// are secondary and collapsed by default.
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||||
|
|
||||||
|
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
|
||||||
|
function delay(min, max) {
|
||||||
|
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
||||||
|
if (min === max) return fmt(min)
|
||||||
|
return `${fmt(min)}–${fmt(max)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function Panel({ title, right, children }) {
|
||||||
|
return (
|
||||||
|
<section className="panel" style={{ padding: 18 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Places({ places }) {
|
||||||
|
if (places.length === 0) {
|
||||||
|
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{places.map((place) => (
|
||||||
|
<div
|
||||||
|
key={`${place.facet}:${place.label}`}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '6px 0',
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
fontSize: '0.86rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
|
||||||
|
<span className="dim" style={{ flex: 'none' }}>
|
||||||
|
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
|
||||||
|
{num(place.maxAlive)} at once
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spawners({ spawners, truncated }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
if (spawners.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Individual spawners"
|
||||||
|
right={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sans"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
|
||||||
|
>
|
||||||
|
{open ? 'Hide' : `Show ${num(spawners.length)}`}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{open && (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
|
||||||
|
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
|
||||||
|
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{spawners.map((s) => (
|
||||||
|
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
|
||||||
|
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{truncated && (
|
||||||
|
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||||
|
Only the largest spawners are listed.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AtlasCreature() {
|
||||||
|
const { slug } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
|
||||||
|
|
||||||
|
// A 404 here means "no such creature in this atlas", which is a real answer
|
||||||
|
// and not a failure — a visitor following a stale link deserves to be told
|
||||||
|
// that plainly rather than shown a generic error box.
|
||||||
|
const missing = error?.status === 404 || error?.message === 'Not Found'
|
||||||
|
|
||||||
|
const facets = useMemo(
|
||||||
|
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
|
||||||
|
[data],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicLayout section="website">
|
||||||
|
<div className="shell-narrow page-body">
|
||||||
|
<p className="sans" style={{ marginBottom: 8 }}>
|
||||||
|
<Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||||
|
← Spawn atlas
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && <Loading />}
|
||||||
|
{error && !missing && <ErrorState message="Could not load that creature right now." />}
|
||||||
|
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
|
||||||
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Bestiary"
|
||||||
|
title={data.name}
|
||||||
|
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Panel
|
||||||
|
title="Where it spawns"
|
||||||
|
right={
|
||||||
|
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||||
|
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Places places={data.places || []} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
|
||||||
|
|
||||||
|
{data.alsoHere?.length > 0 && (
|
||||||
|
<Panel title="Shares a spawner with">
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
{data.alsoHere.map((other) => (
|
||||||
|
<Link
|
||||||
|
key={other.slug}
|
||||||
|
to={`/site/atlas/${encodeURIComponent(other.slug)}`}
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
padding: '4px 11px',
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{other.name} <span className="dim">×{num(other.shared)}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PublicLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
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, [])
|
||||||
|
})
|
||||||
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,189 @@ 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;
|
||||||
|
|
||||||
|
-- ── 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;
|
||||||
|
|
||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- 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;
|
||||||
|
|
||||||
-- 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 +1397,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,6 +16,7 @@ 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')
|
||||||
|
|
||||||
@@ -96,31 +97,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
|
||||||
@@ -204,9 +180,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,
|
||||||
|
|||||||
390
server/src/model/shardAtlas/shardAtlas.db.js
Normal file
390
server/src/model/shardAtlas/shardAtlas.db.js
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
const { pool, query } = require('../../utils/db')
|
||||||
|
|
||||||
|
// 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
server/src/model/shardAtlas/shardAtlas.model.js
Normal file
485
server/src/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('../settings/settings.model')
|
||||||
|
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||||
|
const {
|
||||||
|
AtlasSourceError,
|
||||||
|
PARSER_VERSION,
|
||||||
|
buildAtlas,
|
||||||
|
hashSources,
|
||||||
|
sameSources,
|
||||||
|
} = require('../../utils/spawnAtlasSource')
|
||||||
|
const log = require('../../utils/logger')('shardAtlas')
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
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 }
|
||||||
@@ -7,7 +7,10 @@
|
|||||||
const db = require('./uoLinkConfig.db')
|
const db = require('./uoLinkConfig.db')
|
||||||
const secretBox = require('../../utils/secretBox')
|
const secretBox = require('../../utils/secretBox')
|
||||||
|
|
||||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 1
|
// The wire protocol this build speaks (link/sidecar/src/main.rs PROTOCOL_VERSION).
|
||||||
|
// Only used before an admin has saved anything — the stored row wins once it exists,
|
||||||
|
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
||||||
|
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 3
|
||||||
|
|
||||||
function toSafe(row) {
|
function toSafe(row) {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
const posts = require('../../../model/posts/posts.model')
|
const posts = require('../../../model/posts/posts.model')
|
||||||
const wiki = require('../../../model/wiki/wiki.model')
|
const wiki = require('../../../model/wiki/wiki.model')
|
||||||
const settings = require('../../../model/settings/settings.model')
|
const settings = require('../../../model/settings/settings.model')
|
||||||
@@ -9,6 +11,11 @@ const announceJobs = require('../../../model/announceJobs/announceJobs.model')
|
|||||||
const newsGump = require('../../../utils/newsGump')
|
const newsGump = require('../../../utils/newsGump')
|
||||||
const pushDispatch = require('../../../utils/pushDispatch')
|
const pushDispatch = require('../../../utils/pushDispatch')
|
||||||
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
const { cleanBody } = require('../../../utils/sanitizeHtml')
|
||||||
|
const { parseJsonSetting } = require('../../../utils/settingsJson')
|
||||||
|
const { validateThemeVisual } = require('../../../utils/themeResolve')
|
||||||
|
const { validateBrandAssets, resolveBrandAssets } = require('../../../utils/brandAssets')
|
||||||
|
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../../../utils/navOverrides')
|
||||||
|
const htmlShell = require('../../../utils/htmlShell')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('admin')
|
const log = require('../../../utils/logger')('admin')
|
||||||
|
|
||||||
@@ -529,8 +536,61 @@ async function updateSettings(req, res) {
|
|||||||
if (typeof updates.homepage_teaser === 'string') {
|
if (typeof updates.homepage_teaser === 'string') {
|
||||||
updates.homepage_teaser = cleanBody(updates.homepage_teaser)
|
updates.homepage_teaser = cleanBody(updates.homepage_teaser)
|
||||||
}
|
}
|
||||||
|
// theme_visual is JSON whose values become CSS custom properties, so every
|
||||||
|
// one has to come from the closed sets in config/themePresets.js. The read
|
||||||
|
// path drops anything invalid anyway (THEMING_AND_NAV.md §4.4), but silently
|
||||||
|
// storing a value that will never apply is a bad admin experience — reject it
|
||||||
|
// with the offending field named instead. Accepts an object or the stringified
|
||||||
|
// form, and stores it stringified either way, since settings.value is TEXT.
|
||||||
|
if ('theme_visual' in updates) {
|
||||||
|
const raw = updates.theme_visual
|
||||||
|
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||||
|
if (typeof raw === 'string' && parsed === null) {
|
||||||
|
return res.status(400).json({ message: 'theme_visual must be a JSON object' })
|
||||||
|
}
|
||||||
|
const check = validateThemeVisual(parsed)
|
||||||
|
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||||
|
updates.theme_visual = JSON.stringify(parsed)
|
||||||
|
}
|
||||||
|
// brand_assets holds the only settings values written straight into HTML the
|
||||||
|
// browser then fetches (an <img src>, a <link rel="icon">, an og:image), so
|
||||||
|
// the accepted shape is narrow — see utils/brandAssets.js. Cleared slots are
|
||||||
|
// dropped rather than stored as null, keeping "a field is absent" the single
|
||||||
|
// meaning of "falls back to BRAND_* env".
|
||||||
|
if ('brand_assets' in updates) {
|
||||||
|
const raw = updates.brand_assets
|
||||||
|
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||||
|
if (typeof raw === 'string' && parsed === null) {
|
||||||
|
return res.status(400).json({ message: 'brand_assets must be a JSON object' })
|
||||||
|
}
|
||||||
|
const check = validateBrandAssets(parsed)
|
||||||
|
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||||
|
updates.brand_assets = JSON.stringify(resolveBrandAssets(parsed))
|
||||||
|
}
|
||||||
|
// The three nav rows are JSON too, and without this they would reach
|
||||||
|
// settingsDb.set as objects and be stored as the string "[object Object]".
|
||||||
|
// Shape only — whether a key names a route the nav actually declares is the
|
||||||
|
// client's question, and utils/navOverrides.js says why. Resolved on the way
|
||||||
|
// in so the stored row carries no dead fields, and so `hidden` can never land
|
||||||
|
// on the nav editor's own row.
|
||||||
|
for (const key of NAV_KEYS) {
|
||||||
|
if (!(key in updates)) continue
|
||||||
|
const raw = updates[key]
|
||||||
|
const parsed = typeof raw === 'string' ? parseJsonSetting(raw) : raw
|
||||||
|
if (typeof raw === 'string' && parsed === null) {
|
||||||
|
return res.status(400).json({ message: `${key} must be a JSON object` })
|
||||||
|
}
|
||||||
|
const check = validateNavOverrides(parsed, key)
|
||||||
|
if (!check.ok) return res.status(400).json({ message: check.message })
|
||||||
|
updates[key] = JSON.stringify(resolveNavOverrides(parsed, key))
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await settings.setMany(updates, req.user.id)
|
await settings.setMany(updates, req.user.id)
|
||||||
|
// The HTML shell is templated from brand_assets and theme_visual, and is
|
||||||
|
// cached per process (utils/htmlShell.js) — a write that can change it has
|
||||||
|
// to say so, or the favicon an admin just uploaded appears only after the
|
||||||
|
// cache's TTL.
|
||||||
|
if ('brand_assets' in updates || 'theme_visual' in updates) htmlShell.invalidate()
|
||||||
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
|
await activity.log({ req, action: 'settings.update', detail: { keys: Object.keys(updates) } })
|
||||||
return res.json(await settings.getAll())
|
return res.json(await settings.getAll())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -539,6 +599,115 @@ async function updateSettings(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Brand assets ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Per-slot rules applied on top of the shared multer allowlist. The allowlist
|
||||||
|
// itself is never widened (§9: "no second upload path with weaker validation") —
|
||||||
|
// these only ever tighten it:
|
||||||
|
//
|
||||||
|
// • favicon — PNG only. .ico would mean adding a new type to MIME_EXT, and the
|
||||||
|
// fact that the stored extension comes from that map is exactly what makes
|
||||||
|
// the upload path safe (§4.10). Every browser this app supports takes a PNG
|
||||||
|
// icon. Small cap: a favicon is a handful of KB.
|
||||||
|
// • logo — a header mark next to the site title, not a page image.
|
||||||
|
// • hero — a full-bleed background, so it keeps the shared ceiling.
|
||||||
|
//
|
||||||
|
// The cap is checked after multer has written the file rather than by a second
|
||||||
|
// multer instance: one upload config, one allowlist, and the oversized file is
|
||||||
|
// unlinked before we answer.
|
||||||
|
const ASSET_RULES = {
|
||||||
|
logo: { maxBytes: 1024 * 1024, mimetypes: null, label: 'Logo' },
|
||||||
|
hero: { maxBytes: 8 * 1024 * 1024, mimetypes: null, label: 'Hero image' },
|
||||||
|
favicon: { maxBytes: 512 * 1024, mimetypes: ['image/png'], label: 'Favicon' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const prettyBytes = (n) => (n >= 1024 * 1024 ? `${Math.round(n / (1024 * 1024))} MB` : `${Math.round(n / 1024)} KB`)
|
||||||
|
|
||||||
|
// Best-effort cleanup of a file we have decided not to keep. A failure here is
|
||||||
|
// a stray file in /uploads, not something the caller can act on.
|
||||||
|
async function discardUpload(file) {
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(file.path)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('discardUpload', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/settings/brand-asset/:slot — upload one brand asset and point the
|
||||||
|
* brand_assets row at it in the same call.
|
||||||
|
*
|
||||||
|
* One call rather than "upload, then PUT the settings row": a half-completed
|
||||||
|
* save would otherwise leave a file in /uploads that nothing references, and the
|
||||||
|
* per-slot rules above need the slot at upload time anyway. Admin-only, matching
|
||||||
|
* the gate on the settings it writes — POST /admin/uploads is reachable by
|
||||||
|
* editors, who have no business changing the site's identity.
|
||||||
|
*/
|
||||||
|
async function uploadBrandAsset(req, res) {
|
||||||
|
const { slot } = req.params
|
||||||
|
const rules = ASSET_RULES[slot]
|
||||||
|
if (!rules) {
|
||||||
|
if (req.file) await discardUpload(req.file)
|
||||||
|
return res.status(400).json({ message: `Unknown brand asset '${slot}'` })
|
||||||
|
}
|
||||||
|
if (!req.file) return res.status(400).json({ message: 'No file uploaded' })
|
||||||
|
if (rules.mimetypes && !rules.mimetypes.includes(req.file.mimetype)) {
|
||||||
|
await discardUpload(req.file)
|
||||||
|
return res.status(400).json({ message: `${rules.label} must be a PNG image` })
|
||||||
|
}
|
||||||
|
if (req.file.size > rules.maxBytes) {
|
||||||
|
await discardUpload(req.file)
|
||||||
|
return res.status(400).json({ message: `${rules.label} must be ${prettyBytes(rules.maxBytes)} or smaller` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/uploads/${req.file.filename}`
|
||||||
|
try {
|
||||||
|
// Read-modify-write the row: uploading a logo must not clear a hero the
|
||||||
|
// admin set earlier (§6.3). Resolved on the way in, so a hand-edited row
|
||||||
|
// with one bad slot does not block setting another.
|
||||||
|
const current = resolveBrandAssets(parseJsonSetting(await settings.get('brand_assets')))
|
||||||
|
const next = { ...current, [slot]: url }
|
||||||
|
await settings.set('brand_assets', JSON.stringify(next), req.user.id)
|
||||||
|
htmlShell.invalidate()
|
||||||
|
await activity.log({ req, action: 'settings.brandAsset', detail: { slot, url } })
|
||||||
|
return res.status(201).json({ url, brand_assets: next })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('uploadBrandAsset', err)
|
||||||
|
// The row is the point of the call; a stored file nothing points at is
|
||||||
|
// litter, so it goes back out with the error.
|
||||||
|
await discardUpload(req.file)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete one settings row — the "reset to defaults" primitive.
|
||||||
|
//
|
||||||
|
// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the
|
||||||
|
// hardcoded NAV arrays; the *absence* of the row is what selects them
|
||||||
|
// (docs/website/THEMING_AND_NAV.md §2). Resetting therefore has to delete, not
|
||||||
|
// store a copy of the defaults, or the next change to a default would not reach
|
||||||
|
// an instance that had ever pressed reset.
|
||||||
|
//
|
||||||
|
// The key allowlist is the point of the route: an unrestricted DELETE would let
|
||||||
|
// a stray request drop site_mode or the uo-link config, where absence means
|
||||||
|
// something else entirely. Deleting a key that is not set succeeds — reset is
|
||||||
|
// idempotent and the UI should not have to know whether a row exists.
|
||||||
|
async function deleteSetting(req, res) {
|
||||||
|
const { key } = req.params
|
||||||
|
if (!settings.DELETABLE_KEYS.includes(key)) {
|
||||||
|
return res.status(400).json({ message: 'Setting is not resettable' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await settings.remove(key)
|
||||||
|
if (key === 'brand_assets' || key === 'theme_visual') htmlShell.invalidate()
|
||||||
|
await activity.log({ req, action: 'settings.reset', detail: { key } })
|
||||||
|
return res.json({ message: 'Setting reset to default' })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('deleteSetting', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Activity log ──────────────────────────────────────────────────────
|
// ── Activity log ──────────────────────────────────────────────────────
|
||||||
async function listActivity(req, res) {
|
async function listActivity(req, res) {
|
||||||
const limit = Math.min(Number(req.query.limit) || 50, 200)
|
const limit = Math.min(Number(req.query.limit) || 50, 200)
|
||||||
@@ -764,6 +933,9 @@ module.exports = {
|
|||||||
deleteWikiCategory,
|
deleteWikiCategory,
|
||||||
getSettings,
|
getSettings,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
|
deleteSetting,
|
||||||
|
uploadBrandAsset,
|
||||||
|
ASSET_RULES,
|
||||||
listActivity,
|
listActivity,
|
||||||
listUsers,
|
listUsers,
|
||||||
createUser,
|
createUser,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
const express = require('express')
|
const express = require('express')
|
||||||
|
|
||||||
const ctrl = require('./admin.controller')
|
const ctrl = require('./admin.controller')
|
||||||
|
const { upload } = require('./imageUpload')
|
||||||
const { requireRole } = require('../../../utils/auth')
|
const { requireRole } = require('../../../utils/auth')
|
||||||
|
|
||||||
const settingsRouter = express.Router()
|
const settingsRouter = express.Router()
|
||||||
@@ -33,6 +34,7 @@ settingsRouter.put(
|
|||||||
// #swagger.tags = ['Admin · Settings']
|
// #swagger.tags = ['Admin · Settings']
|
||||||
// #swagger.summary = 'Update site settings (admin only)'
|
// #swagger.summary = 'Update site settings (admin only)'
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.description = 'Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.'
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true, description: "An object of key/value settings." } } } } */
|
||||||
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
/* #swagger.responses[200] = { description: 'Updated settings', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[400] = { description: 'Body must be an object of key/value settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
@@ -41,5 +43,42 @@ settingsRouter.put(
|
|||||||
adminOnly,
|
adminOnly,
|
||||||
ctrl.updateSettings,
|
ctrl.updateSettings,
|
||||||
)
|
)
|
||||||
|
// Upload one brand asset (logo/hero/favicon) and point brand_assets at it in the
|
||||||
|
// same call — see the controller for why it is one call and not "upload, then
|
||||||
|
// PUT". Uses the shared multer config (one upload directory, one mimetype
|
||||||
|
// allowlist); the per-slot PNG rule and size caps are applied in the handler.
|
||||||
|
settingsRouter.post(
|
||||||
|
'/brand-asset/:slot',
|
||||||
|
// #swagger.tags = ['Admin · Settings']
|
||||||
|
// #swagger.summary = 'Upload a brand asset and set it as the override (admin only)'
|
||||||
|
// #swagger.description = 'Stores the image and writes the brand_assets settings row in one call, so an upload never leaves an unreferenced file. Favicons must be PNG (max 512 KB); logos max 1 MB; heroes max 8 MB. Absent slots keep falling back to the BRAND_* env defaults — uploading a logo does not clear a hero.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.parameters['slot'] = { in: 'path', required: true, description: 'Which asset to replace', schema: { type: 'string', enum: ['logo', 'hero', 'favicon'] } } */
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object", properties: { image: { type: "string", format: "binary" } } } } } } */
|
||||||
|
/* #swagger.responses[201] = { description: 'Stored file URL and the updated overrides', content: { "application/json": { schema: { type: "object", properties: { url: { type: "string", example: "/uploads/1712345678901-ab12cd34.png" }, brand_assets: { type: "object", properties: { logo: { type: "string" }, hero: { type: "string" }, favicon: { type: "string" } } } } } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'No file, unknown slot, disallowed type, or over the slot size cap', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
upload.single('image'),
|
||||||
|
ctrl.uploadBrandAsset,
|
||||||
|
)
|
||||||
|
// Reset one setting to its default by deleting the row. Only the keys whose
|
||||||
|
// default lives outside the store (theming, nav, hero draft) are deletable —
|
||||||
|
// the controller holds the allowlist.
|
||||||
|
settingsRouter.delete(
|
||||||
|
'/:key',
|
||||||
|
// #swagger.tags = ['Admin · Settings']
|
||||||
|
// #swagger.summary = 'Reset one setting to its default (admin only)'
|
||||||
|
// #swagger.description = 'Deletes the settings row so the surface falls back to its BRAND_* env / theme.css / hardcoded default. Restricted to the resettable keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft). Idempotent: resetting a key that was never set succeeds.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.parameters['key'] = { in: 'path', required: true, description: 'Settings key to reset', schema: { type: 'string' } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Setting reset', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Setting is not resettable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
ctrl.deleteSetting,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = settingsRouter
|
module.exports = settingsRouter
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const express = require('express')
|
|||||||
const { body, param } = require('express-validator')
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
const shardOps = require('./shardOps.controller')
|
const shardOps = require('./shardOps.controller')
|
||||||
|
const shardVisibility = require('./shardVisibility.controller')
|
||||||
|
const shardAtlas = require('./shardAtlas.controller')
|
||||||
|
const shardClilocs = require('./shardClilocs.controller')
|
||||||
const selfShard = require('../player/shard.controller')
|
const selfShard = require('../player/shard.controller')
|
||||||
const { requireRole } = require('../../../utils/auth')
|
const { requireRole } = require('../../../utils/auth')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
@@ -31,6 +34,8 @@ const shardRouter = express.Router()
|
|||||||
|
|
||||||
// Moderator gate. Admins can do everything a moderator can.
|
// Moderator gate. Admins can do everything a moderator can.
|
||||||
const modAccess = requireRole('admin', 'moderator')
|
const modAccess = requireRole('admin', 'moderator')
|
||||||
|
// Admin-only gate, for settings that decide what the PUBLIC sees.
|
||||||
|
const adminOnly = requireRole('admin')
|
||||||
|
|
||||||
// ── Game account linking (self-service, any staff role) ───────────────
|
// ── Game account linking (self-service, any staff role) ───────────────
|
||||||
// Staff link their OWN in-game account here, exactly like players do under
|
// Staff link their OWN in-game account here, exactly like players do under
|
||||||
@@ -232,4 +237,150 @@ shardRouter.get(
|
|||||||
shardOps.listHouses,
|
shardOps.listHouses,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Spawn atlas (admin only) ──────────────────────────────────────────
|
||||||
|
// Operating the atlas import. Admin-only rather than moderator: it reads a path
|
||||||
|
// on the server's filesystem and replaces every atlas table, which is closer to
|
||||||
|
// a deploy action than to moderation.
|
||||||
|
//
|
||||||
|
// These routes sit under /admin/shard even though the public ones deliberately
|
||||||
|
// do NOT sit under /public/shard. That is not an inconsistency: the public split
|
||||||
|
// says "this data does not come from the sidecar", while the admin panel is
|
||||||
|
// simply part of shard administration and belongs beside the rest of it.
|
||||||
|
shardRouter.get(
|
||||||
|
'/atlas',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)'
|
||||||
|
// #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
shardAtlas.getStatus,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/atlas/import',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Re-import the spawn atlas from the ServUO tree (admin only)'
|
||||||
|
// #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('force').optional().isBoolean(),
|
||||||
|
validate,
|
||||||
|
shardAtlas.importAtlas,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/atlas/approve',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)'
|
||||||
|
// #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
shardAtlas.approve,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/atlas/reject',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Reject a staged atlas refresh (admin only)'
|
||||||
|
// #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
shardAtlas.reject,
|
||||||
|
)
|
||||||
|
shardRouter.put(
|
||||||
|
'/atlas/path',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Set the ServUO tree the atlas reads from (admin only)'
|
||||||
|
// #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('path').isString().isLength({ max: 512 }),
|
||||||
|
validate,
|
||||||
|
shardAtlas.setPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Cliloc table (admin only) ─────────────────────────────────────────────
|
||||||
|
// UO's id → display-string map, converted once by the operator from their own
|
||||||
|
// client (docs/website/CLILOCS.md). Sits beside the atlas for the same reason:
|
||||||
|
// it is static content derived from operator-supplied files rather than anything
|
||||||
|
// the sidecar sends, and operating it is shard administration.
|
||||||
|
//
|
||||||
|
// There is deliberately NO public counterpart. The table is never served as a
|
||||||
|
// table — 123k rows would dwarf any page that used it, and the Android client
|
||||||
|
// consumes the same already-resolved JSON. Names are applied server-side to the
|
||||||
|
// responses that need them.
|
||||||
|
shardRouter.get(
|
||||||
|
'/clilocs',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)'
|
||||||
|
// #swagger.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
shardClilocs.getStatus,
|
||||||
|
)
|
||||||
|
shardRouter.post(
|
||||||
|
'/clilocs/import',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Re-import the cliloc table from its source files (admin only)'
|
||||||
|
// #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('force').optional().isBoolean(),
|
||||||
|
body('approve').optional().isBoolean(),
|
||||||
|
validate,
|
||||||
|
shardClilocs.importClilocs,
|
||||||
|
)
|
||||||
|
shardRouter.put(
|
||||||
|
'/clilocs/path',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Set the cliloc source the site reads from (admin only)'
|
||||||
|
// #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('path').isString().isLength({ max: 512 }),
|
||||||
|
validate,
|
||||||
|
shardClilocs.setPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Feature visibility (admin only) ───────────────────────────────────
|
||||||
|
// Who can see which shard surface, and which sensitive fields within it. This
|
||||||
|
// decides what ANONYMOUS visitors get, so it sits above the moderator tier.
|
||||||
|
shardRouter.get(
|
||||||
|
'/visibility',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Get per-feature shard visibility config (admin only)'
|
||||||
|
// #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
shardVisibility.getVisibility,
|
||||||
|
)
|
||||||
|
shardRouter.put(
|
||||||
|
'/visibility',
|
||||||
|
// #swagger.tags = ['Admin · Shard']
|
||||||
|
// #swagger.summary = 'Update per-feature shard visibility config (admin only)'
|
||||||
|
// #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('features').isObject(),
|
||||||
|
validate,
|
||||||
|
shardVisibility.putVisibility,
|
||||||
|
)
|
||||||
|
|
||||||
module.exports = shardRouter
|
module.exports = shardRouter
|
||||||
|
|||||||
117
server/src/router/v1/admin/shardAtlas.controller.js
Normal file
117
server/src/router/v1/admin/shardAtlas.controller.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// ── Admin · Spawn atlas ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Operating the atlas import: where the ServUO tree is, whether it has drifted
|
||||||
|
// from what is loaded, and the approve/reject decision for a refresh that would
|
||||||
|
// remove a facet (docs/website/SPAWN_ATLAS.md).
|
||||||
|
//
|
||||||
|
// The policy lives in the model. This controller does three things and no more:
|
||||||
|
// it validates input, it maps a refresh RESULT onto an HTTP status, and it
|
||||||
|
// records the action in the admin activity log.
|
||||||
|
//
|
||||||
|
// **A refresh result is not an exception.** `shardAtlas.refresh()` reports
|
||||||
|
// `unavailable` / `failed` / `needsReview` rather than throwing, because the boot
|
||||||
|
// path must never be stopped by a bad tree. That contract is preserved here: an
|
||||||
|
// unreadable mount is a 200 carrying `status: 'unavailable'`, not a 500. The
|
||||||
|
// admin needs to be told what is wrong with their path, and a 500 says only
|
||||||
|
// "something broke".
|
||||||
|
|
||||||
|
const atlas = require('../../../model/shardAtlas/shardAtlas.model')
|
||||||
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
|
const log = require('../../../utils/logger')('admin-shard-atlas')
|
||||||
|
|
||||||
|
// GET /admin/shard/atlas — what is loaded, what the tree looks like, what is
|
||||||
|
// staged. Unlike the public /atlas/meta route this DOES carry the filesystem
|
||||||
|
// path and the drift flag: that is the whole point of the panel.
|
||||||
|
async function getStatus(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await atlas.status())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getStatus', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/atlas/import — apply a map change without a restart.
|
||||||
|
//
|
||||||
|
// `force` reimports even when the source hashes match what is loaded (the escape
|
||||||
|
// hatch for "the database is wrong but the tree is not"). Facet loss is still
|
||||||
|
// staged rather than applied — approving is a separate, explicit act.
|
||||||
|
async function importAtlas(req, res) {
|
||||||
|
try {
|
||||||
|
const force = !!req.body?.force
|
||||||
|
const result = await atlas.refresh({ force })
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'shard.atlas.import',
|
||||||
|
detail: { force, status: result.status, counts: result.counts ?? null },
|
||||||
|
})
|
||||||
|
return res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('importAtlas', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/atlas/approve — apply a staged refresh, facet loss and all.
|
||||||
|
//
|
||||||
|
// Re-parses the tree rather than applying something captured at boot: only the
|
||||||
|
// DECISION was stored, so what lands matches the tree as it is now. If the
|
||||||
|
// operator has since fixed a half-copied mount, the approved import is simply
|
||||||
|
// the corrected one — which is the desired outcome, not a surprise.
|
||||||
|
async function approve(req, res) {
|
||||||
|
try {
|
||||||
|
const result = await atlas.approvePending()
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'shard.atlas.approve',
|
||||||
|
detail: { status: result.status, removed: result.removedFacets ?? null },
|
||||||
|
})
|
||||||
|
return res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('approveAtlas', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/atlas/reject — keep the current atlas and remember the
|
||||||
|
// decision against those exact source hashes, so a declined refresh does not
|
||||||
|
// re-prompt on every restart. Changing the tree asks again.
|
||||||
|
async function reject(req, res) {
|
||||||
|
try {
|
||||||
|
const result = await atlas.rejectPending()
|
||||||
|
if (result.status === 'none') {
|
||||||
|
return res.status(404).json({ message: 'No refresh is awaiting review.' })
|
||||||
|
}
|
||||||
|
await activity.log({ req, action: 'shard.atlas.reject', detail: {} })
|
||||||
|
return res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('rejectAtlas', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /admin/shard/atlas/path — point the atlas at a different ServUO tree.
|
||||||
|
//
|
||||||
|
// Persisted as a setting, which wins over the SERVUO_PATH env default so an
|
||||||
|
// operator can move the mount without a redeploy. Blank clears it, which turns
|
||||||
|
// the atlas off (boot skips, the loaded atlas keeps serving) — that is a
|
||||||
|
// legitimate thing to want, so it is allowed rather than validated away.
|
||||||
|
//
|
||||||
|
// Deliberately does NOT import as a side effect: changing where the atlas reads
|
||||||
|
// from and reloading it are separate decisions, and an operator fixing a typo
|
||||||
|
// should not have a multi-thousand-row replace happen under them. The response
|
||||||
|
// carries the refreshed status so the panel can offer the import immediately.
|
||||||
|
async function setPath(req, res) {
|
||||||
|
try {
|
||||||
|
const value = String(req.body?.path ?? '').trim()
|
||||||
|
await atlas.setServuoPath(value, req.user?.id ?? null)
|
||||||
|
await activity.log({ req, action: 'shard.atlas.path', detail: { path: value } })
|
||||||
|
return res.json(await atlas.status())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('setAtlasPath', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStatus, importAtlas, approve, reject, setPath }
|
||||||
106
server/src/router/v1/admin/shardClilocs.controller.js
Normal file
106
server/src/router/v1/admin/shardClilocs.controller.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// ── Admin · Cliloc table ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Operating the cliloc import: where the converted cliloc file is, whether it
|
||||||
|
// has drifted from what is loaded, and a forced reimport after a client patch
|
||||||
|
// (docs/website/CLILOCS.md).
|
||||||
|
//
|
||||||
|
// The policy lives in the model. This controller does three things and no more:
|
||||||
|
// it validates input, it maps a refresh RESULT onto an HTTP status, and it
|
||||||
|
// records the action in the admin activity log.
|
||||||
|
//
|
||||||
|
// **A refresh result is not an exception.** `shardClilocs.refresh()` reports
|
||||||
|
// `unavailable` / `failed` rather than throwing, because the boot path must never
|
||||||
|
// be stopped by a bad file. That contract is preserved here: a missing file, or
|
||||||
|
// the single most likely operator mistake — pointing at the client's own
|
||||||
|
// COMPRESSED `Cliloc.enu` — is a 200 carrying `status: 'unavailable'` and the
|
||||||
|
// reason, not a 500. A 500 would say only "something broke"; the operator needs
|
||||||
|
// to be told which file to convert.
|
||||||
|
|
||||||
|
const clilocs = require('../../../model/shardClilocs/shardClilocs.model')
|
||||||
|
const market = require('../../../model/shardMarket/shardMarket.model')
|
||||||
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
|
const log = require('../../../utils/logger')('admin-shard-clilocs')
|
||||||
|
|
||||||
|
// GET /admin/shard/clilocs — what is loaded, what the file looks like, whether
|
||||||
|
// they disagree. There is no public counterpart: the cliloc table is never
|
||||||
|
// served as a table, only applied to names the site already returns.
|
||||||
|
async function getStatus(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await clilocs.status())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getStatus', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/shard/clilocs/import — reload after a client patch or a change to
|
||||||
|
// the shard's own overlay files, without a restart.
|
||||||
|
//
|
||||||
|
// `force` reimports even when the source hashes match what is loaded (the escape
|
||||||
|
// hatch for "the database is wrong but the files are not").
|
||||||
|
//
|
||||||
|
// `approve` accepts a refresh in which a previously-loaded source has VANISHED.
|
||||||
|
// That is refused by default because an unmounted volume and a deliberate
|
||||||
|
// deletion look identical from the server — the lighter cousin of the atlas's
|
||||||
|
// approve/reject flow, and the reason it can be a flag here rather than a
|
||||||
|
// pending table is that nothing is stored to approve: the import re-reads the
|
||||||
|
// files at approval time by construction.
|
||||||
|
async function importClilocs(req, res) {
|
||||||
|
try {
|
||||||
|
const force = !!req.body?.force
|
||||||
|
const approve = !!req.body?.approve
|
||||||
|
const result = await clilocs.refresh({ force, approve })
|
||||||
|
|
||||||
|
// The marketplace denormalizes resolved item names into
|
||||||
|
// shard_vendor_items.display_name, and the shard's market sweep will NOT
|
||||||
|
// re-send an unchanged shop just because the site learned what its items are
|
||||||
|
// called — so without this pass, an operator who imports clilocs after the
|
||||||
|
// first sweep keeps seeing item ids until every shop happens to change.
|
||||||
|
// Awaited (rather than fired and forgotten) so the panel's "imported" is
|
||||||
|
// honest about the names being live; the pass is a bounded walk of one table
|
||||||
|
// and never throws.
|
||||||
|
if (result.status === 'imported') await market.refreshDisplayNames()
|
||||||
|
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'shard.clilocs.import',
|
||||||
|
detail: {
|
||||||
|
force,
|
||||||
|
approve,
|
||||||
|
status: result.status,
|
||||||
|
count: result.count ?? null,
|
||||||
|
missingSources: result.missingSources ?? result.acceptedMissing ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.json(result)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('importClilocs', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /admin/shard/clilocs/path — point the site at a different cliloc file.
|
||||||
|
//
|
||||||
|
// Persisted as a setting, which wins over the UO_CLIENT_PATH env default so an
|
||||||
|
// operator can move the mount without a redeploy. Blank clears it, which turns
|
||||||
|
// resolution off (boot skips, the loaded table keeps serving) — a legitimate
|
||||||
|
// thing to want, so it is allowed rather than validated away.
|
||||||
|
//
|
||||||
|
// Deliberately does NOT import as a side effect, for the same reason the atlas
|
||||||
|
// path does not: changing where the table reads from and reloading it are
|
||||||
|
// separate decisions. The response carries the refreshed status so the panel can
|
||||||
|
// offer the import immediately.
|
||||||
|
async function setPath(req, res) {
|
||||||
|
try {
|
||||||
|
const value = String(req.body?.path ?? '').trim()
|
||||||
|
await clilocs.setClientPath(value, req.user?.id ?? null)
|
||||||
|
await activity.log({ req, action: 'shard.clilocs.path', detail: { path: value } })
|
||||||
|
return res.json(await clilocs.status())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('setClilocPath', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStatus, importClilocs, setPath }
|
||||||
98
server/src/router/v1/admin/shardVisibility.controller.js
Normal file
98
server/src/router/v1/admin/shardVisibility.controller.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// ── Admin · Shard visibility ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Read/write the per-feature audience config that gates every shard-derived
|
||||||
|
// surface. Admin-only: this decides what anonymous visitors can see, so it is
|
||||||
|
// not part of the moderator tier.
|
||||||
|
//
|
||||||
|
// The policy itself (the ladder, the feature catalog, which fields are locked)
|
||||||
|
// lives in utils/shardVisibility.js. This controller only validates input
|
||||||
|
// against that policy and persists it.
|
||||||
|
|
||||||
|
const model = require('../../../model/shardVisibility/shardVisibility.model')
|
||||||
|
const visibility = require('../../../utils/shardVisibility')
|
||||||
|
const log = require('../../../utils/logger')('admin-shard-visibility')
|
||||||
|
|
||||||
|
// GET /admin/shard/visibility — the effective config (defaults merged with any
|
||||||
|
// stored overrides), plus the vocabulary the admin UI needs to render itself:
|
||||||
|
// the ladder, and which fields each feature exposes as configurable.
|
||||||
|
async function getVisibility(req, res) {
|
||||||
|
try {
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
return res.json({
|
||||||
|
ladder: visibility.LADDER,
|
||||||
|
lockedFields: Object.keys(visibility.LOCKED_FIELDS),
|
||||||
|
defaults: visibility.compileDefaults(),
|
||||||
|
features: config,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getVisibility', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /admin/shard/visibility — replace the settings for one or more features.
|
||||||
|
// Body: { features: { <name>: { enabled, audience, stream, fieldRules } } }
|
||||||
|
//
|
||||||
|
// Rejects unknown feature names, unknown rungs, and any attempt to configure a
|
||||||
|
// locked field — a 400 rather than a silent drop, so an admin who tries to make
|
||||||
|
// `acct` public learns that it is not negotiable.
|
||||||
|
async function putVisibility(req, res) {
|
||||||
|
try {
|
||||||
|
const incoming = req.body?.features
|
||||||
|
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
|
||||||
|
return res.status(400).json({ message: 'features object required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
for (const [name, patch] of Object.entries(incoming)) {
|
||||||
|
if (!visibility.isFeature(name)) {
|
||||||
|
return res.status(400).json({ message: `Unknown feature: ${name}` })
|
||||||
|
}
|
||||||
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||||
|
return res.status(400).json({ message: `Invalid settings for ${name}` })
|
||||||
|
}
|
||||||
|
if (patch.audience != null && !visibility.isLevel(patch.audience)) {
|
||||||
|
return res.status(400).json({ message: `Unknown audience for ${name}: ${patch.audience}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldRules = {}
|
||||||
|
for (const [field, level] of Object.entries(patch.fieldRules || {})) {
|
||||||
|
// Matches flattened spellings too (`ownerAcct`, `leaderWebId`), so the
|
||||||
|
// rejection covers every way the field can be named rather than the two
|
||||||
|
// canonical keys.
|
||||||
|
if (visibility.isLockedField(field)) {
|
||||||
|
return res.status(400).json({ message: `Field '${field}' is admin-only and cannot be configured` })
|
||||||
|
}
|
||||||
|
if (!visibility.isLevel(level)) {
|
||||||
|
return res.status(400).json({ message: `Unknown rung for ${name}.${field}: ${level}` })
|
||||||
|
}
|
||||||
|
fieldRules[field] = level
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = (await visibility.getConfig())[name]
|
||||||
|
entries.push({
|
||||||
|
feature: name,
|
||||||
|
enabled: patch.enabled == null ? current.enabled : !!patch.enabled,
|
||||||
|
audience: patch.audience ?? current.audience,
|
||||||
|
stream: patch.stream == null ? current.stream : !!patch.stream,
|
||||||
|
fieldRules,
|
||||||
|
updatedBy: req.user?.id ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) await model.upsert(entry)
|
||||||
|
visibility.invalidate()
|
||||||
|
|
||||||
|
log.info('shard visibility updated', {
|
||||||
|
by: req.user?.id,
|
||||||
|
features: entries.map((e) => e.feature),
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({ features: await visibility.getConfig() })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('putVisibility', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getVisibility, putVisibility }
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||||
const shardState = require('../../../model/shardState/shardState.model')
|
const shardState = require('../../../model/shardState/shardState.model')
|
||||||
|
const shardClilocs = require('../../../model/shardClilocs/shardClilocs.model')
|
||||||
const settings = require('../../../model/settings/settings.model')
|
const settings = require('../../../model/settings/settings.model')
|
||||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
const activity = require('../../../model/activity/activity.model')
|
||||||
@@ -18,9 +19,62 @@ const log = require('../../../utils/logger')('player-shard')
|
|||||||
|
|
||||||
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the cliloc ids on a profile into display names.
|
||||||
|
*
|
||||||
|
* Items on the wire carry a `LabelNumber`, not a name — `BridgeProfile.WriteItem`
|
||||||
|
* sends `cliloc` on every equipment entry and `name` only for the minority of
|
||||||
|
* items a player has renamed. Reward titles are the same shape: the shard sends
|
||||||
|
* a cliloc number as a string, which the sheet previously had to SKIP because it
|
||||||
|
* had no way to turn it into words.
|
||||||
|
*
|
||||||
|
* Resolution happens here rather than in the browser because the table is ~123k
|
||||||
|
* rows: shipping it to render a dozen names would dwarf the page, and the
|
||||||
|
* Android client consumes this same JSON and would otherwise need its own copy.
|
||||||
|
*
|
||||||
|
* A shard with no cliloc table configured resolves nothing and the sheet renders
|
||||||
|
* ids exactly as it did before — this is decoration, and it is applied in the
|
||||||
|
* same best-effort block as the guild/governor cross-links.
|
||||||
|
*/
|
||||||
|
async function resolveProfileClilocs(profile) {
|
||||||
|
const wanted = []
|
||||||
|
|
||||||
|
const equipment = Array.isArray(profile.equipment) ? profile.equipment : []
|
||||||
|
for (const item of equipment) {
|
||||||
|
if (Number.isInteger(item?.cliloc)) wanted.push(item.cliloc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reward titles arrive as strings that may be either a literal ("Knight of
|
||||||
|
// Trinsic") or a cliloc number in string form. Only the numeric ones need us.
|
||||||
|
const reward = Array.isArray(profile.titles?.reward) ? profile.titles.reward : []
|
||||||
|
const rewardNumbers = reward.map((r) => (/^\d+$/.test(String(r)) ? Number(r) : null))
|
||||||
|
for (const n of rewardNumbers) if (n !== null) wanted.push(n)
|
||||||
|
|
||||||
|
if (wanted.length === 0) return
|
||||||
|
|
||||||
|
const names = await shardClilocs.resolveMany(wanted)
|
||||||
|
if (names.size === 0) return
|
||||||
|
|
||||||
|
for (const item of equipment) {
|
||||||
|
// A player-given name always wins over the type name: an item called "Bob's
|
||||||
|
// lucky axe" should not be relabelled "hatchet".
|
||||||
|
if (item?.name) continue
|
||||||
|
const resolved = names.get(item?.cliloc)
|
||||||
|
if (resolved) item.clilocName = resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rewardNumbers.some((n) => n !== null)) {
|
||||||
|
profile.titles.rewardResolved = reward.map((raw, i) => {
|
||||||
|
const n = rewardNumbers[i]
|
||||||
|
return n === null ? String(raw) : names.get(n) ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Decorate a char.profile with cross-links from our own board data: the guild the
|
// Decorate a char.profile with cross-links from our own board data: the guild the
|
||||||
// character leads and any city governorship on its account. Best-effort — a
|
// character leads and any city governorship on its account, plus resolved cliloc
|
||||||
// failure here never fails the profile (it's a nicety, not the sheet).
|
// names. Best-effort — a failure here never fails the profile (it's a nicety,
|
||||||
|
// not the sheet).
|
||||||
async function enrichCharProfile(profile) {
|
async function enrichCharProfile(profile) {
|
||||||
if (!profile) return profile
|
if (!profile) return profile
|
||||||
try {
|
try {
|
||||||
@@ -30,6 +84,7 @@ async function enrichCharProfile(profile) {
|
|||||||
const govs = await shardState.listGovernorshipsForAccounts([profile.acct])
|
const govs = await shardState.listGovernorshipsForAccounts([profile.acct])
|
||||||
if (govs.length) profile.governorOf = govs.map((g) => g.city)
|
if (govs.length) profile.governorOf = govs.map((g) => g.city)
|
||||||
}
|
}
|
||||||
|
await resolveProfileClilocs(profile)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message })
|
log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message })
|
||||||
}
|
}
|
||||||
|
|||||||
134
server/src/router/v1/public/atlas.controller.js
Normal file
134
server/src/router/v1/public/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/shardVisibility')
|
||||||
|
|
||||||
|
const log = require('../../../utils/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,
|
||||||
|
}
|
||||||
128
server/src/router/v1/public/atlas.router.js
Normal file
128
server/src/router/v1/public/atlas.router.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { param, query } = require('express-validator')
|
||||||
|
|
||||||
|
const atlas = require('./atlas.controller')
|
||||||
|
const siteMode = require('../../../middleware/siteMode')
|
||||||
|
const validate = require('../../../middleware/validate')
|
||||||
|
const { requireFeature } = require('../../../utils/shardVisibility')
|
||||||
|
|
||||||
|
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
|
||||||
@@ -21,6 +21,7 @@ const postsRouter = require('./posts.router')
|
|||||||
const wikiRouter = require('./wiki.router')
|
const wikiRouter = require('./wiki.router')
|
||||||
const pagesRouter = require('./pages.router')
|
const pagesRouter = require('./pages.router')
|
||||||
const shardRouter = require('./shard.router')
|
const shardRouter = require('./shard.router')
|
||||||
|
const atlasRouter = require('./atlas.router')
|
||||||
const siteRouter = require('./site.router')
|
const siteRouter = require('./site.router')
|
||||||
|
|
||||||
const publicRouter = express.Router()
|
const publicRouter = express.Router()
|
||||||
@@ -32,6 +33,11 @@ publicRouter.use('/wiki', wikiRouter)
|
|||||||
publicRouter.use('/pages', pagesRouter)
|
publicRouter.use('/pages', pagesRouter)
|
||||||
// Live shard data, never site-mode gated.
|
// Live shard data, never site-mode gated.
|
||||||
publicRouter.use('/shard', shardRouter)
|
publicRouter.use('/shard', shardRouter)
|
||||||
|
// The spawn atlas: static shard CONTENT, parsed from the shard's ServUO tree
|
||||||
|
// rather than fetched from the sidecar. Deliberately not under /shard — nothing
|
||||||
|
// here depends on the bridge — and site-mode gated per route like the content
|
||||||
|
// routers above, which is the other half of that distinction.
|
||||||
|
publicRouter.use('/atlas', atlasRouter)
|
||||||
|
|
||||||
// The four singletons that own no path segment of their own: /settings, /status,
|
// The four singletons that own no path segment of their own: /settings, /status,
|
||||||
// /version and /contact. Mounted at the group root, last — safe only because
|
// /version and /contact. Mounted at the group root, last — safe only because
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
|
|
||||||
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
const shardEvents = require('../../../model/shardEvents/shardEvents.model')
|
||||||
const shardState = require('../../../model/shardState/shardState.model')
|
const shardState = require('../../../model/shardState/shardState.model')
|
||||||
|
const shardMarket = require('../../../model/shardMarket/shardMarket.model')
|
||||||
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfig = require('../../../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
const broadcast = require('../../../utils/shardBroadcast')
|
const broadcast = require('../../../utils/shardBroadcast')
|
||||||
const auth = require('../../../utils/auth')
|
const visibility = require('../../../utils/shardVisibility')
|
||||||
|
|
||||||
const log = require('../../../utils/logger')('public-shard')
|
const log = require('../../../utils/logger')('public-shard')
|
||||||
|
|
||||||
@@ -39,21 +40,49 @@ async function getStatus(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /public/shard/feed?kind=&limit= — recent notable events from the log,
|
// GET /public/shard/feed?kind=&limit= — recent notable events from the log.
|
||||||
// restricted to the public-safe allowlist so staff audit / cheat / link events
|
//
|
||||||
// (which are stored for the admin channel) can never leak to the public.
|
// This is the stored-history twin of the SSE stream, and it must reach the same
|
||||||
|
// verdict the stream does about the same event. Two things are therefore resolved
|
||||||
|
// against the LIVE config rather than the compiled defaults:
|
||||||
|
//
|
||||||
|
// • which kinds this viewer may read at all — `visibleKinds`, not the static
|
||||||
|
// PUBLIC_KINDS set (which is fixed at module load, so an admin moving
|
||||||
|
// `guilds` to `staff` would gate /guilds while /feed kept serving
|
||||||
|
// guild.join to anonymous callers), and
|
||||||
|
// • the payload itself, projected per event against ITS OWN kind's feature —
|
||||||
|
// the rows are a mix of features, and without this the stored frames were
|
||||||
|
// returned verbatim, `acct`/`webId` and all, on an anonymous endpoint.
|
||||||
async function getFeed(req, res) {
|
async function getFeed(req, res) {
|
||||||
try {
|
try {
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
const level = req.viewerLevel || (await visibility.viewerLevel(req))
|
||||||
|
const allowed = new Set(visibility.visibleKinds(level, config))
|
||||||
|
|
||||||
const { kind, limit } = req.query
|
const { kind, limit } = req.query
|
||||||
|
// No readable kinds ⇒ nothing to serve. Returning early also keeps us clear
|
||||||
|
// of `list({ kinds: [] })`, which means "no filter", not "match nothing".
|
||||||
|
if (allowed.size === 0) return res.json([])
|
||||||
|
|
||||||
let events
|
let events
|
||||||
if (kind) {
|
if (kind) {
|
||||||
// A specific kind is only served if it is itself public-safe.
|
if (!allowed.has(kind)) return res.json([])
|
||||||
if (!broadcast.PUBLIC_KINDS.has(kind)) return res.json([])
|
|
||||||
events = await shardEvents.list({ kind, limit })
|
events = await shardEvents.list({ kind, limit })
|
||||||
} else {
|
} else {
|
||||||
events = await shardEvents.list({ kinds: [...broadcast.PUBLIC_KINDS], limit })
|
events = await shardEvents.list({ kinds: [...allowed], limit })
|
||||||
}
|
}
|
||||||
return res.json(events)
|
|
||||||
|
return res.json(
|
||||||
|
events.map((ev) => ({
|
||||||
|
...ev,
|
||||||
|
payload: visibility.projectFeature(
|
||||||
|
visibility.KIND_FEATURE.get(ev.kind),
|
||||||
|
ev.payload,
|
||||||
|
level,
|
||||||
|
config,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getFeed', err)
|
log.error('shard.getFeed', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
@@ -72,18 +101,21 @@ async function getEconomy(req, res) {
|
|||||||
|
|
||||||
// GET /public/shard/online — players online now whose account is linked to a
|
// GET /public/shard/online — players online now whose account is linked to a
|
||||||
// STAFF website user (admin/editor/moderator). Everyone sees that a staff member
|
// STAFF website user (admin/editor/moderator). Everyone sees that a staff member
|
||||||
// is online (name + serial); their in-game location (map + coordinates) is only
|
// is online (name + serial); their in-game location (map + coordinates) is gated
|
||||||
// included for privileged viewers (admin/moderator) so it is never exposed to
|
// on the `presence` feature's `location` field rule, which defaults to `staff`
|
||||||
// players or the public via the network tab. Non-staff players are never listed.
|
// — the same admin/moderator set this used to hardcode. Non-staff players are
|
||||||
function canSeeStaffLocation(req) {
|
// never listed.
|
||||||
const viewer = auth.getUserFromRequest(req)
|
async function canSeeStaffLocation(req) {
|
||||||
return !!viewer && (viewer.role === 'admin' || viewer.role === 'moderator')
|
const config = await visibility.getConfig()
|
||||||
|
const required = config.presence?.fields?.location || 'staff'
|
||||||
|
const level = req.viewerLevel || (await visibility.viewerLevel(req))
|
||||||
|
return visibility.meets(level, required)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOnline(req, res) {
|
async function getOnline(req, res) {
|
||||||
try {
|
try {
|
||||||
const rows = await shardState.listOnlineLinked()
|
const rows = await shardState.listOnlineLinked()
|
||||||
const showLocation = canSeeStaffLocation(req)
|
const showLocation = await canSeeStaffLocation(req)
|
||||||
return res.json(
|
return res.json(
|
||||||
rows.map((r) => {
|
rows.map((r) => {
|
||||||
const entry = { serial: r.serial, name: r.name }
|
const entry = { serial: r.serial, name: r.name }
|
||||||
@@ -103,9 +135,14 @@ async function getOnline(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /public/shard/idoc — houses currently in danger (stage IDOC).
|
// GET /public/shard/idoc — houses currently in danger (stage IDOC).
|
||||||
|
//
|
||||||
|
// Projected: shapeHouse flattens the owner actor into `ownerSerial`/`ownerAcct`/
|
||||||
|
// `ownerName`, so this endpoint used to hand an anonymous caller the house
|
||||||
|
// owner's GAME ACCOUNT NAME. The public IDOC board only ever needed name, region
|
||||||
|
// and location — which is all that survives projection below `staff`.
|
||||||
async function getIdoc(req, res) {
|
async function getIdoc(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.listIdoc())
|
return res.json(await visibility.project('houses', await shardState.listIdoc(), req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getIdoc', err)
|
log.error('shard.getIdoc', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
@@ -117,7 +154,7 @@ async function getIdoc(req, res) {
|
|||||||
// the public SSE stream so the page can update in place.
|
// the public SSE stream so the page can update in place.
|
||||||
async function getChamps(req, res) {
|
async function getChamps(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.listChamps())
|
return res.json(await visibility.project('champs', await shardState.listChamps(), req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getChamps', err)
|
log.error('shard.getChamps', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
@@ -126,9 +163,13 @@ async function getChamps(req, res) {
|
|||||||
|
|
||||||
// GET /public/shard/guilds — the current guild board. Served from our store;
|
// GET /public/shard/guilds — the current guild board. Served from our store;
|
||||||
// live via guild.update / guild.remove / guild.join on the public SSE stream.
|
// live via guild.update / guild.remove / guild.join on the public SSE stream.
|
||||||
|
//
|
||||||
|
// Projected: the stored payload is the raw guild.update frame, whose `leader`
|
||||||
|
// actor carries `acct` and `webId`. Those are admin-only and were previously
|
||||||
|
// returned verbatim to anonymous callers.
|
||||||
async function getGuilds(req, res) {
|
async function getGuilds(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.listGuilds())
|
return res.json(await visibility.project('guilds', await shardState.listGuilds(), req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getGuilds', err)
|
log.error('shard.getGuilds', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
@@ -136,10 +177,11 @@ async function getGuilds(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /public/shard/governors — the current town-governor board (empty on shards
|
// GET /public/shard/governors — the current town-governor board (empty on shards
|
||||||
// without City Loyalty). Live via city.update on the public SSE stream.
|
// without City Loyalty). Live via city.update on the public SSE stream. Projected
|
||||||
|
// for the same reason as getGuilds: `governor` / `governorElect` are actors.
|
||||||
async function getGovernors(req, res) {
|
async function getGovernors(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.listGovernors())
|
return res.json(await visibility.project('governors', await shardState.listGovernors(), req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getGovernors', err)
|
log.error('shard.getGovernors', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
@@ -150,7 +192,8 @@ async function getGovernors(req, res) {
|
|||||||
// (look-back: "who were all the governors of Britain?"), newest first.
|
// (look-back: "who were all the governors of Britain?"), newest first.
|
||||||
async function getGovernorHistory(req, res) {
|
async function getGovernorHistory(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.listGovernorHistory(req.params.city, req.query.limit))
|
const terms = await shardState.listGovernorHistory(req.params.city, req.query.limit)
|
||||||
|
return res.json(await visibility.project('governors', terms, req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getGovernorHistory', err)
|
log.error('shard.getGovernorHistory', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
@@ -161,7 +204,7 @@ async function getGovernorHistory(req, res) {
|
|||||||
// + per-region). Live via presence.online on the public SSE stream.
|
// + per-region). Live via presence.online on the public SSE stream.
|
||||||
async function getPresence(req, res) {
|
async function getPresence(req, res) {
|
||||||
try {
|
try {
|
||||||
return res.json(await shardState.latestPresence())
|
return res.json(await visibility.project('presence', await shardState.latestPresence(), req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getPresence', err)
|
log.error('shard.getPresence', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
@@ -185,16 +228,175 @@ async function getHouses(req, res) {
|
|||||||
z: h.z,
|
z: h.z,
|
||||||
isIdoc: true,
|
isIdoc: true,
|
||||||
}))
|
}))
|
||||||
return res.json(publicHouses)
|
// Already a hand-picked safe subset; projected anyway so an admin who
|
||||||
|
// tightens a `houses` field rule sees it honoured on every houses surface
|
||||||
|
// rather than on some of them.
|
||||||
|
return res.json(await visibility.project('houses', publicHouses, req))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('shard.getHouses', err)
|
log.error('shard.getHouses', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /public/shard/stream — public live-event SSE channel (safe kinds only).
|
// GET /public/shard/ruleset — the shard's published ruleset (Protocol 3.0):
|
||||||
|
// expansion, which optional systems are on, skill/stat caps, account and house
|
||||||
|
// limits, champion scroll rules, the save/restart schedule. Served from our own
|
||||||
|
// store, so it renders while the shard is down; live via world.ruleset on the
|
||||||
|
// public SSE stream.
|
||||||
|
//
|
||||||
|
// `null` means the shard has never published one (an old plugin, or
|
||||||
|
// Bridge.RulesetEnabled=false) — a real answer, distinct from a published
|
||||||
|
// ruleset, and the page says so rather than rendering an empty one.
|
||||||
|
//
|
||||||
|
// Projected like every other shard read (§3.6.1's rule: a read path that returns
|
||||||
|
// shard data and does not call projectFeature is a bug). The `connect` string is
|
||||||
|
// the one configurable field — an operator who published a connect address may
|
||||||
|
// still want it behind a login.
|
||||||
|
async function getRuleset(req, res) {
|
||||||
|
try {
|
||||||
|
const ruleset = await shardState.getRuleset()
|
||||||
|
if (!ruleset) return res.json(null)
|
||||||
|
return res.json(await visibility.project('ruleset', ruleset, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getRuleset', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shard keys boards by its own PointsType enum name (QueensLoyalty,
|
||||||
|
// CleanUpBritannia, …). Constrain the path param to that shape before it reaches
|
||||||
|
// the model: the column is VARCHAR(48), and an unbounded string here is a needless
|
||||||
|
// query on a value that can only ever be an identifier.
|
||||||
|
const SYSTEM_RE = /^[A-Za-z][A-Za-z0-9_]{0,47}$/
|
||||||
|
|
||||||
|
// GET /public/shard/points — every points/loyalty leaderboard the shard publishes.
|
||||||
|
// Served from our own store, so the page renders while the shard is down — which
|
||||||
|
// matters more here than for live state: these are standings accumulated over
|
||||||
|
// months, and blanking them during a restart would look like a data loss.
|
||||||
|
async function getPointsBoards(req, res) {
|
||||||
|
try {
|
||||||
|
const boards = await shardState.listPointsBoards()
|
||||||
|
return res.json(await visibility.project('leaderboards', boards, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getPointsBoards', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/points/:system — one system's board.
|
||||||
|
//
|
||||||
|
// 404 for a system the shard has never published, matching the sidecar: "no such
|
||||||
|
// board" and "a board nobody is on yet" are different answers.
|
||||||
|
async function getPointsBoard(req, res) {
|
||||||
|
const { system } = req.params
|
||||||
|
if (!SYSTEM_RE.test(system)) return res.status(400).json({ message: 'Invalid points system.' })
|
||||||
|
try {
|
||||||
|
const board = await shardState.getPointsBoard(system)
|
||||||
|
if (!board) return res.status(404).json({ message: 'Unknown points system.' })
|
||||||
|
return res.json(await visibility.project('leaderboards', board, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getPointsBoard', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marketplace (Protocol 3.0 vendor.listing) ──────────────────────────────
|
||||||
|
//
|
||||||
|
// The shard-wide player-vendor index. Served entirely from our own tables — the
|
||||||
|
// sidecar is never touched on this path — so shops stay browsable while the shard
|
||||||
|
// is down, labelled with how stale they may be.
|
||||||
|
//
|
||||||
|
// The staleness label is not decoration. The shard sweeps vendors round-robin, so
|
||||||
|
// a shop can legitimately be a full cycle behind; a page that implied live prices
|
||||||
|
// would send people to a vendor whose item sold twenty minutes ago.
|
||||||
|
|
||||||
|
// The serial spelling the bridge uses everywhere: "0x" and hex. Constrained
|
||||||
|
// before it reaches the model, like SYSTEM_RE above.
|
||||||
|
const SERIAL_RE = /^0x[0-9A-Fa-f]{1,16}$/
|
||||||
|
|
||||||
|
const intParam = (value) => {
|
||||||
|
const n = Number.parseInt(value, 10)
|
||||||
|
return Number.isFinite(n) ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/market — search the index.
|
||||||
|
//
|
||||||
|
// Returns LISTINGS, not vendors: "who sells a vanquishing kryss and for how much"
|
||||||
|
// is the question, and a vendor-shaped result would make every caller flatten the
|
||||||
|
// shops back out.
|
||||||
|
async function getMarket(req, res) {
|
||||||
|
try {
|
||||||
|
const page = await shardMarket.search({
|
||||||
|
q: typeof req.query.q === 'string' ? req.query.q : '',
|
||||||
|
minPrice: intParam(req.query.minPrice),
|
||||||
|
maxPrice: intParam(req.query.maxPrice),
|
||||||
|
itemId: intParam(req.query.itemId),
|
||||||
|
map: typeof req.query.map === 'string' ? req.query.map : '',
|
||||||
|
region: typeof req.query.region === 'string' ? req.query.region : '',
|
||||||
|
sort: typeof req.query.sort === 'string' ? req.query.sort : 'price_asc',
|
||||||
|
limit: intParam(req.query.limit) ?? 50,
|
||||||
|
offset: intParam(req.query.offset) ?? 0,
|
||||||
|
})
|
||||||
|
return res.json(await visibility.project('market', page, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getMarket', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/market/meta — index size, staleness, and the filter options
|
||||||
|
// (which facets and regions actually hold vendors). Separate from the search so
|
||||||
|
// the page can build its filters without running a query it will throw away.
|
||||||
|
async function getMarketMeta(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await visibility.project('market', await shardMarket.meta(), req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getMarketMeta', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/market/vendors/:serial — one shop and its listings.
|
||||||
|
//
|
||||||
|
// 404 for a serial the index has never seen, which also covers a vendor that has
|
||||||
|
// since been dismissed or hidden: to an anonymous caller "no such shop" is the
|
||||||
|
// only honest answer, and distinguishing the two would leak that a vendor exists
|
||||||
|
// but was hidden.
|
||||||
|
async function getMarketVendor(req, res) {
|
||||||
|
const { serial } = req.params
|
||||||
|
if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid vendor serial.' })
|
||||||
|
try {
|
||||||
|
const vendor = await shardMarket.getVendor(serial, {
|
||||||
|
limit: intParam(req.query.limit) ?? 250,
|
||||||
|
offset: intParam(req.query.offset) ?? 0,
|
||||||
|
})
|
||||||
|
if (!vendor) return res.status(404).json({ message: 'Unknown vendor.' })
|
||||||
|
return res.json(await visibility.project('market', vendor, req))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getMarketVendor', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/features — the shard features THIS caller can actually see,
|
||||||
|
// so the SPA (and the Android client) can hide nav entries instead of rendering
|
||||||
|
// links that 403. Deliberately reports only what the viewer may reach: the list
|
||||||
|
// itself must not disclose the existence of a feature they're gated out of.
|
||||||
|
async function getFeatures(req, res) {
|
||||||
|
try {
|
||||||
|
const config = await visibility.getConfig()
|
||||||
|
const level = await visibility.viewerLevel(req)
|
||||||
|
return res.json({ level, features: visibility.visibleFeatures(level, config) })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('shard.getFeatures', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /public/shard/stream — live-event SSE channel. What arrives depends on the
|
||||||
|
// caller's audience rung, resolved once at subscribe time; see shardBroadcast.js.
|
||||||
function stream(req, res) {
|
function stream(req, res) {
|
||||||
broadcast.subscribe(req, res, 'public')
|
return broadcast.subscribe(req, res, 'public')
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -209,5 +411,12 @@ module.exports = {
|
|||||||
getGovernorHistory,
|
getGovernorHistory,
|
||||||
getPresence,
|
getPresence,
|
||||||
getHouses,
|
getHouses,
|
||||||
|
getRuleset,
|
||||||
|
getPointsBoards,
|
||||||
|
getPointsBoard,
|
||||||
|
getMarket,
|
||||||
|
getMarketMeta,
|
||||||
|
getMarketVendor,
|
||||||
|
getFeatures,
|
||||||
stream,
|
stream,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,19 +10,29 @@
|
|||||||
// visitors *and* by the Android ShardStreamClient, neither of which sends an
|
// visitors *and* by the Android ShardStreamClient, neither of which sends an
|
||||||
// Authorization header; adding requireAuth here blacks out the public live boards
|
// Authorization header; adding requireAuth here blacks out the public live boards
|
||||||
// on web and mobile. The sensitive kinds (staff audit, cheat detection, login
|
// on web and mobile. The sensitive kinds (staff audit, cheat detection, login
|
||||||
// attempts, IPs) are withheld by the allowlist in utils/shardBroadcast.js, not by
|
// attempts, IPs) are withheld by utils/shardBroadcast.js, not by a route gate —
|
||||||
// a route gate — that allowlist split is the security boundary, not this file.
|
// that per-frame filtering is the security boundary, not this file. /stream is
|
||||||
|
// deliberately NOT wrapped in requireFeature either: it spans every feature, and
|
||||||
|
// each frame is gated individually against the subscriber's rung.
|
||||||
|
//
|
||||||
|
// Every other route carries `requireFeature(<name>)` (utils/shardVisibility.js),
|
||||||
|
// which 404s when an admin has disabled the feature and 403s when the caller sits
|
||||||
|
// below its configured audience. Defaults reproduce pre-v3 behavior exactly, so
|
||||||
|
// these gates are inert until an admin changes something.
|
||||||
|
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const { param, query } = require('express-validator')
|
const { param, query } = require('express-validator')
|
||||||
|
|
||||||
const shard = require('./shard.controller')
|
const shard = require('./shard.controller')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
|
const { marketLimiter } = require('../../../middleware/rateLimit')
|
||||||
|
const { requireFeature } = require('../../../utils/shardVisibility')
|
||||||
|
|
||||||
const shardRouter = express.Router()
|
const shardRouter = express.Router()
|
||||||
|
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/status',
|
'/status',
|
||||||
|
requireFeature('status'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
||||||
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
|
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */
|
||||||
@@ -30,9 +40,11 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/feed',
|
'/feed',
|
||||||
|
requireFeature('activity'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
||||||
// #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale.' }
|
// #swagger.description = 'The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\'s audience rung under the live visibility config, and each event\'s payload is field-projected against its own kind\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.'
|
||||||
|
// #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind.' }
|
||||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' }
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' }
|
||||||
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
/* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */
|
||||||
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
|
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
|
||||||
@@ -42,6 +54,7 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/economy',
|
'/economy',
|
||||||
|
requireFeature('status'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
|
// #swagger.summary = 'Gold-supply time series (oldest → newest)'
|
||||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' }
|
||||||
@@ -52,6 +65,7 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/online',
|
'/online',
|
||||||
|
requireFeature('presence'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
|
// #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)'
|
||||||
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */
|
||||||
@@ -59,13 +73,16 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/idoc',
|
'/idoc',
|
||||||
|
requireFeature('houses'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
// #swagger.summary = 'Houses currently in danger (IDOC)'
|
||||||
|
// #swagger.description = 'Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\'s field rules (default `staff`), and the owner\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.'
|
||||||
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
/* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||||
shard.getIdoc,
|
shard.getIdoc,
|
||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/champs',
|
'/champs',
|
||||||
|
requireFeature('champs'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Current champion-spawn board (all categories)'
|
// #swagger.summary = 'Current champion-spawn board (all categories)'
|
||||||
// #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.'
|
// #swagger.description = 'The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.'
|
||||||
@@ -74,6 +91,7 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/guilds',
|
'/guilds',
|
||||||
|
requireFeature('guilds'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
|
// #swagger.summary = 'Current guild board (rosters, alliances, leaders)'
|
||||||
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
|
// #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.'
|
||||||
@@ -82,6 +100,7 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/governors',
|
'/governors',
|
||||||
|
requireFeature('governors'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Current town-governor board (City Loyalty)'
|
// #swagger.summary = 'Current town-governor board (City Loyalty)'
|
||||||
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
|
// #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.'
|
||||||
@@ -90,6 +109,7 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/governors/:city/history',
|
'/governors/:city/history',
|
||||||
|
requireFeature('governors'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Governor term history for a city'
|
// #swagger.summary = 'Governor term history for a city'
|
||||||
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
|
// #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' }
|
||||||
@@ -102,6 +122,7 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/presence',
|
'/presence',
|
||||||
|
requireFeature('presence'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
|
// #swagger.summary = 'Online population aggregate (count + per-facet + per-region)'
|
||||||
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
|
// #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.'
|
||||||
@@ -110,17 +131,121 @@ shardRouter.get(
|
|||||||
)
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/houses',
|
'/houses',
|
||||||
|
requireFeature('houses'),
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
|
// #swagger.summary = 'House registry (owner, co-owners, price, decay)'
|
||||||
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
|
// #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.'
|
||||||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */
|
||||||
shard.getHouses,
|
shard.getHouses,
|
||||||
)
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/ruleset',
|
||||||
|
requireFeature('ruleset'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'The shard\'s published ruleset (expansion, systems, caps, limits)'
|
||||||
|
// #swagger.description = 'How this shard is actually configured, published by the shard itself as one world.ruleset frame: expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules and the save/restart schedule. Served from our own store, so it renders while the shard is down; live via world.ruleset on /shard/stream. Returns `null` if the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false) — distinct from a published ruleset, and the page renders it differently.'
|
||||||
|
/* #swagger.responses[200] = { description: 'The ruleset, or null if never published', content: { "application/json": { schema: { type: "object", nullable: true, additionalProperties: true } } } } */
|
||||||
|
shard.getRuleset,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/points',
|
||||||
|
requireFeature('leaderboards'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Points / loyalty leaderboards, one board per point system'
|
||||||
|
// #swagger.description = 'Every points/loyalty leaderboard the shard publishes (Queen\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardPointsBoard" } } } } } */
|
||||||
|
shard.getPointsBoards,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/points/:system',
|
||||||
|
requireFeature('leaderboards'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'One points system\'s leaderboard'
|
||||||
|
// #swagger.description = 'A single board by the shard\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.'
|
||||||
|
/* #swagger.parameters['system'] = { in: 'path', required: true, description: 'PointsType name, e.g. QueensLoyalty', schema: { type: 'string' } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardPointsBoard" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Malformed system name' } */
|
||||||
|
/* #swagger.responses[404] = { description: 'The shard has never published that system' } */
|
||||||
|
shard.getPointsBoard,
|
||||||
|
)
|
||||||
|
// ── Marketplace ────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Rate-limited, unlike every other route in this file. These are the first
|
||||||
|
// genuinely expensive PUBLIC reads on the site — a LIKE scan plus a COUNT over
|
||||||
|
// what is typically the largest shard_* table, reachable with no session.
|
||||||
|
shardRouter.get(
|
||||||
|
'/market',
|
||||||
|
requireFeature('market'),
|
||||||
|
marketLimiter,
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Search the player-vendor marketplace'
|
||||||
|
// #swagger.description = 'Every priced listing on every player vendor the shard publishes — the same index the in-game Vendor Search gump reads, and it honours the same per-vendor opt-out, so a player who hid their shop in game is hidden here too. Results are LISTINGS, each carrying enough of its shop to be actionable. Served from the site\'s own tables (the sidecar is not touched), so it renders while the shard is down; `staleAt` is the oldest vendor row and the page must say how far behind the index can be — the shard sweeps vendors round-robin, so prices are inherently up to one full cycle old. Item names are resolved server-side against the cliloc table (docs/website/CLILOCS.md); on a shard that has not configured one, `displayName` is null and clients render the item id.'
|
||||||
|
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the resolved item name or the item\'s own literal name (max 60 chars).' }
|
||||||
|
// #swagger.parameters['minPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Lowest price to include.' }
|
||||||
|
// #swagger.parameters['maxPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Highest price to include.' }
|
||||||
|
// #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match, for "more like this".' }
|
||||||
|
// #swagger.parameters['map'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet. Facet names come from the shard\'s own data; an unknown one returns an empty page.' }
|
||||||
|
// #swagger.parameters['region'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one named region.' }
|
||||||
|
// #swagger.parameters['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. `recent` orders by when the shop was last seen.' }
|
||||||
|
// #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 listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketPage" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'The market feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'The market feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[429] = { description: 'Rate limited', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||||
|
query('minPrice').optional({ values: 'falsy' }).isInt({ min: 0, max: 999999999 }),
|
||||||
|
query('maxPrice').optional({ values: 'falsy' }).isInt({ min: 0, max: 999999999 }),
|
||||||
|
query('itemId').optional({ values: 'falsy' }).isInt({ min: 0, max: 65535 }),
|
||||||
|
query('map').optional({ values: 'falsy' }).isString().isLength({ max: 40 }),
|
||||||
|
query('region').optional({ values: 'falsy' }).isString().isLength({ max: 80 }),
|
||||||
|
query('sort').optional({ values: 'falsy' }).isIn(['price_asc', 'price_desc', 'recent']),
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||||
|
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||||
|
validate,
|
||||||
|
shard.getMarket,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/market/meta',
|
||||||
|
requireFeature('market'),
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Marketplace size, staleness and filter options'
|
||||||
|
// #swagger.description = 'How many vendors and listings the index holds, how stale it may be (`staleAt` = the oldest vendor row, `freshAt` = the newest), and which facets and regions actually hold vendors — so a client can build its filters without running a search it will discard.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketMeta" } } } } */
|
||||||
|
shard.getMarketMeta,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/market/vendors/:serial',
|
||||||
|
requireFeature('market'),
|
||||||
|
marketLimiter,
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'One player vendor and everything it is selling'
|
||||||
|
// #swagger.description = 'A single shop by its vendor serial, with its listings. `truncated` (and `total` exceeding `count`) means the shop holds more than the shard publishes per frame — a commodity reseller with thousands of stacks is a real thing, and the page says so rather than presenting a partial shop as complete. Returns 404 for a serial the index has never seen, which also covers a vendor since dismissed or hidden.'
|
||||||
|
/* #swagger.parameters['serial'] = { in: 'path', required: true, description: 'Vendor serial, e.g. 0x40001234', schema: { type: 'string' } } */
|
||||||
|
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to return, 1..500 (default 250).' }
|
||||||
|
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to skip (default 0).' }
|
||||||
|
/* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketVendor" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Malformed vendor serial' } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No such vendor in the index' } */
|
||||||
|
param('serial').isString().isLength({ max: 20 }),
|
||||||
|
query('limit').optional().isInt({ min: 1, max: 500 }),
|
||||||
|
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||||
|
validate,
|
||||||
|
shard.getMarketVendor,
|
||||||
|
)
|
||||||
|
shardRouter.get(
|
||||||
|
'/features',
|
||||||
|
// #swagger.tags = ['Public · Shard']
|
||||||
|
// #swagger.summary = 'Shard features visible to the caller (drives client nav)'
|
||||||
|
// #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.'
|
||||||
|
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */
|
||||||
|
shard.getFeatures,
|
||||||
|
)
|
||||||
shardRouter.get(
|
shardRouter.get(
|
||||||
'/stream',
|
'/stream',
|
||||||
// #swagger.tags = ['Public · Shard']
|
// #swagger.tags = ['Public · Shard']
|
||||||
// #swagger.summary = 'Live shard event stream (Server-Sent Events, public/safe kinds)'
|
// #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)'
|
||||||
// #swagger.description = 'text/event-stream of curated live events. Sensitive kinds (staff audit, cheat detection, login attempts, IPs) are NOT sent on this channel.'
|
// #swagger.description = 'text/event-stream of live events. The caller\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.'
|
||||||
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||||
shard.stream,
|
shard.stream,
|
||||||
)
|
)
|
||||||
|
|||||||
35
server/src/router/v1/settings/index.js
Normal file
35
server/src/router/v1/settings/index.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// /api/v1/settings — settings any *authenticated* account needs to read, whoever
|
||||||
|
// they are.
|
||||||
|
//
|
||||||
|
// A fifth group alongside /auth, /public, /admin and /player, and deliberately
|
||||||
|
// not folded into any of them:
|
||||||
|
//
|
||||||
|
// - /public is anonymous, and the admin nav's labels describe the shape of the
|
||||||
|
// admin surface — that belongs behind a login.
|
||||||
|
// - /admin is `staffOnly` + `requireRole('admin')` on settings, but AdminLayout
|
||||||
|
// renders for editors and moderators too, so they could never read their own
|
||||||
|
// nav overrides from there (docs/website/THEMING_AND_NAV.md §4.2).
|
||||||
|
// - /player is self-service data scoped to req.user.id. These rows are
|
||||||
|
// site-wide configuration that happens to need a login, not anything about
|
||||||
|
// the caller.
|
||||||
|
//
|
||||||
|
// Group gate: authenticated only, no role restriction — staff and players alike
|
||||||
|
// read their own layout's nav. It lives here, ahead of every mount, so a route
|
||||||
|
// added later cannot ship ungated.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
const { requireAuth } = require('../../../auth/session.middleware')
|
||||||
|
const noindex = require('../../../middleware/noindex')
|
||||||
|
|
||||||
|
const navRouter = require('./nav.router')
|
||||||
|
const themeRouter = require('./theme.router')
|
||||||
|
|
||||||
|
const settingsRouter = express.Router()
|
||||||
|
|
||||||
|
settingsRouter.use(noindex, requireAuth)
|
||||||
|
|
||||||
|
settingsRouter.use('/nav', navRouter)
|
||||||
|
settingsRouter.use('/theme', themeRouter)
|
||||||
|
|
||||||
|
module.exports = settingsRouter
|
||||||
22
server/src/router/v1/settings/nav.controller.js
Normal file
22
server/src/router/v1/settings/nav.controller.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const settings = require('../../../model/settings/settings.model')
|
||||||
|
|
||||||
|
// The logger module exports a FACTORY — calling it is what yields {error, warn,
|
||||||
|
// info, debug}. Using the factory directly makes `log.error` undefined, which
|
||||||
|
// would turn a DB fault into a TypeError thrown inside the catch (no response
|
||||||
|
// sent, request left hanging) instead of a 500.
|
||||||
|
const log = require('../../../utils/logger')('settings')
|
||||||
|
|
||||||
|
// The nav overrides for the two authenticated layouts. Values are the raw stored
|
||||||
|
// JSON strings (settings.value is TEXT) or null; the caller parses them with the
|
||||||
|
// same fail-safe posture as every other JSON setting — malformed reads as
|
||||||
|
// absent, and absent means the hardcoded NAV array is used unchanged.
|
||||||
|
async function getNav(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await settings.getNav())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getNav', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getNav }
|
||||||
28
server/src/router/v1/settings/nav.router.js
Normal file
28
server/src/router/v1/settings/nav.router.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// Settings · Nav — the admin-sidebar and player-portal nav overrides, readable
|
||||||
|
// by the accounts those navs are rendered for.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/settings/nav by settings/index.js, which already applied
|
||||||
|
// `noindex, requireAuth`. No role gate on purpose: an editor, a moderator and a
|
||||||
|
// player each need the override for the layout they see, and the payload is
|
||||||
|
// presentation-only — label/order/hidden/group over items the reader's own
|
||||||
|
// role/feature filter still gets the final say on
|
||||||
|
// (docs/website/THEMING_AND_NAV.md §7).
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
const ctrl = require('./nav.controller')
|
||||||
|
|
||||||
|
const navRouter = express.Router()
|
||||||
|
|
||||||
|
navRouter.get(
|
||||||
|
'/',
|
||||||
|
// #swagger.tags = ['Settings']
|
||||||
|
// #swagger.summary = 'Nav overrides for the admin and player layouts'
|
||||||
|
// #swagger.description = 'Returns the stored nav_admin and nav_player overrides as raw JSON strings (null when the admin never overrode that nav). Any authenticated account may read them: AdminLayout renders for editors and moderators, PlayerPortalLayout for players, and none of them can read GET /admin/settings. Presentation-only — the role/feature filters in the layouts still decide what is actually shown.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Nav overrides', content: { "application/json": { schema: { $ref: "#/components/schemas/NavSettings" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
ctrl.getNav,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = navRouter
|
||||||
17
server/src/router/v1/settings/theme.controller.js
Normal file
17
server/src/router/v1/settings/theme.controller.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const { themeOptions } = require('../../../utils/themeResolve')
|
||||||
|
|
||||||
|
// The theme catalog the admin appearance form builds its controls from: the
|
||||||
|
// presets and their swatches, the curated font shortlist, the shadow depths,
|
||||||
|
// and which color and radius fields are editable.
|
||||||
|
//
|
||||||
|
// Served rather than duplicated in client code so the options the form OFFERS
|
||||||
|
// can never drift from the ones validateThemeVisual() ACCEPTS — a drift shows
|
||||||
|
// up as an admin picking a font and the save 400ing for no visible reason.
|
||||||
|
//
|
||||||
|
// Static: derived from config/themePresets.js with no DB read, so there is
|
||||||
|
// nothing here to fail and no error branch to write.
|
||||||
|
function getThemeOptions(req, res) {
|
||||||
|
return res.json(themeOptions())
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getThemeOptions }
|
||||||
26
server/src/router/v1/settings/theme.router.js
Normal file
26
server/src/router/v1/settings/theme.router.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Settings · Theme — the closed sets the admin appearance form is built from.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/settings/theme by settings/index.js, which already applied
|
||||||
|
// `noindex, requireAuth`. No role gate is added here for the same reason the
|
||||||
|
// group has none: it is a static catalog of presets and font names, not
|
||||||
|
// configuration and not anything about the caller. The route that WRITES a
|
||||||
|
// theme is PUT /api/v1/admin/settings, which is admin-only.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
const ctrl = require('./theme.controller')
|
||||||
|
|
||||||
|
const themeRouter = express.Router()
|
||||||
|
|
||||||
|
themeRouter.get(
|
||||||
|
'/options',
|
||||||
|
// #swagger.tags = ['Settings']
|
||||||
|
// #swagger.summary = 'Theme presets and the curated option lists'
|
||||||
|
// #swagger.description = 'The closed sets an admin may choose from when theming the site: the three presets (with swatch colors), the curated Google Fonts shortlist per role, the shadow depths, and the editable color/radius field names. Served so the admin form can never offer a value the server would reject. Static — no database read.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Theme option catalog', content: { "application/json": { schema: { $ref: "#/components/schemas/ThemeOptions" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
ctrl.getThemeOptions,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = themeRouter
|
||||||
@@ -6,11 +6,18 @@ const authRouter = require('./auth')
|
|||||||
const publicRouter = require('./public')
|
const publicRouter = require('./public')
|
||||||
const adminRouter = require('./admin')
|
const adminRouter = require('./admin')
|
||||||
const playerRouter = require('./player')
|
const playerRouter = require('./player')
|
||||||
|
const settingsRouter = require('./settings')
|
||||||
|
|
||||||
v1Router.use('/auth', authRouter)
|
v1Router.use('/auth', authRouter)
|
||||||
v1Router.use('/public', publicRouter)
|
v1Router.use('/public', publicRouter)
|
||||||
v1Router.use('/admin', adminRouter)
|
v1Router.use('/admin', adminRouter)
|
||||||
v1Router.use('/player', playerRouter)
|
v1Router.use('/player', playerRouter)
|
||||||
|
// Site-wide settings that need a login but no particular role — currently the
|
||||||
|
// nav overrides the admin and player layouts read for themselves. Not /public
|
||||||
|
// (the admin nav's labels describe the admin surface), not /admin (editors and
|
||||||
|
// moderators render AdminLayout but are not admins), not /player (this is
|
||||||
|
// configuration, not self-scoped data). See settings/index.js.
|
||||||
|
v1Router.use('/settings', settingsRouter)
|
||||||
// NOTE: /internal is intentionally NOT mounted here. Those routes return the
|
// NOTE: /internal is intentionally NOT mounted here. Those routes return the
|
||||||
// decrypted Discord bot token and must never share the public listener that
|
// decrypted Discord bot token and must never share the public listener that
|
||||||
// Pangolin proxies. They live on a separate, unpublished port via
|
// Pangolin proxies. They live on a separate, unpublished port via
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
|||||||
const settings = require('./model/settings/settings.model')
|
const settings = require('./model/settings/settings.model')
|
||||||
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
||||||
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
|
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
|
||||||
|
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
|
||||||
|
const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
|
||||||
|
const shardMarket = require('./model/shardMarket/shardMarket.model')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
||||||
const brand = require('./config/brand')
|
const brand = require('./config/brand')
|
||||||
@@ -77,6 +80,32 @@ async function start() {
|
|||||||
log.warn('mobile-auth-bridge prune failed', { error: err.message })
|
log.warn('mobile-auth-bridge prune failed', { error: err.message })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 database write.
|
||||||
|
//
|
||||||
|
// Best-effort by contract: no configured path, an unreadable mount or a
|
||||||
|
// malformed file must never stop the site coming up. A refresh that would
|
||||||
|
// REMOVE a facet is staged for admin approval instead of being applied.
|
||||||
|
await shardAtlas.refreshOnBoot()
|
||||||
|
|
||||||
|
// Refresh the cliloc table (UO's id → display-string map) from the file the
|
||||||
|
// operator converted out of their own client. Same contract as the atlas:
|
||||||
|
// hash-gated so an unchanged file costs one read, and best-effort so a missing
|
||||||
|
// or wrong-format file never stops the site coming up — it just means item
|
||||||
|
// names render as ids, which is what they did before the table existed.
|
||||||
|
const clilocResult = await shardClilocs.refreshOnBoot()
|
||||||
|
|
||||||
|
// A cliloc import changes what item names RESOLVE to, and the marketplace
|
||||||
|
// stores those names denormalized (shard_vendor_items.display_name) so it can
|
||||||
|
// index and search them. The shard's market sweep will not re-send an unchanged
|
||||||
|
// shop just because the site learned what its items are called, so the backfill
|
||||||
|
// has to be pulled rather than waited for. Only after an actual import — the
|
||||||
|
// common boot is hash-gated to a no-op and must stay one.
|
||||||
|
if (clilocResult && clilocResult.status === 'imported') await shardMarket.refreshDisplayNames()
|
||||||
|
|
||||||
const mode = await settings.get('site_mode')
|
const mode = await settings.get('site_mode')
|
||||||
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
||||||
|
|
||||||
|
|||||||
95
server/src/utils/brandAssets.js
Normal file
95
server/src/utils/brandAssets.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// Uploaded brand-asset overrides — the `brand_assets` settings row.
|
||||||
|
//
|
||||||
|
// { "logo": "/uploads/1234-ab.png", "hero": null, "favicon": null }
|
||||||
|
//
|
||||||
|
// Each field, once set, holds a stored upload URL; a null or absent field falls
|
||||||
|
// back to brand.logo / brand.hero / brand.favicon from BRAND_* env. Uploading a
|
||||||
|
// logo does not force the admin to also pick a hero
|
||||||
|
// (docs/website/THEMING_AND_NAV.md §6.3).
|
||||||
|
//
|
||||||
|
// These values are the only part of the settings store that is written straight
|
||||||
|
// into HTML the browser then fetches — an <img src>, a <link rel="icon">, an
|
||||||
|
// og:image. So the accepted shape is deliberately narrow: a same-origin path
|
||||||
|
// under one of the three directories this app serves, and nothing else. No
|
||||||
|
// scheme, no protocol-relative `//host`, no `..`. The upload route only ever
|
||||||
|
// produces `/uploads/…`, so the other two prefixes exist for an admin who wants
|
||||||
|
// to point at an asset already baked into the image or mounted at /brand.
|
||||||
|
//
|
||||||
|
// Same strict-on-write / forgiving-on-read asymmetry as the theme
|
||||||
|
// (utils/themeResolve.js): a bad write is rejected with the field named, while a
|
||||||
|
// bad *stored* value is dropped field by field so a hand-edited row degrades to
|
||||||
|
// the env default instead of rendering a broken page.
|
||||||
|
|
||||||
|
// The three overridable assets, in the order the admin UI shows them.
|
||||||
|
const SLOTS = ['logo', 'hero', 'favicon']
|
||||||
|
|
||||||
|
// Directories this server actually serves: /uploads (UPLOAD_DIR), /brand
|
||||||
|
// (BRAND_DIR, optional) and /assets (the built SPA's static files).
|
||||||
|
const ALLOWED_PREFIXES = ['/uploads/', '/brand/', '/assets/']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this a value we are willing to emit as a URL into the page?
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isSafeAssetPath(value) {
|
||||||
|
if (typeof value !== 'string' || value === '') return false
|
||||||
|
// A leading `//` is protocol-relative and would load from another origin
|
||||||
|
// despite looking like a path; `..` could climb out of the served directory.
|
||||||
|
if (value.startsWith('//') || value.includes('..')) return false
|
||||||
|
// Whitespace and control characters have no place in a stored path and are the
|
||||||
|
// raw material for `javascript:` smuggling past a naive prefix check.
|
||||||
|
if (/[\s<>"'\\]/.test(value)) return false
|
||||||
|
return ALLOWED_PREFIXES.some((prefix) => value.startsWith(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a brand_assets object for WRITING. Strict: names the offending field.
|
||||||
|
* @param {unknown} value the parsed object (or null to clear every slot)
|
||||||
|
* @returns {{ok: true} | {ok: false, message: string}}
|
||||||
|
*/
|
||||||
|
function validateBrandAssets(value) {
|
||||||
|
if (value === null || value === undefined) return { ok: true }
|
||||||
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return { ok: false, message: 'brand_assets must be a JSON object' }
|
||||||
|
}
|
||||||
|
for (const [slot, url] of Object.entries(value)) {
|
||||||
|
if (!SLOTS.includes(slot)) {
|
||||||
|
return { ok: false, message: `Unknown brand asset '${slot}'` }
|
||||||
|
}
|
||||||
|
// null/'' is how a slot is cleared back to the env default — allowed, and
|
||||||
|
// stripped by the caller so the stored row never carries dead fields.
|
||||||
|
if (url === null || url === '') continue
|
||||||
|
if (!isSafeAssetPath(url)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: `brand_assets.${slot} must be an uploaded path under /uploads/, /brand/ or /assets/`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only the slots that hold a usable path. Serves both directions on
|
||||||
|
* purpose:
|
||||||
|
*
|
||||||
|
* • writing — an admin who removes their logo stores `{}` (and the caller
|
||||||
|
* deletes the row entirely) rather than a row full of nulls, which would
|
||||||
|
* read as "set to nothing" rather than "never set";
|
||||||
|
* • reading — an unusable stored field is dropped and its neighbours kept, so
|
||||||
|
* one bad slot cannot cost the admin the other two.
|
||||||
|
*
|
||||||
|
* @param {object|null} value an object, or a parseJsonSetting result
|
||||||
|
* @returns {{logo?: string, hero?: string, favicon?: string}}
|
||||||
|
*/
|
||||||
|
function resolveBrandAssets(value) {
|
||||||
|
const out = {}
|
||||||
|
if (!value || typeof value !== 'object') return out
|
||||||
|
for (const slot of SLOTS) {
|
||||||
|
if (isSafeAssetPath(value[slot])) out[slot] = value[slot]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SLOTS, ALLOWED_PREFIXES, isSafeAssetPath, validateBrandAssets, resolveBrandAssets }
|
||||||
287
server/src/utils/clilocParse.js
Normal file
287
server/src/utils/clilocParse.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
// Cliloc parsing — the pure half.
|
||||||
|
//
|
||||||
|
// A "cliloc" is UO's localization table: an integer id mapped to a display
|
||||||
|
// string. Items carry a `LabelNumber` rather than a name, so without this table
|
||||||
|
// the site can only render `id 1023721` where the game shows "quarter staff".
|
||||||
|
// The shard already sends the id on every equipment entry (`char.profile`'s
|
||||||
|
// `cliloc` field) and will send one per marketplace listing — the *number* was
|
||||||
|
// never the missing piece, the *table* was.
|
||||||
|
//
|
||||||
|
// This module is fs-free on purpose, exactly like `spawnAtlasParse.js`: the
|
||||||
|
// suite runs in CI where there is no UO client, so every parser here is driven
|
||||||
|
// from inline fixtures. `clilocSource.js` is the only thing that touches disk.
|
||||||
|
//
|
||||||
|
// ── Two input formats, and why ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The client's own `Cliloc.enu` is COMPRESSED (Mythic format) on any modern
|
||||||
|
// client, and decompressing it is a bit-level port of an inverse-BWT coder that
|
||||||
|
// nothing in this stack needs at runtime. ServUO's own bundled `Ultima.StringList`
|
||||||
|
// cannot read it either — which is why `VendorSearch.GetItemName` is already inert
|
||||||
|
// on such a shard and the plugin could not supply names even if we asked it to.
|
||||||
|
//
|
||||||
|
// So the operator converts once, from their own client, and points the site at
|
||||||
|
// the result (see docs/website/CLILOCS.md). Two shapes are accepted because
|
||||||
|
// different tools produce different things:
|
||||||
|
//
|
||||||
|
// • PLAIN BINARY — the pre-compression cliloc layout: a 6-byte header, then
|
||||||
|
// records of {int32 number, byte flag, uint16 length, UTF-8 bytes}.
|
||||||
|
// • DELIMITED TEXT — `number<TAB|,|;>text` per line, which is what the common
|
||||||
|
// GUI exports emit. Quoted CSV fields and a header row are tolerated.
|
||||||
|
//
|
||||||
|
// Nothing derived from the client is ever committed: the converted file lives at
|
||||||
|
// an operator-supplied path and is gitignored, the same rule the spawn atlas art
|
||||||
|
// map already follows.
|
||||||
|
|
||||||
|
/** Raised for a file we can identify but deliberately refuse to guess at. */
|
||||||
|
class ClilocFormatError extends Error {
|
||||||
|
constructor(message, code) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ClilocFormatError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bumped when this parser produces DIFFERENT data from an IDENTICAL source file.
|
||||||
|
*
|
||||||
|
* Stored beside the source hash so the boot path can tell "same file, but the
|
||||||
|
* parser moved on" from "same file, nothing to do". Without it a corrected parse
|
||||||
|
* would ship and never reach an install whose cliloc file never changes — the
|
||||||
|
* trap `spawnAtlasSource.PARSER_VERSION` documents.
|
||||||
|
*/
|
||||||
|
const PARSER_VERSION = 1
|
||||||
|
|
||||||
|
// The plain layout's header is `02 00 00 00 01 00` — a 4-byte version and a
|
||||||
|
// 2-byte language marker. Only the size matters for parsing; the values are
|
||||||
|
// checked to sniff the format, not to validate it.
|
||||||
|
const HEADER_BYTES = 6
|
||||||
|
const RECORD_HEADER_BYTES = 7 // int32 number + byte flag + uint16 length
|
||||||
|
|
||||||
|
// Every compressed cliloc file the client ships begins with a DWORD whose high
|
||||||
|
// byte is 0x8E (the XOR key UOFiddler calls `HeaderXorKey`, 0x8E2C9A3D). That is
|
||||||
|
// the single cheapest way to tell an operator they exported the wrong file —
|
||||||
|
// without it, the plain parser happily reads compressed bytes as ~19k records of
|
||||||
|
// negative ids and 60 KB "strings" before dying somewhere in the middle, and the
|
||||||
|
// resulting error names the wrong problem.
|
||||||
|
const MYTHIC_HIGH_BYTE = 0x8e
|
||||||
|
|
||||||
|
/** True when `buffer` is a Mythic-compressed cliloc rather than the plain layout. */
|
||||||
|
function isCompressedCliloc(buffer) {
|
||||||
|
return buffer.length >= 4 && buffer[3] === MYTHIC_HIGH_BYTE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the plain binary cliloc layout.
|
||||||
|
*
|
||||||
|
* Strict about truncation, and that strictness is load-bearing: a half-copied or
|
||||||
|
* partly-written file is the realistic failure here, and it must fail loudly
|
||||||
|
* rather than import a silently short table that then renders half the world as
|
||||||
|
* `id 1023721`. A record that runs past the end of the buffer throws.
|
||||||
|
*/
|
||||||
|
function parseClilocBinary(buffer) {
|
||||||
|
if (!Buffer.isBuffer(buffer)) throw new ClilocFormatError('Not a buffer', 'NOT_BUFFER')
|
||||||
|
if (isCompressedCliloc(buffer)) {
|
||||||
|
throw new ClilocFormatError(
|
||||||
|
'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.',
|
||||||
|
'COMPRESSED',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (buffer.length < HEADER_BYTES) {
|
||||||
|
throw new ClilocFormatError('File is shorter than a cliloc header', 'TRUNCATED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
let offset = HEADER_BYTES
|
||||||
|
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
if (offset + RECORD_HEADER_BYTES > buffer.length) {
|
||||||
|
throw new ClilocFormatError(
|
||||||
|
`Truncated record header at byte ${offset} (${entries.length} entries read)`,
|
||||||
|
'TRUNCATED',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const number = buffer.readInt32LE(offset)
|
||||||
|
const flag = buffer.readUInt8(offset + 4)
|
||||||
|
// The length is written by the client as an unsigned 16-bit value. Reading it
|
||||||
|
// signed (as ServUO's own SDK does) turns any string over 32 KB into a
|
||||||
|
// negative length; real tables top out around 12 KB, so this has no effect on
|
||||||
|
// current data and costs nothing to get right.
|
||||||
|
const length = buffer.readUInt16LE(offset + 5)
|
||||||
|
offset += RECORD_HEADER_BYTES
|
||||||
|
|
||||||
|
if (offset + length > buffer.length) {
|
||||||
|
throw new ClilocFormatError(
|
||||||
|
`Truncated record body at byte ${offset} (${entries.length} entries read)`,
|
||||||
|
'TRUNCATED',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entries.push({ number, flag, text: buffer.toString('utf8', offset, offset + length) })
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// A delimited line splits on the FIRST separator only: cliloc text is full of
|
||||||
|
// commas ("a scroll of magery, unfinished") and splitting on all of them would
|
||||||
|
// truncate every such entry at its first comma.
|
||||||
|
const TEXT_SEPARATORS = ['\t', ',', ';']
|
||||||
|
|
||||||
|
/** Unwrap one CSV field: strip surrounding quotes and unescape doubled quotes. */
|
||||||
|
function unquote(value) {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/""/g, '"')
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a delimited text export: `number<sep>text` per line.
|
||||||
|
*
|
||||||
|
* Tolerant by design — this is whatever an operator's GUI tool produced, not a
|
||||||
|
* format we control. A header row, blank lines, `#` comments and a trailing
|
||||||
|
* flags column are all ignored. A line whose first field is not an integer is
|
||||||
|
* skipped rather than fatal, because that is exactly what a header row is.
|
||||||
|
*
|
||||||
|
* The one thing it will NOT do is return an empty table quietly: a file that
|
||||||
|
* yields no entries at all is a wrong file, not an empty one.
|
||||||
|
*/
|
||||||
|
function parseClilocText(text) {
|
||||||
|
const entries = []
|
||||||
|
for (const line of String(text).split(/\r?\n/)) {
|
||||||
|
// The line is deliberately NOT trimmed before the separator search. Roughly
|
||||||
|
// half of a real cliloc table is empty strings (unused ids), which export as
|
||||||
|
// `1005008<TAB>` — and trimming eats that trailing separator, leaving a bare
|
||||||
|
// number that then looks like a header row and is skipped. That silently
|
||||||
|
// dropped 55,994 of 123,490 entries. Individual FIELDS are trimmed instead,
|
||||||
|
// by `unquote`.
|
||||||
|
if (line.trim() === '' || line.trimStart().startsWith('#')) continue
|
||||||
|
|
||||||
|
// Pick the separator that actually appears first, so a tab-delimited line
|
||||||
|
// whose text contains a comma still splits on the tab.
|
||||||
|
let cut = -1
|
||||||
|
for (const sep of TEXT_SEPARATORS) {
|
||||||
|
const at = line.indexOf(sep)
|
||||||
|
if (at !== -1 && (cut === -1 || at < cut)) cut = at
|
||||||
|
}
|
||||||
|
if (cut === -1) continue
|
||||||
|
|
||||||
|
// An EMPTY first field must not become id 0: `Number('')` is 0, not NaN, so
|
||||||
|
// a line that merely starts with a separator would otherwise import as a
|
||||||
|
// bogus cliloc 0 instead of being skipped.
|
||||||
|
const head = unquote(line.slice(0, cut))
|
||||||
|
if (head === '') continue
|
||||||
|
const number = Number(head)
|
||||||
|
if (!Number.isInteger(number)) continue // header row, or a wrapped line
|
||||||
|
|
||||||
|
let rest = line.slice(cut + 1)
|
||||||
|
// Some exports carry `number,flag,text`. A bare integer in the second field
|
||||||
|
// is a flag; anything else is the text itself (and a text field that IS just
|
||||||
|
// a number is indistinguishable, so it stays as the text — the safer miss).
|
||||||
|
let flag = 0
|
||||||
|
for (const sep of TEXT_SEPARATORS) {
|
||||||
|
const at = rest.indexOf(sep)
|
||||||
|
if (at === -1) continue
|
||||||
|
const head = unquote(rest.slice(0, at))
|
||||||
|
if (/^\d{1,3}$/.test(head) && rest.slice(at + 1).trim() !== '') {
|
||||||
|
flag = Number(head)
|
||||||
|
rest = rest.slice(at + 1)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({ number, flag, text: unquote(rest) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
throw new ClilocFormatError('No cliloc entries found in the text export', 'EMPTY')
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse either supported shape, sniffing which one this is.
|
||||||
|
*
|
||||||
|
* The sniff is on the binary header rather than the file extension: operators
|
||||||
|
* name these things whatever they like, and an `.enu` that is really a TSV (or a
|
||||||
|
* `.txt` that is really binary) should still import.
|
||||||
|
*/
|
||||||
|
function parseCliloc(buffer) {
|
||||||
|
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer)
|
||||||
|
|
||||||
|
if (isCompressedCliloc(buf)) {
|
||||||
|
throw new ClilocFormatError(
|
||||||
|
'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.',
|
||||||
|
'COMPRESSED',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plain layout always opens with version 2 / language 1. Anything else is
|
||||||
|
// treated as text, which is the recoverable guess: a mis-sniffed text file
|
||||||
|
// yields "no entries found", while a mis-sniffed binary yields nonsense.
|
||||||
|
if (buf.length >= HEADER_BYTES && buf.readInt32LE(0) === 2 && buf.readUInt16LE(4) === 1) {
|
||||||
|
return parseClilocBinary(buf)
|
||||||
|
}
|
||||||
|
return parseClilocText(buf.toString('utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Cliloc strings interpolate arguments the client supplies out of an item's
|
||||||
|
// property list: `~1_val~`, `~2_NAME~`, `~1_ITEM~`. We never have those — the
|
||||||
|
// bridge sends the id, not the packet — so a name carrying them must be reduced
|
||||||
|
// to what is actually knowable rather than shown with the raw tokens in it.
|
||||||
|
const PLACEHOLDER_RE = /~\d+_[^~]*~/g
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce a raw cliloc string to something displayable.
|
||||||
|
*
|
||||||
|
* Placeholders are dropped and the leftover punctuation tidied, so
|
||||||
|
* `"[~1_stuff~]"` becomes `""` (correctly nothing — the whole string was the
|
||||||
|
* argument) and `"cold damage ~1_val~%"` becomes `"cold damage"`.
|
||||||
|
*
|
||||||
|
* **Punctuation is only tidied when a placeholder was actually removed.** The
|
||||||
|
* trailing `%` above is the unit belonging to the number we never had, and the
|
||||||
|
* brackets in `[~1_stuff~]` only ever wrapped the argument — but a string with
|
||||||
|
* no placeholder has no such debris, and trimming it anyway corrupts real names.
|
||||||
|
* A shard's `"Runic Gateway Sigil (v2)"` came back as `"(v2"` while this was
|
||||||
|
* unconditional.
|
||||||
|
*
|
||||||
|
* Returns `''` when nothing survives, which callers treat as "no name" and fall
|
||||||
|
* back to the item id — better than showing a bracket.
|
||||||
|
*/
|
||||||
|
const DEBRIS = /^[\s\-–—,.;:%[\]()]+|[\s\-–—,.;:%[\]()]+$/g
|
||||||
|
|
||||||
|
function displayText(raw) {
|
||||||
|
if (raw == null) return ''
|
||||||
|
const source = String(raw)
|
||||||
|
const hadPlaceholder = PLACEHOLDER_RE.test(source)
|
||||||
|
PLACEHOLDER_RE.lastIndex = 0 // the regex is global; `test` advances it
|
||||||
|
|
||||||
|
if (!hadPlaceholder) return source.replace(/\s+/g, ' ').trim()
|
||||||
|
|
||||||
|
return source
|
||||||
|
.replace(PLACEHOLDER_RE, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/\s+([,.;:!?])/g, '$1')
|
||||||
|
.replace(DEBRIS, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a raw cliloc string is nothing but interpolated arguments. */
|
||||||
|
const isPlaceholderOnly = (raw) => raw != null && String(raw).trim() !== '' && displayText(raw) === ''
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ClilocFormatError,
|
||||||
|
PARSER_VERSION,
|
||||||
|
HEADER_BYTES,
|
||||||
|
isCompressedCliloc,
|
||||||
|
parseCliloc,
|
||||||
|
parseClilocBinary,
|
||||||
|
parseClilocText,
|
||||||
|
displayText,
|
||||||
|
isPlaceholderOnly,
|
||||||
|
}
|
||||||
316
server/src/utils/clilocSource.js
Normal file
316
server/src/utils/clilocSource.js
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
// Cliloc table — the filesystem layer.
|
||||||
|
//
|
||||||
|
// `clilocParse.js` holds the pure parsers; this module is the only thing that
|
||||||
|
// touches cliloc files on disk, and it is shared by both callers:
|
||||||
|
//
|
||||||
|
// - the server, which refreshes the table on boot (`shardClilocs.model.js`)
|
||||||
|
// - the admin panel, which can force a reimport without a restart
|
||||||
|
//
|
||||||
|
// The files are the OPERATOR'S (see docs/website/CLILOCS.md). Nothing derived
|
||||||
|
// from them is committed: the repo holds no string table, exactly as it holds no
|
||||||
|
// map snapshot and no artwork. That rule is why this module reads a configured
|
||||||
|
// path instead of a path inside the repo.
|
||||||
|
//
|
||||||
|
// ── Why this reads a SET of files, not one ────────────────────────────────
|
||||||
|
//
|
||||||
|
// Shards edit items and add new ones. Those carry cliloc ids that a stock client
|
||||||
|
// table does not have — and forcing a 5 MB client re-export every time an
|
||||||
|
// operator adds one item would be miserable enough that the table would simply
|
||||||
|
// go stale, which is the exact failure the spawn atlas was redesigned to avoid.
|
||||||
|
//
|
||||||
|
// So this mirrors `spawnAtlasSource.readSources()`: a BASE table (the converted
|
||||||
|
// client file) plus every operator-maintained OVERLAY beside it, all re-read on
|
||||||
|
// every boot and hash-gated as a SET. Adding, editing or removing any overlay
|
||||||
|
// counts as drift and re-imports. Later sources win, so an overlay both adds new
|
||||||
|
// ids and overrides stock ones.
|
||||||
|
//
|
||||||
|
// Measured on a real shard: the script tree references 16,434 cliloc ids and only
|
||||||
|
// 37 are absent from the stock client table. Tens of entries against a 67k base
|
||||||
|
// is what makes the overlay the right shape rather than a second full table.
|
||||||
|
//
|
||||||
|
// Reading and hashing ~5 MB costs a few milliseconds and a full parse ~50 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 { ClilocFormatError, PARSER_VERSION, parseCliloc, isCompressedCliloc } = require('./clilocParse')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filenames looked for as the BASE table when the configured path is a directory.
|
||||||
|
*
|
||||||
|
* Ordered by how specific they are: an explicitly converted file wins over
|
||||||
|
* something that merely sits in a client folder, so an operator who dropped a
|
||||||
|
* `cliloc.plain.enu` next to the original compressed `cliloc.enu` gets the one
|
||||||
|
* they made rather than the one that will be rejected.
|
||||||
|
*
|
||||||
|
* Matching is case-insensitive against the real directory listing, because the
|
||||||
|
* client ships `Cliloc.enu` on Windows and the site usually runs on Linux, where
|
||||||
|
* a hardcoded lowercase open would simply miss.
|
||||||
|
*/
|
||||||
|
const CANDIDATE_NAMES = [
|
||||||
|
'clilocs.tsv',
|
||||||
|
'clilocs.csv',
|
||||||
|
'clilocs.plain',
|
||||||
|
'cliloc.plain',
|
||||||
|
'cliloc.plain.enu',
|
||||||
|
'cliloc.enu.plain',
|
||||||
|
'clilocs.txt',
|
||||||
|
'cliloc.enu',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where shard-specific additions and overrides live: a `custom/` directory
|
||||||
|
* beside the base table.
|
||||||
|
*
|
||||||
|
* ServUO has **no server-side convention** for custom clilocs — they live in the
|
||||||
|
* patched client file a shard distributes to its players, and nothing in the
|
||||||
|
* tree declares them. There is therefore nothing to discover, and this is the
|
||||||
|
* one place in the cliloc pipeline that is a convention we chose rather than one
|
||||||
|
* the shard already has. It is a directory rather than a single file so an
|
||||||
|
* operator can keep additions grouped however they like (per system, per patch)
|
||||||
|
* without the site caring.
|
||||||
|
*/
|
||||||
|
const CUSTOM_DIR = 'custom'
|
||||||
|
const CUSTOM_EXTENSIONS = ['.tsv', '.csv', '.txt', '.enu', '.plain']
|
||||||
|
|
||||||
|
class ClilocSourceError extends Error {
|
||||||
|
constructor(message, code) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ClilocSourceError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(buffer) {
|
||||||
|
return crypto.createHash('sha256').update(buffer).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the configured path to `{ root, base }`.
|
||||||
|
*
|
||||||
|
* Accepts either a direct file path or a directory to search, because operators
|
||||||
|
* reasonably supply both — "here is the file" and "here is the folder I put it
|
||||||
|
* in" are equally natural answers to the admin panel's prompt. When it is a
|
||||||
|
* file, `root` is the directory CONTAINING it, so overlays work either way: an
|
||||||
|
* operator who pointed at a file should not have to re-point at its folder just
|
||||||
|
* to add a `custom/` directory next to it.
|
||||||
|
*/
|
||||||
|
function resolveBase(configured) {
|
||||||
|
if (!configured || String(configured).trim() === '') {
|
||||||
|
throw new ClilocSourceError('No cliloc path configured', 'NO_PATH')
|
||||||
|
}
|
||||||
|
const target = String(configured).trim()
|
||||||
|
|
||||||
|
let stat
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(target)
|
||||||
|
} catch {
|
||||||
|
throw new ClilocSourceError(`Cliloc path does not exist: ${target}`, 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat.isFile()) return { root: path.dirname(target), base: target }
|
||||||
|
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
throw new ClilocSourceError(`Cliloc path is neither a file nor a directory: ${target}`, 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
|
||||||
|
let listing
|
||||||
|
try {
|
||||||
|
listing = fs.readdirSync(target)
|
||||||
|
} catch {
|
||||||
|
throw new ClilocSourceError(`Cliloc directory is not readable: ${target}`, 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
|
||||||
|
const byLower = new Map(listing.map((name) => [name.toLowerCase(), name]))
|
||||||
|
for (const candidate of CANDIDATE_NAMES) {
|
||||||
|
const actual = byLower.get(candidate)
|
||||||
|
if (actual) return { root: target, base: path.join(target, actual) }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ClilocSourceError(
|
||||||
|
`No cliloc file found in ${target} (looked for ${CANDIDATE_NAMES.join(', ')})`,
|
||||||
|
'NO_FILE',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overlay files under `<root>/custom/`, sorted so precedence is deterministic. */
|
||||||
|
function listCustom(root) {
|
||||||
|
const dir = path.join(root, CUSTOM_DIR)
|
||||||
|
let listing
|
||||||
|
try {
|
||||||
|
listing = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
} catch (err) {
|
||||||
|
// No overlay directory is the normal case, not an error.
|
||||||
|
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
|
||||||
|
throw new ClilocSourceError(`Cliloc overlay directory is not readable: ${dir}`, 'UNREADABLE')
|
||||||
|
}
|
||||||
|
return listing
|
||||||
|
.filter((e) => e.isFile() && CUSTOM_EXTENSIONS.includes(path.extname(e.name).toLowerCase()))
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort()
|
||||||
|
.map((name) => path.join(dir, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileOrThrow(file) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(file)
|
||||||
|
} catch {
|
||||||
|
throw new ClilocSourceError(`Cliloc file is not readable: ${file}`, 'UNREADABLE')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read every cliloc source under the configured path.
|
||||||
|
*
|
||||||
|
* Returns `{ root, files: [{ label, kind, file, buffer, sha256, bytes, compressed }] }`
|
||||||
|
* with the base first and overlays after, in the order they must be merged.
|
||||||
|
*
|
||||||
|
* Labels are root-relative and forward-slashed so a hash map compares equal
|
||||||
|
* across platforms — the same directory read on Windows and Linux must produce
|
||||||
|
* the same fingerprint, or every boot would look like a change. (The same
|
||||||
|
* reasoning, and the same bug, as `spawnAtlasSource.readSources`.)
|
||||||
|
*/
|
||||||
|
function readSources(configured) {
|
||||||
|
const { root, base } = resolveBase(configured)
|
||||||
|
|
||||||
|
const describe = (file, kind) => {
|
||||||
|
const buffer = readFileOrThrow(file)
|
||||||
|
return {
|
||||||
|
label: path.relative(root, file).split(path.sep).join('/'),
|
||||||
|
kind,
|
||||||
|
file,
|
||||||
|
buffer,
|
||||||
|
sha256: sha256(buffer),
|
||||||
|
bytes: buffer.length,
|
||||||
|
compressed: isCompressedCliloc(buffer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [describe(base, 'base')]
|
||||||
|
for (const overlay of listCustom(root)) files.push(describe(overlay, 'custom'))
|
||||||
|
|
||||||
|
return { root, files }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fingerprint of every source: `{ "<label>": "<sha256>" }`, plus the base's
|
||||||
|
* details for the admin panel.
|
||||||
|
*
|
||||||
|
* `compressed` is reported here rather than left to the parse because the admin
|
||||||
|
* panel calls this and NOT `readCliloc` (parsing 5 MB on every status poll would
|
||||||
|
* be wasteful). Without it, pointing the setting at an unconverted client
|
||||||
|
* directory reports a perfectly readable file with pending drift — "ready to
|
||||||
|
* import" — and the operator only learns otherwise when the import fails. The
|
||||||
|
* check is four bytes of a buffer already in hand.
|
||||||
|
*/
|
||||||
|
function hashSources(configured) {
|
||||||
|
const { root, files } = readSources(configured)
|
||||||
|
const hashes = {}
|
||||||
|
for (const file of files) hashes[file.label] = file.sha256
|
||||||
|
const base = files[0]
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
hashes,
|
||||||
|
file: base.file,
|
||||||
|
bytes: base.bytes,
|
||||||
|
compressed: files.some((f) => f.compressed),
|
||||||
|
customCount: files.length - 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when two source fingerprints describe the same set of files. */
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Labels present in `loaded` that are absent from `current`.
|
||||||
|
*
|
||||||
|
* This is the multi-source hazard that a single file did not have. One corrupt
|
||||||
|
* file fails the parse loudly, but a source that has simply VANISHED — an
|
||||||
|
* unmounted volume, a half-copied deploy — parses perfectly and imports a table
|
||||||
|
* quietly missing everything that file contributed. That is the same ambiguity
|
||||||
|
* the spawn atlas escalates for a disappearing facet, so it is escalated here
|
||||||
|
* too rather than applied.
|
||||||
|
*/
|
||||||
|
function missingSources(current, loaded) {
|
||||||
|
if (!loaded) return []
|
||||||
|
return Object.keys(loaded).filter((label) => !Object.hasOwn(current, label))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse every source, merged into one entry list.
|
||||||
|
*
|
||||||
|
* Later sources win: the base client table first, then each overlay in sorted
|
||||||
|
* order, so an overlay both ADDS ids the client never had and OVERRIDES stock
|
||||||
|
* ones the shard has re-purposed.
|
||||||
|
*
|
||||||
|
* Returns `{ entries, source }`. Throws `ClilocSourceError` for anything about
|
||||||
|
* the paths and `ClilocFormatError` for anything about the contents — different
|
||||||
|
* problems for an operator (wrong place vs wrong file), and the admin panel says
|
||||||
|
* which. A format error names the file it came from, because "which of my six
|
||||||
|
* overlay files is malformed" is otherwise a guessing game.
|
||||||
|
*/
|
||||||
|
function readCliloc(configured) {
|
||||||
|
const { root, files } = readSources(configured)
|
||||||
|
|
||||||
|
const merged = new Map()
|
||||||
|
const perSource = []
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = parseCliloc(file.buffer)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ClilocFormatError) {
|
||||||
|
throw new ClilocFormatError(`${file.label}: ${err.message}`, err.code)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
let added = 0
|
||||||
|
let overrode = 0
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!Number.isInteger(entry.number)) continue
|
||||||
|
if (merged.has(entry.number)) overrode++
|
||||||
|
else added++
|
||||||
|
merged.set(entry.number, entry)
|
||||||
|
}
|
||||||
|
perSource.push({ label: file.label, kind: file.kind, entries: entries.length, added, overrode })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: [...merged.values()],
|
||||||
|
source: {
|
||||||
|
root,
|
||||||
|
file: files[0].file,
|
||||||
|
sha256: files[0].sha256,
|
||||||
|
bytes: files[0].bytes,
|
||||||
|
hashes: Object.fromEntries(files.map((f) => [f.label, f.sha256])),
|
||||||
|
parserVersion: PARSER_VERSION,
|
||||||
|
sources: perSource,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ClilocFormatError,
|
||||||
|
ClilocSourceError,
|
||||||
|
PARSER_VERSION,
|
||||||
|
CANDIDATE_NAMES,
|
||||||
|
CUSTOM_DIR,
|
||||||
|
CUSTOM_EXTENSIONS,
|
||||||
|
resolveBase,
|
||||||
|
listCustom,
|
||||||
|
readSources,
|
||||||
|
hashSources,
|
||||||
|
sameSources,
|
||||||
|
missingSources,
|
||||||
|
readCliloc,
|
||||||
|
}
|
||||||
187
server/src/utils/htmlShell.js
Normal file
187
server/src/utils/htmlShell.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
// The SPA's HTML shell: index.html templated with this instance's branding.
|
||||||
|
//
|
||||||
|
// This used to be a one-liner at module load in app.js — read the built
|
||||||
|
// index.html, template it from BRAND_* env, serve that one string forever. The
|
||||||
|
// admin-configurable brand assets (docs/website/THEMING_AND_NAV.md §4.3) make
|
||||||
|
// the favicon and OG image settings-driven, which is a lifecycle change rather
|
||||||
|
// than an `await`: the shell now depends on a row that can change while the
|
||||||
|
// process runs.
|
||||||
|
//
|
||||||
|
// Three properties this module exists to guarantee:
|
||||||
|
//
|
||||||
|
// • It is a cached string in the steady state. A settings read per page view
|
||||||
|
// would put the database on the critical path of every SPA route, including
|
||||||
|
// during an outage where the API is already degraded.
|
||||||
|
// • A DB fault never fails the page. A read error renders the env-only shell —
|
||||||
|
// exactly what the code did before this feature — and that fallback is
|
||||||
|
// cached like any other, so an outage cannot turn every page view into a
|
||||||
|
// failing query.
|
||||||
|
// • With no brand_assets and no theme_visual row it is BYTE-IDENTICAL to what
|
||||||
|
// app.js served before. That is an acceptance criterion of §9, and the
|
||||||
|
// reason the theme <style> block and the asset overrides are appended only
|
||||||
|
// when they exist rather than always emitted with default values.
|
||||||
|
//
|
||||||
|
// Invalidation is explicit — the settings controller calls invalidate() after a
|
||||||
|
// successful write to brand_assets or theme_visual — with a TTL as a safety net.
|
||||||
|
// The cache is per process: in a scaled deployment the process that handled the
|
||||||
|
// write is the only one that learns of it, so without the TTL every other worker
|
||||||
|
// would serve the old favicon until the next restart.
|
||||||
|
|
||||||
|
const brand = require('../config/brand')
|
||||||
|
|
||||||
|
// How long a rendered shell is trusted without an explicit invalidation. Short
|
||||||
|
// enough that a second process converges on its own, long enough that this is
|
||||||
|
// still one render per process per five minutes rather than one per request.
|
||||||
|
const TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
// A stored theme reaches the browser twice: in this block, and again as inline
|
||||||
|
// properties once the SPA has fetched /public/settings. The block exists purely
|
||||||
|
// so a themed instance does not paint the shipped palette for one frame first;
|
||||||
|
// the client drops it (by id) as soon as it has the authoritative payload — see
|
||||||
|
// contexts/SiteContext.jsx.
|
||||||
|
const THEME_STYLE_ID = 'theme-boot'
|
||||||
|
|
||||||
|
// Belt and braces over the theme validators. Every token name comes from a fixed
|
||||||
|
// map and every value from a closed set (hex color, curated font stack, bounded
|
||||||
|
// px, listed shadow), so nothing that reaches here can carry markup today. These
|
||||||
|
// two patterns make that a property of the HTML writer rather than of a validator
|
||||||
|
// three modules away that someone may one day loosen.
|
||||||
|
const SAFE_TOKEN_NAME = /^--[a-zA-Z0-9-_]+$/
|
||||||
|
const SAFE_TOKEN_VALUE = /^[a-zA-Z0-9 ,.()#%_'"/-]+$/
|
||||||
|
|
||||||
|
let template = null // the built index.html, read once
|
||||||
|
let cached = null // { html, at }
|
||||||
|
let inflight = null // de-dupes a burst of requests on a cold cache
|
||||||
|
let generation = 0 // bumped by invalidate(); an in-flight render checks it
|
||||||
|
|
||||||
|
// Escape user/brand text for safe interpolation into the HTML shell.
|
||||||
|
function htmlEscape(s) {
|
||||||
|
return String(s).replace(
|
||||||
|
/[&<>"']/g,
|
||||||
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An uploaded asset path is always relative (`/uploads/…`), but og:image is read
|
||||||
|
* off-site by scrapers that handle a relative URL poorly. Absolutize it against
|
||||||
|
* BRAND_URL when we have one.
|
||||||
|
*
|
||||||
|
* Env values pass through untouched even when relative: the shell an instance
|
||||||
|
* gets today is the operator's choice and must not change just because this
|
||||||
|
* module now exists.
|
||||||
|
*/
|
||||||
|
function absolutize(url) {
|
||||||
|
if (!brand.url || !url.startsWith('/')) return url
|
||||||
|
return `${brand.url.replace(/\/+$/, '')}${url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the shell. Pure — every input is a parameter, so a test can assert the
|
||||||
|
* byte-identical property without a database.
|
||||||
|
*
|
||||||
|
* @param {string} html the built index.html
|
||||||
|
* @param {{logo?: string, favicon?: string, theme?: object|null}} [overrides]
|
||||||
|
* effective brand assets and theme; anything absent falls back to BRAND_* env
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function render(html, overrides = {}) {
|
||||||
|
const title = htmlEscape(brand.name)
|
||||||
|
const desc = htmlEscape(brand.description)
|
||||||
|
// Effective values: an uploaded override wins over env, absence means env.
|
||||||
|
const logo = overrides.logo ? absolutize(overrides.logo) : brand.logo
|
||||||
|
const favicon = overrides.favicon || brand.favicon
|
||||||
|
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)}" />` : '',
|
||||||
|
logo ? `<meta property="og:image" content="${htmlEscape(logo)}" />` : '',
|
||||||
|
'<meta name="twitter:card" content="summary_large_image" />',
|
||||||
|
`<meta name="twitter:title" content="${title}" />`,
|
||||||
|
`<meta name="twitter:description" content="${desc}" />`,
|
||||||
|
favicon ? `<link rel="icon" href="${htmlEscape(favicon)}" />` : '',
|
||||||
|
themeStyleTag(overrides.theme),
|
||||||
|
]
|
||||||
|
.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>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin theme as a :root block, or '' when this instance has never been
|
||||||
|
// themed. Injected last in <head> so it follows the built stylesheet and wins
|
||||||
|
// the equal-specificity tie against theme.css's own :root.
|
||||||
|
function themeStyleTag(theme) {
|
||||||
|
if (!theme || typeof theme !== 'object') return ''
|
||||||
|
const decls = Object.entries(theme)
|
||||||
|
.filter(([name, value]) => SAFE_TOKEN_NAME.test(name) && typeof value === 'string' && SAFE_TOKEN_VALUE.test(value))
|
||||||
|
.map(([name, value]) => `${name}:${value}`)
|
||||||
|
.join(';')
|
||||||
|
return decls ? `<style id="${THEME_STYLE_ID}">:root{${decls}}</style>` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide the built index.html. Called once at boot by app.js; a separate step
|
||||||
|
* from get() so the file read stays synchronous and startup still fails loudly
|
||||||
|
* if the client build is unreadable.
|
||||||
|
*/
|
||||||
|
function init(html) {
|
||||||
|
template = html
|
||||||
|
cached = null
|
||||||
|
inflight = null
|
||||||
|
generation += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the cached shell. Called after any write that can change it. */
|
||||||
|
function invalidate() {
|
||||||
|
cached = null
|
||||||
|
inflight = null
|
||||||
|
generation += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current shell. Renders on a cold or expired cache, otherwise returns the
|
||||||
|
* cached string. Never rejects: a settings read that fails yields the env-only
|
||||||
|
* shell.
|
||||||
|
*
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function get() {
|
||||||
|
if (template === null) throw new Error('htmlShell.init() was never called')
|
||||||
|
if (cached && Date.now() - cached.at < TTL_MS) return cached.html
|
||||||
|
if (inflight) return inflight
|
||||||
|
|
||||||
|
const startedAt = generation
|
||||||
|
const run = (async () => {
|
||||||
|
let overrides = {}
|
||||||
|
try {
|
||||||
|
// Required lazily: this module is loaded by app.js at boot, and the
|
||||||
|
// settings model pulls in the DB pool. Requiring it at the top would make
|
||||||
|
// the HTML shell a startup-time dependency of the database.
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const settings = require('../model/settings/settings.model')
|
||||||
|
overrides = await settings.getShellBrand()
|
||||||
|
} catch {
|
||||||
|
// A DB fault must never fail the page (§4.3). Fall back to the env-only
|
||||||
|
// shell — the pre-feature behaviour — and cache it, so an outage does not
|
||||||
|
// mean a failing query per page view.
|
||||||
|
overrides = {}
|
||||||
|
}
|
||||||
|
const html = render(template, overrides)
|
||||||
|
// An invalidation that landed while this read was in flight means the value
|
||||||
|
// we just read may already be stale. Serve it, but do not cache it.
|
||||||
|
if (generation === startedAt) cached = { html, at: Date.now() }
|
||||||
|
// Only retire our own registration: an invalidation during the read may have
|
||||||
|
// already started a newer render, and clearing that one would cost an extra
|
||||||
|
// render on the next request.
|
||||||
|
if (inflight === run) inflight = null
|
||||||
|
return html
|
||||||
|
})()
|
||||||
|
inflight = run
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, get, invalidate, render, TTL_MS, THEME_STYLE_ID }
|
||||||
336
server/src/utils/navOverrides.js
Normal file
336
server/src/utils/navOverrides.js
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
// Navigation overrides — the `nav_public` / `nav_admin` / `nav_player` rows.
|
||||||
|
//
|
||||||
|
// { "/site/news": { "label": "Announcements", "order": 2 },
|
||||||
|
// "/site/market": { "hidden": true },
|
||||||
|
// "/admin/houses": { "order": 1, "group": "Moderation" } }
|
||||||
|
//
|
||||||
|
// Keyed by an item's existing `to`; every field is optional and an absent one
|
||||||
|
// falls back to the code default (docs/website/THEMING_AND_NAV.md §6.4). The
|
||||||
|
// merge itself happens on the client — client/src/lib/navOverrides.js — and the
|
||||||
|
// role/feature filters in the layouts run *after* it, so this layer is
|
||||||
|
// presentation and never authorization (§7).
|
||||||
|
//
|
||||||
|
// **What this module cannot check, deliberately: whether a `to` exists.** The
|
||||||
|
// three base NAV arrays are client constants (SiteHeader.jsx, AdminLayout.jsx,
|
||||||
|
// PlayerPortalLayout.jsx). Shipping a copy of them to the server would create a
|
||||||
|
// second source of truth for navigation that drifts the first time a route is
|
||||||
|
// added, and it would buy nothing: `applyNavOverrides` already drops an entry
|
||||||
|
// whose `to` the base array does not declare, which is the right place for it —
|
||||||
|
// deleting a route in code stops mattering immediately, with no migration and no
|
||||||
|
// stale row doing something unexpected later. So the server validates *shape*
|
||||||
|
// and the client owns *membership*.
|
||||||
|
//
|
||||||
|
// Same strict-on-write / forgiving-on-read asymmetry as the theme and the brand
|
||||||
|
// assets (utils/themeResolve.js, utils/brandAssets.js): a bad write is rejected
|
||||||
|
// with the offending key named, while a bad stored value is dropped entry by
|
||||||
|
// entry so one hand-edited row does not cost the admin the rest of their nav.
|
||||||
|
|
||||||
|
// The overridable fields on a CODED item. `group` is only meaningful on the
|
||||||
|
// grouped admin nav and `section` only on the public header, but accepting both
|
||||||
|
// everywhere costs nothing — the merge util drops a group the base nav does not
|
||||||
|
// declare, and a section id no `sections` entry declares.
|
||||||
|
const FIELDS = ['label', 'order', 'hidden', 'group', 'section']
|
||||||
|
|
||||||
|
// Bounds. None of these is a security control on its own — the row is written by
|
||||||
|
// an admin and rendered as text by React — they keep a single settings row from
|
||||||
|
// growing without limit, and they are what makes "the admin nav has 21 items"
|
||||||
|
// the shape this store is sized for.
|
||||||
|
const MAX_ENTRIES = 200
|
||||||
|
const MAX_PATH = 128
|
||||||
|
const MAX_LABEL = 64
|
||||||
|
const MAX_GROUP = 64
|
||||||
|
const MAX_SECTIONS = 12
|
||||||
|
const MAX_LINKS = 40
|
||||||
|
|
||||||
|
// Only the public header supports admin-created dropdown sections and
|
||||||
|
// admin-authored links (THEMING_AND_NAV.md §7, Phase 10). The admin sidebar has
|
||||||
|
// its own coded sections and the player portal is three flat rows, so both keep
|
||||||
|
// the bare items map; `sections`/`links` are dropped for them rather than
|
||||||
|
// rejected, the same posture as every other unusable field here.
|
||||||
|
const SECTIONED_KEYS = ['nav_public']
|
||||||
|
|
||||||
|
// Generated by the editor, never typed. Constrained so a stored id is safe to
|
||||||
|
// use as a React key and as a DOM id fragment without further escaping.
|
||||||
|
const SECTION_ID = /^sec_[a-z0-9]{4,16}$/
|
||||||
|
const LINK_ID = /^lnk_[a-z0-9]{4,16}$/
|
||||||
|
|
||||||
|
// The one item an override may never hide: the nav editor itself. An admin who
|
||||||
|
// hid it would lose the only screen that can un-hide it, and "type the URL from
|
||||||
|
// memory" is not a recovery path. Enforced here as well as in the editor's UI so
|
||||||
|
// a hand-written row cannot do it either.
|
||||||
|
const UNHIDEABLE = { nav_admin: ['/admin/navigation'] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this a usable key — that is, something that could be a `to` in a nav array?
|
||||||
|
* An app-internal path: absolute, same-origin, no scheme and no whitespace.
|
||||||
|
* Whether it *is* one of the declared routes is the client's question (above).
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isNavPath(value) {
|
||||||
|
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PATH) return false
|
||||||
|
if (!value.startsWith('/')) return false
|
||||||
|
// `//host` is protocol-relative and would leave the origin despite looking
|
||||||
|
// like a path; whitespace and quotes have no business in a route.
|
||||||
|
if (value.startsWith('//') || /[\s<>"'\\]/.test(value)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a stored value into its three parts.
|
||||||
|
*
|
||||||
|
* The public header grew dropdown sections in Phase 10, so `nav_public` may be
|
||||||
|
* a wrapper — `{ items, sections, links }` — while the other two navs stay the
|
||||||
|
* bare items map phases 6-8 wrote. **A bare map is still read as the items
|
||||||
|
* map**, which is unambiguous because every item key is a path beginning with
|
||||||
|
* `/` and so can never be the string `items`.
|
||||||
|
*
|
||||||
|
* @param {object} value a parsed, non-array object
|
||||||
|
* @returns {{items: object, sections: unknown, links: unknown, wrapped: boolean}}
|
||||||
|
*/
|
||||||
|
function unwrap(value) {
|
||||||
|
const wrapped = value.items && typeof value.items === 'object' && !Array.isArray(value.items)
|
||||||
|
if (!wrapped) return { items: value, sections: undefined, links: undefined, wrapped: false }
|
||||||
|
return { items: value.items, sections: value.sections, links: value.links, wrapped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A section is a dropdown an admin created: a label and a position, no route.
|
||||||
|
// It is never itself a link — it only opens — so there is no `to` to validate.
|
||||||
|
function validateSections(sections, key) {
|
||||||
|
if (sections === undefined || sections === null) return { ok: true }
|
||||||
|
if (!Array.isArray(sections)) return { ok: false, message: `${key}.sections must be an array` }
|
||||||
|
if (sections.length > MAX_SECTIONS) {
|
||||||
|
return { ok: false, message: `${key} may hold at most ${MAX_SECTIONS} sections` }
|
||||||
|
}
|
||||||
|
const seen = new Set()
|
||||||
|
for (const section of sections) {
|
||||||
|
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
||||||
|
return { ok: false, message: `${key}.sections entries must be objects` }
|
||||||
|
}
|
||||||
|
if (typeof section.id !== 'string' || !SECTION_ID.test(section.id)) {
|
||||||
|
return { ok: false, message: `${key}.sections has an entry with an invalid id` }
|
||||||
|
}
|
||||||
|
if (seen.has(section.id)) {
|
||||||
|
return { ok: false, message: `${key}.sections has a duplicate id '${section.id}'` }
|
||||||
|
}
|
||||||
|
seen.add(section.id)
|
||||||
|
if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) {
|
||||||
|
return { ok: false, message: `${key}.sections['${section.id}'].label must be text of at most ${MAX_LABEL} characters` }
|
||||||
|
}
|
||||||
|
if (section.order !== undefined && (typeof section.order !== 'number' || !Number.isFinite(section.order))) {
|
||||||
|
return { ok: false, message: `${key}.sections['${section.id}'].order must be a number` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link is the one thing an admin may ADD to a nav, and the only place a `to`
|
||||||
|
// is not required to already exist in code. It is kept in its own array rather
|
||||||
|
// than in `items` on purpose: `items` may only key routes the base array
|
||||||
|
// declares, so an override structurally cannot invent a route, and everything
|
||||||
|
// that CAN name an arbitrary path is here where the path rule is applied.
|
||||||
|
//
|
||||||
|
// A link carries no `roles` or `feature` of its own. It does not need one: the
|
||||||
|
// page behind it enforces its own access, so a link to somewhere the viewer
|
||||||
|
// cannot reach 403s exactly as typing the URL would (§7).
|
||||||
|
function validateLinks(links, key) {
|
||||||
|
if (links === undefined || links === null) return { ok: true }
|
||||||
|
if (!Array.isArray(links)) return { ok: false, message: `${key}.links must be an array` }
|
||||||
|
if (links.length > MAX_LINKS) {
|
||||||
|
return { ok: false, message: `${key} may hold at most ${MAX_LINKS} added links` }
|
||||||
|
}
|
||||||
|
const seen = new Set()
|
||||||
|
for (const link of links) {
|
||||||
|
if (!link || typeof link !== 'object' || Array.isArray(link)) {
|
||||||
|
return { ok: false, message: `${key}.links entries must be objects` }
|
||||||
|
}
|
||||||
|
if (typeof link.id !== 'string' || !LINK_ID.test(link.id)) {
|
||||||
|
return { ok: false, message: `${key}.links has an entry with an invalid id` }
|
||||||
|
}
|
||||||
|
if (seen.has(link.id)) {
|
||||||
|
return { ok: false, message: `${key}.links has a duplicate id '${link.id}'` }
|
||||||
|
}
|
||||||
|
seen.add(link.id)
|
||||||
|
if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) {
|
||||||
|
return { ok: false, message: `${key}.links['${link.id}'].label must be text of at most ${MAX_LABEL} characters` }
|
||||||
|
}
|
||||||
|
// The whole point of the restriction: an added link points somewhere on this
|
||||||
|
// site. No scheme, no `//host` — the nav is not a place to send visitors off
|
||||||
|
// to an origin the operator does not control.
|
||||||
|
if (!isNavPath(link.to)) {
|
||||||
|
return { ok: false, message: `${key}.links['${link.id}'].to must be a path on this site, such as /wiki/new-player-guide` }
|
||||||
|
}
|
||||||
|
if (link.order !== undefined && (typeof link.order !== 'number' || !Number.isFinite(link.order))) {
|
||||||
|
return { ok: false, message: `${key}.links['${link.id}'].order must be a number` }
|
||||||
|
}
|
||||||
|
if (link.section !== undefined && link.section !== null && typeof link.section !== 'string') {
|
||||||
|
return { ok: false, message: `${key}.links['${link.id}'].section must be a section id` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a nav-override object for WRITING. Strict: names the offending key.
|
||||||
|
* @param {unknown} value the parsed object, or null to clear every override
|
||||||
|
* @param {string} [key] which nav row this is, for the messages
|
||||||
|
* @returns {{ok: true} | {ok: false, message: string}}
|
||||||
|
*/
|
||||||
|
function validateNavOverrides(value, key = 'nav') {
|
||||||
|
if (value === null || value === undefined) return { ok: true }
|
||||||
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return { ok: false, message: `${key} must be a JSON object` }
|
||||||
|
}
|
||||||
|
const { items, sections, links } = unwrap(value)
|
||||||
|
if (!items || typeof items !== 'object' || Array.isArray(items)) {
|
||||||
|
return { ok: false, message: `${key}.items must be a JSON object` }
|
||||||
|
}
|
||||||
|
const sectionCheck = validateSections(sections, key)
|
||||||
|
if (!sectionCheck.ok) return sectionCheck
|
||||||
|
const linkCheck = validateLinks(links, key)
|
||||||
|
if (!linkCheck.ok) return linkCheck
|
||||||
|
|
||||||
|
const entries = Object.entries(items)
|
||||||
|
if (entries.length > MAX_ENTRIES) {
|
||||||
|
return { ok: false, message: `${key} may hold at most ${MAX_ENTRIES} entries` }
|
||||||
|
}
|
||||||
|
for (const [to, entry] of entries) {
|
||||||
|
if (!isNavPath(to)) {
|
||||||
|
return { ok: false, message: `${key} key '${to}' must be an app path such as /site/news` }
|
||||||
|
}
|
||||||
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||||
|
return { ok: false, message: `${key}['${to}'] must be an object` }
|
||||||
|
}
|
||||||
|
for (const [field, fieldValue] of Object.entries(entry)) {
|
||||||
|
if (!FIELDS.includes(field)) {
|
||||||
|
return { ok: false, message: `Unknown nav field '${field}' on ${key}['${to}']` }
|
||||||
|
}
|
||||||
|
if (field === 'label' && (typeof fieldValue !== 'string' || fieldValue.length > MAX_LABEL)) {
|
||||||
|
return { ok: false, message: `${key}['${to}'].label must be text of at most ${MAX_LABEL} characters` }
|
||||||
|
}
|
||||||
|
if (field === 'group' && (typeof fieldValue !== 'string' || fieldValue.length > MAX_GROUP)) {
|
||||||
|
return { ok: false, message: `${key}['${to}'].group must be text of at most ${MAX_GROUP} characters` }
|
||||||
|
}
|
||||||
|
if (field === 'order' && (typeof fieldValue !== 'number' || !Number.isFinite(fieldValue))) {
|
||||||
|
return { ok: false, message: `${key}['${to}'].order must be a number` }
|
||||||
|
}
|
||||||
|
if (field === 'section' && fieldValue !== null && typeof fieldValue !== 'string') {
|
||||||
|
return { ok: false, message: `${key}['${to}'].section must be a section id` }
|
||||||
|
}
|
||||||
|
// `hidden: false` is not an error — it is simply the default, and the
|
||||||
|
// editor sends it while a row is being edited. It is dropped below, never
|
||||||
|
// stored, because hiding is subtractive only (§7): a stored `false` could
|
||||||
|
// read as "force visible" to a later reader, and nothing may un-hide.
|
||||||
|
if (field === 'hidden' && typeof fieldValue !== 'boolean') {
|
||||||
|
return { ok: false, message: `${key}['${to}'].hidden must be true or false` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only the entries and fields that would actually do something. Serves both
|
||||||
|
* directions, like resolveBrandAssets:
|
||||||
|
*
|
||||||
|
* • writing — an admin who cleared every override stores nothing, and the
|
||||||
|
* caller deletes the row instead, so "a row exists" keeps meaning "this nav
|
||||||
|
* was customised" (§4.1);
|
||||||
|
* • reading — a hand-edited entry is dropped and its neighbours kept.
|
||||||
|
*
|
||||||
|
* Sections and added links are honored only for the navs that can render them
|
||||||
|
* (`nav_public`), and a `section` naming no surviving section falls back to the
|
||||||
|
* top level rather than stranding the item in a dropdown that is not there.
|
||||||
|
*
|
||||||
|
* The return shape mirrors the input: a nav with no sections and no added links
|
||||||
|
* resolves to the bare items map phases 6-8 wrote, so adding this feature
|
||||||
|
* changed nothing at all for a nav that does not use it.
|
||||||
|
*
|
||||||
|
* @param {object|null} value an object, or a parseJsonSetting result
|
||||||
|
* @param {string} [key] the settings key, so the un-hideable rule can apply
|
||||||
|
* @returns {object} a new object, `{}` when nothing survives
|
||||||
|
*/
|
||||||
|
function resolveNavOverrides(value, key = 'nav') {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||||
|
const { items, sections, links } = unwrap(value)
|
||||||
|
if (!items || typeof items !== 'object' || Array.isArray(items)) return {}
|
||||||
|
|
||||||
|
const sectioned = SECTIONED_KEYS.includes(key)
|
||||||
|
const cleanSections = sectioned ? resolveSections(sections) : []
|
||||||
|
const known = new Set(cleanSections.map((s) => s.id))
|
||||||
|
const cleanLinks = sectioned ? resolveLinks(links, known) : []
|
||||||
|
|
||||||
|
const out = resolveItems(items, key, known)
|
||||||
|
if (cleanSections.length === 0 && cleanLinks.length === 0) return out
|
||||||
|
// A section with nothing in it renders as an empty dropdown, so an admin who
|
||||||
|
// emptied one has simply stopped using it — but it is theirs to keep until
|
||||||
|
// they delete it, and the editor is where that happens. Kept here; the
|
||||||
|
// renderer drops it (client/src/lib/navOverrides.js pruneNav).
|
||||||
|
const wrapper = { items: out }
|
||||||
|
if (cleanSections.length) wrapper.sections = cleanSections
|
||||||
|
if (cleanLinks.length) wrapper.links = cleanLinks
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSections(sections) {
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
if (!Array.isArray(sections)) return out
|
||||||
|
for (const section of sections.slice(0, MAX_SECTIONS)) {
|
||||||
|
if (!section || typeof section !== 'object' || Array.isArray(section)) continue
|
||||||
|
if (typeof section.id !== 'string' || !SECTION_ID.test(section.id) || seen.has(section.id)) continue
|
||||||
|
if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) continue
|
||||||
|
seen.add(section.id)
|
||||||
|
const clean = { id: section.id, label: section.label.trim() }
|
||||||
|
if (typeof section.order === 'number' && Number.isFinite(section.order)) clean.order = section.order
|
||||||
|
out.push(clean)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLinks(links, knownSections) {
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
if (!Array.isArray(links)) return out
|
||||||
|
for (const link of links.slice(0, MAX_LINKS)) {
|
||||||
|
if (!link || typeof link !== 'object' || Array.isArray(link)) continue
|
||||||
|
if (typeof link.id !== 'string' || !LINK_ID.test(link.id) || seen.has(link.id)) continue
|
||||||
|
if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) continue
|
||||||
|
if (!isNavPath(link.to)) continue
|
||||||
|
seen.add(link.id)
|
||||||
|
const clean = { id: link.id, label: link.label.trim(), to: link.to }
|
||||||
|
if (typeof link.order === 'number' && Number.isFinite(link.order)) clean.order = link.order
|
||||||
|
if (typeof link.section === 'string' && knownSections.has(link.section)) clean.section = link.section
|
||||||
|
out.push(clean)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveItems(items, key, knownSections) {
|
||||||
|
const out = {}
|
||||||
|
const unhideable = UNHIDEABLE[key] || []
|
||||||
|
for (const [to, entry] of Object.entries(items)) {
|
||||||
|
if (!isNavPath(to) || !entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
||||||
|
const clean = {}
|
||||||
|
// A label that is only whitespace is not a label — it would render an
|
||||||
|
// unclickable-looking gap — so it falls back to the coded one.
|
||||||
|
if (typeof entry.label === 'string' && entry.label.trim() && entry.label.length <= MAX_LABEL) {
|
||||||
|
clean.label = entry.label.trim()
|
||||||
|
}
|
||||||
|
if (typeof entry.order === 'number' && Number.isFinite(entry.order)) clean.order = entry.order
|
||||||
|
// Only the literal `true` is stored: `hidden: false` is the default and
|
||||||
|
// carrying it would suggest an override that can un-hide something.
|
||||||
|
if (entry.hidden === true && !unhideable.includes(to)) clean.hidden = true
|
||||||
|
if (typeof entry.group === 'string' && entry.group.trim() && entry.group.length <= MAX_GROUP) {
|
||||||
|
clean.group = entry.group.trim()
|
||||||
|
}
|
||||||
|
// Only a section that survived resolution: an item pointing at a deleted or
|
||||||
|
// malformed one belongs at the top level, visible, rather than inside a
|
||||||
|
// dropdown that no longer exists.
|
||||||
|
if (typeof entry.section === 'string' && knownSections.has(entry.section)) clean.section = entry.section
|
||||||
|
if (Object.keys(clean).length > 0) out[to] = clean
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateNavOverrides, resolveNavOverrides, NAV_KEYS: ['nav_public', 'nav_admin', 'nav_player'] }
|
||||||
35
server/src/utils/settingsJson.js
Normal file
35
server/src/utils/settingsJson.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Parse a JSON-valued settings row.
|
||||||
|
//
|
||||||
|
// `settings.value` is TEXT (db/schema.sql), so every JSON-shaped key —
|
||||||
|
// hero_layout, and now theme_visual / brand_assets / nav_* — is stored
|
||||||
|
// stringified and arrives as a string. Consumers must parse it, and the parse
|
||||||
|
// has to be fail-safe: a malformed or wrong-shaped value is treated as
|
||||||
|
// **absent** (the surface falls back to its BRAND_* env / theme.css / NAV
|
||||||
|
// default), never as an error and never as a half-applied object. That is the
|
||||||
|
// same posture parseLayout already takes on the client
|
||||||
|
// (client/src/lib/heroLayout.js).
|
||||||
|
//
|
||||||
|
// See docs/website/THEMING_AND_NAV.md §4.4.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|null|undefined} str the raw stored value
|
||||||
|
* @param {(value: unknown) => boolean} [validator] shape check; anything it
|
||||||
|
* rejects is treated as absent
|
||||||
|
* @returns {object|null} the parsed object, or null when absent/malformed
|
||||||
|
*/
|
||||||
|
function parseJsonSetting(str, validator) {
|
||||||
|
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
|
||||||
|
if (validator && !validator(parsed)) return null
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseJsonSetting }
|
||||||
@@ -2,67 +2,66 @@
|
|||||||
//
|
//
|
||||||
// The browser can't talk to the sidecar's WebSocket directly (the token must
|
// The browser can't talk to the sidecar's WebSocket directly (the token must
|
||||||
// never reach it, and the WS may be on another host). Instead the server ingests
|
// never reach it, and the WS may be on another host). Instead the server ingests
|
||||||
// the WS feed and re-broadcasts curated events to browsers over Server-Sent
|
// the WS feed and re-broadcasts events to browsers over Server-Sent Events
|
||||||
// Events (plain HTTP — works through any reverse proxy).
|
// (plain HTTP — works through any reverse proxy).
|
||||||
//
|
//
|
||||||
// Two channels:
|
// Since Protocol 3.0 the split is no longer "one public channel with a static
|
||||||
// • public — safe kinds only (sales, deaths, IDOC, logins, economy). No IPs,
|
// allowlist plus one admin channel". Each subscriber carries the audience rung
|
||||||
// no account-login attempts, no staff audit / cheat events.
|
// it resolved to at subscribe time, and every frame is
|
||||||
// • admin — everything, including the sensitive kinds above.
|
//
|
||||||
|
// 1. mapped kind → feature (an UNMAPPED kind reaches nobody below admin —
|
||||||
|
// fail closed; see utils/shardVisibility.js rule 2),
|
||||||
|
// 2. gated on that feature being enabled, streamed, and within the viewer's
|
||||||
|
// rung, and
|
||||||
|
// 3. passed through field projection, so `acct` / `webId` and any field an
|
||||||
|
// admin has re-gated are stripped per viewer.
|
||||||
|
//
|
||||||
|
// **This is the security boundary.** It used to be the PUBLIC_KINDS set in this
|
||||||
|
// file; it is now the kind map plus the visibility config. PUBLIC_KINDS still
|
||||||
|
// exists and is still exported, but it is now DERIVED from the kind map (see
|
||||||
|
// shardVisibility.js) so the two can no longer drift.
|
||||||
//
|
//
|
||||||
// shardIngest calls broadcast(event) for each ingested event; the public/admin
|
// shardIngest calls broadcast(event) for each ingested event; the public/admin
|
||||||
// SSE route handlers call subscribe(req, res, channel).
|
// SSE route handlers call subscribe(req, res, channel).
|
||||||
|
|
||||||
|
const visibility = require('./shardVisibility')
|
||||||
const log = require('./logger')('shard-broadcast')
|
const log = require('./logger')('shard-broadcast')
|
||||||
|
|
||||||
// Kinds safe to expose to unauthenticated browsers. Note: vendor.sale is
|
// Re-exported for back-compat: shardEvents `/feed` filtering and
|
||||||
// deliberately NOT here — sales are owner-private (a linked player sees only
|
// config/notificationStreams.js both ask "is this kind public-safe?".
|
||||||
// their own, via /player/shard/sales).
|
const { PUBLIC_KINDS } = visibility
|
||||||
const PUBLIC_KINDS = new Set([
|
|
||||||
'player.death',
|
|
||||||
'player.murdered',
|
|
||||||
'mob.killed',
|
|
||||||
'house.decay',
|
|
||||||
'quest.complete',
|
|
||||||
'skill.gain',
|
|
||||||
'fame.change',
|
|
||||||
'karma.change',
|
|
||||||
'mob.login',
|
|
||||||
'mob.logout',
|
|
||||||
'economy.supply',
|
|
||||||
'server.hello',
|
|
||||||
'server.shutdown',
|
|
||||||
'server.crashed',
|
|
||||||
// Champion-spawn board deltas — the public Champions page renders these live.
|
|
||||||
'champ.update',
|
|
||||||
'champ.remove',
|
|
||||||
// Protocol 2.0 boards — public, rendered live on their respective pages.
|
|
||||||
'guild.update',
|
|
||||||
'guild.remove',
|
|
||||||
'guild.join',
|
|
||||||
'city.update',
|
|
||||||
'presence.online',
|
|
||||||
'region.enter',
|
|
||||||
// NOTE: house.update / house.remove (the full registry — owner, price, co-owners)
|
|
||||||
// are deliberately NOT public. The public Houses page shows only IDOC houses (via
|
|
||||||
// house.decay, which is public above) with location only; the full registry is
|
|
||||||
// staff-only and rides the admin SSE channel. See public/shard.controller getHouses.
|
|
||||||
])
|
|
||||||
|
|
||||||
// Open response streams per channel.
|
// Open streams. Each entry is { res, level }. The admin bucket is kept separate
|
||||||
|
// because it is unconditional and must not depend on a config read.
|
||||||
const clients = { public: new Set(), admin: new Set() }
|
const clients = { public: new Set(), admin: new Set() }
|
||||||
|
|
||||||
const KEEPALIVE_MS = 25000
|
const KEEPALIVE_MS = 25000
|
||||||
|
|
||||||
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
|
// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
|
||||||
// comment, keeps the connection warm with periodic pings, and cleans up on close.
|
// comment, keeps the connection warm with periodic pings, and cleans up on close.
|
||||||
function subscribe(req, res, channel) {
|
//
|
||||||
|
// The viewer's rung is resolved ONCE, here, and frozen for the life of the
|
||||||
|
// connection — a long-lived stream must not silently gain privilege because the
|
||||||
|
// caller's session changed underneath it. (Config changes, by contrast, DO take
|
||||||
|
// effect live: the config is read per broadcast, cached ~5s.)
|
||||||
|
async function subscribe(req, res, channel) {
|
||||||
const bucket = clients[channel]
|
const bucket = clients[channel]
|
||||||
if (!bucket) {
|
if (!bucket) {
|
||||||
res.status(400).end()
|
res.status(400).end()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let level = 'admin'
|
||||||
|
if (channel === 'public') {
|
||||||
|
try {
|
||||||
|
level = await visibility.viewerLevel(req)
|
||||||
|
} catch (err) {
|
||||||
|
// Fail closed: an unresolvable viewer is anonymous, not privileged.
|
||||||
|
log.warn('viewerLevel failed on subscribe; treating as anonymous', { message: err.message })
|
||||||
|
level = 'anonymous'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
@@ -72,9 +71,10 @@ function subscribe(req, res, channel) {
|
|||||||
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
|
res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
|
||||||
res.write(': connected\n\n')
|
res.write(': connected\n\n')
|
||||||
|
|
||||||
bucket.add(res)
|
const client = { res, level, ping: null }
|
||||||
|
bucket.add(client)
|
||||||
|
|
||||||
const ping = setInterval(() => {
|
client.ping = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
res.write(': ping\n\n')
|
res.write(': ping\n\n')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -82,45 +82,80 @@ function subscribe(req, res, channel) {
|
|||||||
}
|
}
|
||||||
}, KEEPALIVE_MS)
|
}, KEEPALIVE_MS)
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => drop(bucket, client)
|
||||||
clearInterval(ping)
|
|
||||||
bucket.delete(res)
|
|
||||||
}
|
|
||||||
req.on('close', cleanup)
|
req.on('close', cleanup)
|
||||||
res.on('error', cleanup)
|
res.on('error', cleanup)
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeTo(bucket, payload) {
|
// The ONLY way a client leaves a bucket. Clearing the keepalive here (rather
|
||||||
for (const res of bucket) {
|
// than only in the close handler) matters: a client dropped because its write
|
||||||
try {
|
// threw never fires `req.close`, so its interval would otherwise keep firing on
|
||||||
res.write(payload)
|
// a dead socket for the life of the process.
|
||||||
} catch (err) {
|
function drop(bucket, client) {
|
||||||
log.warn('sse write failed; dropping client', { message: err.message })
|
clearInterval(client.ping)
|
||||||
bucket.delete(res)
|
bucket.delete(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeTo(bucket, client, payload) {
|
||||||
|
try {
|
||||||
|
client.res.write(payload)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('sse write failed; dropping client', { message: err.message })
|
||||||
|
drop(bucket, client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fan an ingested event out to the admin channel (always) and the public
|
// Fan an ingested event out. The admin channel gets it verbatim, always. Public
|
||||||
// channel (safe kinds only). A no-op when nobody is subscribed.
|
// subscribers are filtered and projected per their own rung — so two viewers on
|
||||||
function broadcast(event) {
|
// the same channel can legitimately receive different versions of one frame, or
|
||||||
|
// one of them nothing at all.
|
||||||
|
async function broadcast(event) {
|
||||||
if (!event || !event.kind) return
|
if (!event || !event.kind) return
|
||||||
const frame = `data: ${JSON.stringify(event)}\n\n`
|
|
||||||
if (clients.admin.size) writeTo(clients.admin, frame)
|
if (clients.admin.size) {
|
||||||
if (clients.public.size && PUBLIC_KINDS.has(event.kind)) writeTo(clients.public, frame)
|
const frame = `data: ${JSON.stringify(event)}\n\n`
|
||||||
|
for (const client of [...clients.admin]) writeTo(clients.admin, client, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clients.public.size) return
|
||||||
|
|
||||||
|
let config
|
||||||
|
try {
|
||||||
|
config = await visibility.getConfig()
|
||||||
|
} catch (err) {
|
||||||
|
// Fail closed: without a config we cannot prove a frame is safe to send.
|
||||||
|
log.error('visibility config unavailable; withholding public frame', err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most frames land on one rung set, so cache the serialised payload per level
|
||||||
|
// instead of re-projecting and re-stringifying for every subscriber.
|
||||||
|
const byLevel = new Map()
|
||||||
|
for (const client of [...clients.public]) {
|
||||||
|
let frame = byLevel.get(client.level)
|
||||||
|
if (frame === undefined) {
|
||||||
|
frame = visibility.kindVisibleTo(event.kind, client.level, config)
|
||||||
|
? `data: ${JSON.stringify(visibility.projectFeature(visibility.KIND_FEATURE.get(event.kind), event, client.level, config))}\n\n`
|
||||||
|
: null
|
||||||
|
byLevel.set(client.level, frame)
|
||||||
|
}
|
||||||
|
if (frame) writeTo(clients.public, client, frame)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close every open stream (graceful shutdown).
|
// Close every open stream (graceful shutdown). Clears each keepalive timer too —
|
||||||
|
// without that the intervals keep the event loop alive after the streams are
|
||||||
|
// gone, and the process won't exit.
|
||||||
function closeAll() {
|
function closeAll() {
|
||||||
for (const channel of Object.values(clients)) {
|
for (const bucket of Object.values(clients)) {
|
||||||
for (const res of channel) {
|
for (const client of [...bucket]) {
|
||||||
|
drop(bucket, client)
|
||||||
try {
|
try {
|
||||||
res.end()
|
client.res.end()
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
channel.clear()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
|
const shardEventsModel = require('../model/shardEvents/shardEvents.model')
|
||||||
const shardStateModel = require('../model/shardState/shardState.model')
|
const shardStateModel = require('../model/shardState/shardState.model')
|
||||||
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||||
|
const shardMarketModel = require('../model/shardMarket/shardMarket.model')
|
||||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const settingsModel = require('../model/settings/settings.model')
|
||||||
const broadcaster = require('./shardBroadcast')
|
const broadcaster = require('./shardBroadcast')
|
||||||
const pushDispatch = require('./pushDispatch')
|
const pushDispatch = require('./pushDispatch')
|
||||||
const defaultLog = require('./logger')('shard-ingest')
|
const defaultLog = require('./logger')('shard-ingest')
|
||||||
@@ -62,6 +64,31 @@ function shouldLog(event) {
|
|||||||
return LOGGED_KINDS.has(event.kind)
|
return LOGGED_KINDS.has(event.kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServUO's stock Server.cfg name. An operator who never set one publishes this
|
||||||
|
// verbatim, so it carries no more information than a blank — matched
|
||||||
|
// case-insensitively and trim-tolerantly, but ONLY as an exact whole value: a
|
||||||
|
// shard genuinely called "My Shard Reborn" keeps its name.
|
||||||
|
const STOCK_SHARD_NAME = 'my shard'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name to publish for the shard: its own, or this instance's when it has
|
||||||
|
* effectively not given one.
|
||||||
|
*
|
||||||
|
* Deliberately not a general "blank means brand" rule applied across the wire —
|
||||||
|
* it is scoped to this one field, where the two names denote the same thing.
|
||||||
|
*/
|
||||||
|
async function resolveShardName(shard, deps) {
|
||||||
|
const given = String(shard ?? '').trim()
|
||||||
|
if (given !== '' && given.toLowerCase() !== STOCK_SHARD_NAME) return given
|
||||||
|
try {
|
||||||
|
return (await deps.settings.getInstanceName()) || given
|
||||||
|
} catch {
|
||||||
|
// A ruleset that publishes the stock name is still better than one that
|
||||||
|
// fails to store because the settings read hiccuped.
|
||||||
|
return given
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
||||||
async function applyStateChange(event, deps) {
|
async function applyStateChange(event, deps) {
|
||||||
const { shardState, uoLinkConfig, log } = deps
|
const { shardState, uoLinkConfig, log } = deps
|
||||||
@@ -173,6 +200,42 @@ async function applyStateChange(event, deps) {
|
|||||||
case 'house.remove':
|
case 'house.remove':
|
||||||
await shardState.removeHouse(event.serial)
|
await shardState.removeHouse(event.serial)
|
||||||
return
|
return
|
||||||
|
// ── Protocol 3.0 ─────────────────────────────────────────────────────
|
||||||
|
// The shard re-emits its whole ruleset on every sidecar connect, so this is
|
||||||
|
// an overwrite, not an append — and deliberately NOT in LOGGED_KINDS: it
|
||||||
|
// would put a duplicate row in the event log on every reconnect, and
|
||||||
|
// server.hello already marks each of those.
|
||||||
|
case 'world.ruleset':
|
||||||
|
// A shard whose operator never edited Server.cfg publishes ServUO's stock
|
||||||
|
// "My Shard". That is the shard saying *unnamed*, not a name, so the site
|
||||||
|
// answers with its own — the rules page reading "My Shard" under a header
|
||||||
|
// reading UOMysticmoon is the shard failing to introduce itself.
|
||||||
|
//
|
||||||
|
// Normalized HERE rather than on read because the ruleset is also live: the
|
||||||
|
// same `event` object is handed to the SSE broadcast a few lines below, and
|
||||||
|
// a read-time fix would be undone by the next reconnect's frame.
|
||||||
|
event.shard = await resolveShardName(event.shard, deps)
|
||||||
|
await shardState.setRuleset(event)
|
||||||
|
return
|
||||||
|
// Board state, like guild.update — the newest frame for a system replaces the
|
||||||
|
// previous one, so it is NOT in LOGGED_KINDS. Logging would append a row every
|
||||||
|
// time anyone's score moved the top ten, which is a board, not an event.
|
||||||
|
case 'points.board':
|
||||||
|
await shardState.upsertPointsBoard(event)
|
||||||
|
return
|
||||||
|
// Player-vendor market index. Each frame is authoritative for one shop, so
|
||||||
|
// the model replaces that vendor's whole listing set rather than merging.
|
||||||
|
//
|
||||||
|
// NOT in LOGGED_KINDS, and this is the strongest case of the three v3 kinds:
|
||||||
|
// one frame carries up to 250 listings, the sweep re-emits a shop on any
|
||||||
|
// price change, and appending each of those to the event log would make
|
||||||
|
// shard_events mostly a price history nobody reads. The market IS the state.
|
||||||
|
case 'vendor.listing':
|
||||||
|
await deps.shardMarket.upsertVendor(event)
|
||||||
|
return
|
||||||
|
case 'vendor.listing.remove':
|
||||||
|
await deps.shardMarket.removeVendor(event.serial)
|
||||||
|
return
|
||||||
case 'account.unlinked':
|
case 'account.unlinked':
|
||||||
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
|
// A player ran [unlink in game (or a site-side unlink echoed back) — drop
|
||||||
// our local link mirror so attribution stops immediately.
|
// our local link mirror so attribution stops immediately.
|
||||||
@@ -195,7 +258,9 @@ function resolveDeps(deps) {
|
|||||||
shardEvents: deps.shardEvents || shardEventsModel,
|
shardEvents: deps.shardEvents || shardEventsModel,
|
||||||
shardState: deps.shardState || shardStateModel,
|
shardState: deps.shardState || shardStateModel,
|
||||||
shardLinks: deps.shardLinks || shardLinksModel,
|
shardLinks: deps.shardLinks || shardLinksModel,
|
||||||
|
shardMarket: deps.shardMarket || shardMarketModel,
|
||||||
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
|
||||||
|
settings: deps.settings || settingsModel,
|
||||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||||
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
|
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
|
||||||
log: deps.log || defaultLog,
|
log: deps.log || defaultLog,
|
||||||
@@ -229,11 +294,12 @@ async function ingest(event, deps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!deps.fromBackfill) {
|
if (!deps.fromBackfill) {
|
||||||
try {
|
// Broadcast is async since v3 (it reads the visibility config to decide what
|
||||||
d.broadcast(event)
|
// each subscriber may see). Fire-and-forget, like the push fan-out below: a
|
||||||
} catch (err) {
|
// slow config read must never delay or fail ingest.
|
||||||
d.log.warn('broadcast failed', { kind: event.kind, message: err.message })
|
Promise.resolve(d.broadcast(event)).catch((err) =>
|
||||||
}
|
d.log.warn('broadcast failed', { kind: event.kind, message: err.message }),
|
||||||
|
)
|
||||||
// Opt-in push fan-out, off the same event source as the SSE broadcast.
|
// Opt-in push fan-out, off the same event source as the SSE broadcast.
|
||||||
// Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest);
|
// Fire-and-forget (a slow/dead ntfy relay must never delay or fail ingest);
|
||||||
// fromShardEvent is self-guarding, but .catch() covers any lookup rejection.
|
// fromShardEvent is self-guarding, but .catch() covers any lookup rejection.
|
||||||
|
|||||||
435
server/src/utils/shardVisibility.js
Normal file
435
server/src/utils/shardVisibility.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('./auth')
|
||||||
|
const log = require('./logger')('shard-visibility')
|
||||||
|
|
||||||
|
// ── The ladder ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
|
||||||
|
const RANK = new Map(LADDER.map((level, i) => [level, i]))
|
||||||
|
|
||||||
|
const isLevel = (level) => RANK.has(level)
|
||||||
|
|
||||||
|
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
|
||||||
|
// point: an unrecognised value must always lose. A single shared fallback cannot
|
||||||
|
// do that — whichever direction it picks, it fails open on one side. So:
|
||||||
|
//
|
||||||
|
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
|
||||||
|
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
|
||||||
|
//
|
||||||
|
// With one `rank()` defaulting to admin, a viewer level that fell through (a
|
||||||
|
// typo, a future rung this build doesn't know, a value from a caller that
|
||||||
|
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
|
||||||
|
const viewerRank = (level) => RANK.get(level) ?? 0
|
||||||
|
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
|
||||||
|
|
||||||
|
// True when a viewer at `viewer` satisfies a requirement of `required`.
|
||||||
|
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
|
||||||
|
|
||||||
|
// Exported for tests/diagnostics; `meets` is what callers should use.
|
||||||
|
const rank = viewerRank
|
||||||
|
|
||||||
|
// ── Features ───────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
|
||||||
|
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
|
||||||
|
// not listed here is visible whenever the feature itself is.
|
||||||
|
//
|
||||||
|
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
|
||||||
|
|
||||||
|
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
|
||||||
|
|
||||||
|
// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
|
||||||
|
// frames nest actors (`leader.acct`), but several read models flatten them
|
||||||
|
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
|
||||||
|
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
|
||||||
|
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
|
||||||
|
// anonymous callers while the same account name was correctly stripped from the
|
||||||
|
// live `house.decay` frame.
|
||||||
|
//
|
||||||
|
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
|
||||||
|
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
|
||||||
|
// fail closed for shapes nobody has written yet.
|
||||||
|
const LOCKED_SUFFIXES = ['acct', 'webid']
|
||||||
|
const isLockedField = (key) => {
|
||||||
|
const k = String(key).toLowerCase()
|
||||||
|
return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURES = {
|
||||||
|
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||||
|
status: { audience: 'anonymous', fields: {} },
|
||||||
|
activity: { audience: 'anonymous', fields: {} },
|
||||||
|
champs: { audience: 'anonymous', fields: {} },
|
||||||
|
guilds: { audience: 'anonymous', fields: {} },
|
||||||
|
governors: { audience: 'anonymous', fields: {} },
|
||||||
|
// The public Houses page showed IDOC location only; owner/price were staff.
|
||||||
|
// `owner` is the actor object on the house.decay/house.update frames;
|
||||||
|
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
|
||||||
|
// REST read models. Both are listed so one rule covers the wire and the read
|
||||||
|
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
|
||||||
|
houses: {
|
||||||
|
audience: 'anonymous',
|
||||||
|
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
|
||||||
|
},
|
||||||
|
// /public/shard/online listed linked staff to everyone but gated location to
|
||||||
|
// admin+moderator — which is exactly the `staff` rung.
|
||||||
|
presence: { audience: 'anonymous', fields: { location: 'staff' } },
|
||||||
|
|
||||||
|
// ── New in v3. ──
|
||||||
|
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
|
||||||
|
atlas: { audience: 'anonymous', fields: {} },
|
||||||
|
// `name` is the ranked character's name inside points.board's `top` entries, and
|
||||||
|
// it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
|
||||||
|
// ("characterName"). projectValue matches on the literal JSON key, so a rule
|
||||||
|
// named for the field's meaning rather than its key silently does nothing — the
|
||||||
|
// same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
|
||||||
|
// leaderboards payload `name` can only be a character name: the board's own
|
||||||
|
// display name arrives as `nameString`/`nameNumber`.
|
||||||
|
leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
|
||||||
|
// Shop name, owner character name and vendor location are already globally
|
||||||
|
// visible in-game via the stock Vendor Search gump, so publishing them is not
|
||||||
|
// a new disclosure — but they stay configurable so an admin can tighten them.
|
||||||
|
//
|
||||||
|
// `ownerName` and `location` were pre-wired here by Part A, before the frame
|
||||||
|
// existed; both were re-checked against the real `vendor.listing` and both are
|
||||||
|
// genuine keys on it (unlike leaderboards' `characterName`, which was inert).
|
||||||
|
// `location` is a NESTED object on the wire and on the read model precisely so
|
||||||
|
// that one rule hides map, coordinates, region and house together — five flat
|
||||||
|
// keys would be five rules that drift apart.
|
||||||
|
//
|
||||||
|
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
|
||||||
|
// lists both: an admin who hides the owner's name and is left with a serial
|
||||||
|
// that every other board resolves back to that name has not hidden anything.
|
||||||
|
market: {
|
||||||
|
audience: 'anonymous',
|
||||||
|
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEATURE_NAMES = Object.keys(FEATURES)
|
||||||
|
const isFeature = (name) => Object.hasOwn(FEATURES, name)
|
||||||
|
|
||||||
|
// ── Kind → feature ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every event kind that may ever leave the admin channel must appear here.
|
||||||
|
// Anything else is admin-only by omission (rule 2). This map is seeded from
|
||||||
|
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
|
||||||
|
// same kinds it did — now attributed to a feature that an admin can re-gate.
|
||||||
|
|
||||||
|
const KIND_FEATURE = new Map(
|
||||||
|
Object.entries({
|
||||||
|
// status / lifecycle
|
||||||
|
'server.hello': 'status',
|
||||||
|
'server.shutdown': 'status',
|
||||||
|
'server.crashed': 'status',
|
||||||
|
'economy.supply': 'status',
|
||||||
|
// activity feed
|
||||||
|
'player.death': 'activity',
|
||||||
|
'player.murdered': 'activity',
|
||||||
|
'mob.killed': 'activity',
|
||||||
|
'quest.complete': 'activity',
|
||||||
|
'skill.gain': 'activity',
|
||||||
|
'fame.change': 'activity',
|
||||||
|
'karma.change': 'activity',
|
||||||
|
'mob.login': 'activity',
|
||||||
|
'mob.logout': 'activity',
|
||||||
|
// boards
|
||||||
|
'champ.update': 'champs',
|
||||||
|
'champ.remove': 'champs',
|
||||||
|
'guild.update': 'guilds',
|
||||||
|
'guild.remove': 'guilds',
|
||||||
|
'guild.join': 'guilds',
|
||||||
|
'city.update': 'governors',
|
||||||
|
'presence.online': 'presence',
|
||||||
|
'region.enter': 'presence',
|
||||||
|
// house.decay is the IDOC signal the public Houses page renders. The full
|
||||||
|
// registry (house.update / house.remove — owner, price, co-owners) stays
|
||||||
|
// off the map deliberately, so it remains admin-only exactly as before.
|
||||||
|
'house.decay': 'houses',
|
||||||
|
// v3
|
||||||
|
'world.ruleset': 'ruleset',
|
||||||
|
'points.board': 'leaderboards',
|
||||||
|
// vendor.listing IS mapped, but the market feature ships with its stream
|
||||||
|
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
|
||||||
|
// inventories would be the site's biggest bandwidth consumer and no page
|
||||||
|
// needs it live. An admin can turn it on.
|
||||||
|
'vendor.listing': 'market',
|
||||||
|
'vendor.listing.remove': 'market',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
|
||||||
|
// are unaffected; only the live stream is suppressed.
|
||||||
|
const DEFAULT_STREAM_OFF = new Set(['market'])
|
||||||
|
|
||||||
|
// Back-compat: the set of kinds that reach an anonymous viewer under the default
|
||||||
|
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
|
||||||
|
// this. Derived from the map above rather than hand-maintained, so the two can
|
||||||
|
// no longer drift.
|
||||||
|
const PUBLIC_KINDS = new Set(
|
||||||
|
[...KIND_FEATURE.entries()]
|
||||||
|
.filter(([, feature]) => {
|
||||||
|
if (DEFAULT_STREAM_OFF.has(feature)) return false
|
||||||
|
return FEATURES[feature].audience === 'anonymous'
|
||||||
|
})
|
||||||
|
.map(([kind]) => kind),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CONFIG_TTL_MS = 5000
|
||||||
|
let cache = null
|
||||||
|
let cachedAt = 0
|
||||||
|
|
||||||
|
// Merge a stored row over its compiled default. Unknown feature names in the DB
|
||||||
|
// are ignored (a stale row from a removed feature must not resurrect it), and an
|
||||||
|
// invalid rung falls back to the default rather than failing open.
|
||||||
|
function applyRow(name, row) {
|
||||||
|
const base = FEATURES[name]
|
||||||
|
const audience = isLevel(row?.audience) ? row.audience : base.audience
|
||||||
|
const fields = { ...base.fields }
|
||||||
|
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
|
||||||
|
if (isLockedField(field)) continue // rule 1: not configurable
|
||||||
|
if (isLevel(level)) fields[field] = level
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: row ? !!row.enabled : true,
|
||||||
|
audience,
|
||||||
|
fields,
|
||||||
|
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileDefaults() {
|
||||||
|
const out = {}
|
||||||
|
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
|
||||||
|
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
|
||||||
|
// "what the site did before" rather than to "everything is public".
|
||||||
|
async function getConfig() {
|
||||||
|
const now = Date.now()
|
||||||
|
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
|
||||||
|
try {
|
||||||
|
const rows = await db.listAll()
|
||||||
|
const byName = new Map(rows.map((r) => [r.feature, r]))
|
||||||
|
const out = {}
|
||||||
|
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
|
||||||
|
cache = out
|
||||||
|
cachedAt = now
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getConfig; falling back to defaults', err)
|
||||||
|
cache = cache || compileDefaults()
|
||||||
|
cachedAt = now
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
cache = null
|
||||||
|
cachedAt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Viewer level ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// anonymous no session
|
||||||
|
// logged_in authenticated, no linked game account
|
||||||
|
// player authenticated with a linked game account
|
||||||
|
// staff admin | moderator — the same set as the existing `modAccess` gate.
|
||||||
|
// `editor` is a CONTENT role with no shard privilege today, so it
|
||||||
|
// resolves by link status like any other member; mapping it to staff
|
||||||
|
// here would silently widen what editors can see.
|
||||||
|
// admin admin
|
||||||
|
//
|
||||||
|
// Staff always satisfy the `player` rung (rank order guarantees it) even without
|
||||||
|
// a linked account, matching the existing rule that /player/* is role-agnostic
|
||||||
|
// self-service.
|
||||||
|
|
||||||
|
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
|
||||||
|
// (or newly relinked) account must not keep the old answer for long. Anonymous,
|
||||||
|
// staff and admin callers short-circuit before this runs, so the lookup only
|
||||||
|
// costs a query on the logged-in-member path.
|
||||||
|
const LINK_TTL_MS = CONFIG_TTL_MS
|
||||||
|
const linkCache = new Map() // userId → { hasLink, at }
|
||||||
|
|
||||||
|
async function hasLinkedAccount(userId) {
|
||||||
|
const hit = linkCache.get(userId)
|
||||||
|
const now = Date.now()
|
||||||
|
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
|
||||||
|
let hasLink = false
|
||||||
|
try {
|
||||||
|
const links = await shardLinks.listForUser(userId)
|
||||||
|
hasLink = Array.isArray(links) && links.length > 0
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
|
||||||
|
}
|
||||||
|
linkCache.set(userId, { hasLink, at: now })
|
||||||
|
return hasLink
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop a user's cached link status (called when a link is created or removed so
|
||||||
|
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
|
||||||
|
const forgetUser = (userId) => linkCache.delete(userId)
|
||||||
|
|
||||||
|
async function viewerLevel(req) {
|
||||||
|
const viewer = req.user || auth.getUserFromRequest(req)
|
||||||
|
if (!viewer) return 'anonymous'
|
||||||
|
if (viewer.role === 'admin') return 'admin'
|
||||||
|
if (viewer.role === 'moderator') return 'staff'
|
||||||
|
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enforcement ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Route gate. 404 when the feature is disabled (do not leak that it exists);
|
||||||
|
// 403 when it exists but the viewer sits below its audience. Stashes the
|
||||||
|
// resolved level on the request so controllers can project without re-resolving.
|
||||||
|
function requireFeature(name) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const config = await getConfig()
|
||||||
|
const feature = config[name]
|
||||||
|
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
|
||||||
|
const level = await viewerLevel(req)
|
||||||
|
req.viewerLevel = level
|
||||||
|
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
|
||||||
|
return next()
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`requireFeature(${name})`, err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the fields a viewer at `level` may not see. Applies the locked rules
|
||||||
|
// first (so acct/webId can never survive below admin), then the feature's
|
||||||
|
// configured field rules. Recurses into arrays and nested objects because the
|
||||||
|
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
|
||||||
|
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
|
||||||
|
// instance is a value, not a bag of fields: rebuilding one key-by-key would
|
||||||
|
// return `{}` (a Date has no enumerable own properties), which is how the DB-
|
||||||
|
// backed read models — whose rows carry real Date columns — differ from the
|
||||||
|
// pure-JSON wire frames the projection was first written against.
|
||||||
|
const isPlainObject = (v) => {
|
||||||
|
if (v === null || typeof v !== 'object') return false
|
||||||
|
const proto = Object.getPrototypeOf(v)
|
||||||
|
return proto === Object.prototype || proto === null
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectValue(value, rules, level) {
|
||||||
|
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
|
||||||
|
if (!isPlainObject(value)) return value
|
||||||
|
const out = {}
|
||||||
|
for (const [key, v] of Object.entries(value)) {
|
||||||
|
// Locked fields are checked by meaning first, so no configured rule (and no
|
||||||
|
// flattened spelling) can widen them past `admin`.
|
||||||
|
const required = isLockedField(key) ? 'admin' : rules[key]
|
||||||
|
if (required && !meets(level, required)) continue
|
||||||
|
out[key] = projectValue(v, rules, level)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project a payload for one feature. `level` defaults to admin-equivalent only
|
||||||
|
// when explicitly passed; callers should always pass a resolved level.
|
||||||
|
function projectFeature(name, payload, level, config) {
|
||||||
|
const feature = config?.[name]
|
||||||
|
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
|
||||||
|
return projectValue(payload, rules, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience for controllers: resolve config once, project, return.
|
||||||
|
async function project(name, payload, req) {
|
||||||
|
const config = await getConfig()
|
||||||
|
const level = req.viewerLevel || (await viewerLevel(req))
|
||||||
|
return projectFeature(name, payload, level, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
|
||||||
|
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
|
||||||
|
function kindVisibleTo(kind, level, config) {
|
||||||
|
if (level === 'admin') return true
|
||||||
|
const name = KIND_FEATURE.get(kind)
|
||||||
|
if (!name) return false // rule 2: unmapped ⇒ admin-only
|
||||||
|
const feature = config?.[name]
|
||||||
|
if (!feature || !feature.enabled || !feature.stream) return false
|
||||||
|
return meets(level, feature.audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event kinds a viewer at `level` may read under the CURRENT config. This is
|
||||||
|
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
|
||||||
|
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
|
||||||
|
// this kind, given what the admin has configured?".
|
||||||
|
//
|
||||||
|
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
|
||||||
|
// feature whose live firehose is off (market) is still readable from the stored
|
||||||
|
// history. Unmapped kinds are absent by construction (rule 2).
|
||||||
|
function visibleKinds(level, config) {
|
||||||
|
return [...KIND_FEATURE.entries()]
|
||||||
|
.filter(([, name]) => {
|
||||||
|
const feature = config?.[name]
|
||||||
|
return !!feature && feature.enabled && meets(level, feature.audience)
|
||||||
|
})
|
||||||
|
.map(([kind]) => kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The features a viewer at `level` can actually see — drives SPA nav so it never
|
||||||
|
// renders a link that would 403.
|
||||||
|
function visibleFeatures(level, config) {
|
||||||
|
return FEATURE_NAMES.filter((name) => {
|
||||||
|
const feature = config[name]
|
||||||
|
return feature.enabled && meets(level, feature.audience)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
LADDER,
|
||||||
|
FEATURES,
|
||||||
|
FEATURE_NAMES,
|
||||||
|
LOCKED_FIELDS,
|
||||||
|
KIND_FEATURE,
|
||||||
|
PUBLIC_KINDS,
|
||||||
|
DEFAULT_STREAM_OFF,
|
||||||
|
isLevel,
|
||||||
|
isFeature,
|
||||||
|
isLockedField,
|
||||||
|
rank,
|
||||||
|
meets,
|
||||||
|
getConfig,
|
||||||
|
invalidate,
|
||||||
|
compileDefaults,
|
||||||
|
viewerLevel,
|
||||||
|
forgetUser,
|
||||||
|
requireFeature,
|
||||||
|
projectFeature,
|
||||||
|
project,
|
||||||
|
kindVisibleTo,
|
||||||
|
visibleKinds,
|
||||||
|
visibleFeatures,
|
||||||
|
}
|
||||||
686
server/src/utils/spawnAtlasParse.js
Normal file
686
server/src/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
server/src/utils/spawnAtlasSource.js
Normal file
336
server/src/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,
|
||||||
|
}
|
||||||
202
server/src/utils/themeResolve.js
Normal file
202
server/src/utils/themeResolve.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
// ── theme_visual: validate on write, resolve on read ───────────────────────
|
||||||
|
//
|
||||||
|
// Two jobs, one closed set of rules (config/themePresets.js):
|
||||||
|
//
|
||||||
|
// validateThemeVisual() the WRITE path. PUT /admin/settings rejects a bad
|
||||||
|
// theme_visual with a 400 rather than storing it, so an
|
||||||
|
// admin gets told why instead of watching a save appear
|
||||||
|
// to succeed and do nothing.
|
||||||
|
// resolveThemeTokens() the READ path. Turns the stored value into the CSS
|
||||||
|
// custom properties settings.getPublic() ships as
|
||||||
|
// `theme`. Fail-safe, per §4.4: anything unrecognized
|
||||||
|
// is dropped field-by-field and the surface falls back
|
||||||
|
// to theme.css's :root — never an error, never a
|
||||||
|
// half-applied palette.
|
||||||
|
//
|
||||||
|
// The write path is the strict one and the read path is the forgiving one on
|
||||||
|
// purpose. Strict-on-write gives feedback; forgiving-on-read means a row
|
||||||
|
// hand-edited in the DB, or written by an older version of this code, degrades
|
||||||
|
// to the shipped default instead of rendering a broken site.
|
||||||
|
//
|
||||||
|
// See docs/website/THEMING_AND_NAV.md §5-§6.
|
||||||
|
|
||||||
|
const {
|
||||||
|
PRESETS,
|
||||||
|
PRESET_IDS,
|
||||||
|
CUSTOM_PRESET,
|
||||||
|
COLOR_FIELDS,
|
||||||
|
RADIUS_FIELDS,
|
||||||
|
FONT_FIELDS,
|
||||||
|
FONT_OPTIONS,
|
||||||
|
SHADOW_OPTIONS,
|
||||||
|
RADIUS_MAX_PX,
|
||||||
|
} = require('../config/themePresets')
|
||||||
|
const { parseJsonSetting } = require('./settingsJson')
|
||||||
|
|
||||||
|
const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
|
||||||
|
const PX_LENGTH = /^(\d{1,3})px$/
|
||||||
|
|
||||||
|
const SHADOW_VALUES = SHADOW_OPTIONS.map((o) => o.value)
|
||||||
|
const FONT_VALUES = Object.fromEntries(
|
||||||
|
Object.keys(FONT_FIELDS).map((role) => [role, FONT_OPTIONS[role].map((o) => o.value)]),
|
||||||
|
)
|
||||||
|
|
||||||
|
function isPlainObject(v) {
|
||||||
|
return !!v && typeof v === 'object' && !Array.isArray(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isColor(v) {
|
||||||
|
return typeof v === 'string' && HEX_COLOR.test(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bounded px length. `0` on its own is not accepted — a radius is always
|
||||||
|
// written with a unit here, which keeps the stored shape uniform.
|
||||||
|
function isRadius(v) {
|
||||||
|
if (typeof v !== 'string') return false
|
||||||
|
const m = PX_LENGTH.exec(v)
|
||||||
|
return !!m && Number(m[1]) <= RADIUS_MAX_PX
|
||||||
|
}
|
||||||
|
|
||||||
|
function isShadow(v) {
|
||||||
|
return typeof v === 'string' && SHADOW_VALUES.includes(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFont(role, v) {
|
||||||
|
return typeof v === 'string' && (FONT_VALUES[role] || []).includes(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-field check for one custom group. Returns the list of offending field
|
||||||
|
// names, so the write path can say which field was wrong.
|
||||||
|
function checkGroup(group, fields, check) {
|
||||||
|
const bad = []
|
||||||
|
for (const [field, value] of Object.entries(group)) {
|
||||||
|
if (!(field in fields)) {
|
||||||
|
bad.push(field)
|
||||||
|
} else if (!check(field, value)) {
|
||||||
|
bad.push(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict shape check for the write path.
|
||||||
|
*
|
||||||
|
* @param {unknown} value the parsed theme_visual object
|
||||||
|
* @returns {{ ok: true } | { ok: false, message: string }}
|
||||||
|
*/
|
||||||
|
function validateThemeVisual(value) {
|
||||||
|
if (!isPlainObject(value)) return { ok: false, message: 'theme_visual must be a JSON object' }
|
||||||
|
|
||||||
|
const keys = Object.keys(value).filter((k) => k !== 'preset' && k !== 'custom')
|
||||||
|
if (keys.length) return { ok: false, message: `theme_visual: unknown field(s) ${keys.join(', ')}` }
|
||||||
|
|
||||||
|
if (!PRESET_IDS.includes(value.preset)) {
|
||||||
|
return { ok: false, message: `theme_visual.preset must be one of ${PRESET_IDS.join(', ')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// `custom` is optional and may be explicitly null ("preset only").
|
||||||
|
const custom = value.custom
|
||||||
|
if (custom === undefined || custom === null) return { ok: true }
|
||||||
|
if (!isPlainObject(custom)) return { ok: false, message: 'theme_visual.custom must be an object or null' }
|
||||||
|
|
||||||
|
const groups = Object.keys(custom).filter((g) => !['colors', 'structure', 'fonts'].includes(g))
|
||||||
|
if (groups.length) return { ok: false, message: `theme_visual.custom: unknown group(s) ${groups.join(', ')}` }
|
||||||
|
|
||||||
|
for (const [group, spec] of [
|
||||||
|
['colors', { fields: COLOR_FIELDS, check: (_f, v) => isColor(v) }],
|
||||||
|
['fonts', { fields: FONT_FIELDS, check: (f, v) => isFont(f, v) }],
|
||||||
|
[
|
||||||
|
'structure',
|
||||||
|
{
|
||||||
|
fields: { ...RADIUS_FIELDS, shadowDepth: '--shadow-card' },
|
||||||
|
check: (f, v) => (f === 'shadowDepth' ? isShadow(v) : isRadius(v)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
const supplied = custom[group]
|
||||||
|
if (supplied === undefined || supplied === null) continue
|
||||||
|
if (!isPlainObject(supplied)) return { ok: false, message: `theme_visual.custom.${group} must be an object` }
|
||||||
|
const bad = checkGroup(supplied, spec.fields, spec.check)
|
||||||
|
if (bad.length) return { ok: false, message: `theme_visual.custom.${group}: invalid value for ${bad.join(', ')}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the fields of one custom group that pass their check onto the token map.
|
||||||
|
// Field-by-field: a bad accent does not discard a good bg beside it.
|
||||||
|
function applyGroup(tokens, group, fields, check) {
|
||||||
|
if (!isPlainObject(group)) return
|
||||||
|
for (const [field, token] of Object.entries(fields)) {
|
||||||
|
const value = group[field]
|
||||||
|
if (value !== undefined && check(field, value)) tokens[token] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The effective CSS custom properties for a stored theme_visual value.
|
||||||
|
*
|
||||||
|
* Layered :root ← preset ← custom, per field. `null` means "no row, or nothing
|
||||||
|
* usable in it" — the caller omits the block entirely and the client applies
|
||||||
|
* nothing, which is what makes an untouched instance render byte-for-byte as
|
||||||
|
* today.
|
||||||
|
*
|
||||||
|
* @param {string|object|null|undefined} stored the raw settings value (TEXT) or
|
||||||
|
* an already-parsed object
|
||||||
|
* @returns {Record<string, string>|null}
|
||||||
|
*/
|
||||||
|
function resolveThemeTokens(stored) {
|
||||||
|
const parsed = typeof stored === 'string' ? parseJsonSetting(stored) : isPlainObject(stored) ? stored : null
|
||||||
|
if (!parsed) return null
|
||||||
|
|
||||||
|
// An unrecognized preset id falls back to no base rather than to a guess: the
|
||||||
|
// admin's custom fields still apply on top of :root.
|
||||||
|
const base = PRESETS[parsed.preset]
|
||||||
|
const tokens = base ? { ...base.tokens } : {}
|
||||||
|
|
||||||
|
const custom = parsed.custom
|
||||||
|
if (isPlainObject(custom)) {
|
||||||
|
applyGroup(tokens, custom.colors, COLOR_FIELDS, (_f, v) => isColor(v))
|
||||||
|
applyGroup(tokens, custom.fonts, FONT_FIELDS, (f, v) => isFont(f, v))
|
||||||
|
applyGroup(tokens, custom.structure, RADIUS_FIELDS, (_f, v) => isRadius(v))
|
||||||
|
applyGroup(tokens, custom.structure, { shadowDepth: '--shadow-card' }, (_f, v) => isShadow(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row that parsed but yielded nothing usable (e.g. `{"preset":"custom"}`
|
||||||
|
// with no custom fields) is the same as no row at all to every consumer.
|
||||||
|
return Object.keys(tokens).length ? tokens : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog the admin UI builds its controls from. Served rather than
|
||||||
|
* duplicated client-side so the options offered can never drift from the
|
||||||
|
* options validateThemeVisual() accepts.
|
||||||
|
*/
|
||||||
|
function themeOptions() {
|
||||||
|
return {
|
||||||
|
// Full token maps, not just a swatch: the form shows each control's
|
||||||
|
// *effective* default for the selected preset, so an admin opening the
|
||||||
|
// accent picker on Fantasy sees Fantasy's gold rather than a hardcoded
|
||||||
|
// client-side copy of the shipped palette. `custom` has no map — it means
|
||||||
|
// "no preset base", and the form falls back to the shipped theme, which is
|
||||||
|
// the runic-gateway map.
|
||||||
|
presets: [
|
||||||
|
...Object.entries(PRESETS).map(([id, p]) => ({ id, label: p.label, tokens: p.tokens })),
|
||||||
|
{ id: CUSTOM_PRESET, label: 'Custom', tokens: null },
|
||||||
|
],
|
||||||
|
// Each editable field paired with the CSS variable it drives, so the form
|
||||||
|
// can look its current value up in the preset map above without knowing the
|
||||||
|
// naming convention that relates the two.
|
||||||
|
colorFields: Object.entries(COLOR_FIELDS).map(([name, token]) => ({ name, token })),
|
||||||
|
radiusFields: Object.entries(RADIUS_FIELDS).map(([name, token]) => ({ name, token })),
|
||||||
|
fonts: FONT_OPTIONS,
|
||||||
|
shadows: SHADOW_OPTIONS,
|
||||||
|
radiusMaxPx: RADIUS_MAX_PX,
|
||||||
|
// The shipped default, i.e. what theme.css's :root already declares. What
|
||||||
|
// an unset field actually resolves to when no preset is selected.
|
||||||
|
shippedTokens: PRESETS['runic-gateway'].tokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateThemeVisual, resolveThemeTokens, themeOptions }
|
||||||
@@ -58,7 +58,7 @@ async function call(path, { method = 'GET', body } = {}) {
|
|||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-UOLink-Version': String(config.protocol || 1),
|
'X-UOLink-Version': String(config.protocol || 3),
|
||||||
}
|
}
|
||||||
if (config.token) headers.Authorization = `Bearer ${config.token}`
|
if (config.token) headers.Authorization = `Bearer ${config.token}`
|
||||||
|
|
||||||
@@ -128,6 +128,18 @@ const getGuilds = () => call('/guilds')
|
|||||||
const getGovernors = () => call('/governors')
|
const getGovernors = () => call('/governors')
|
||||||
const getHouses = () => call('/houses')
|
const getHouses = () => call('/houses')
|
||||||
const getPresence = () => call('/online') // aggregate population (count + byFacet/byRegion)
|
const getPresence = () => call('/online') // aggregate population (count + byFacet/byRegion)
|
||||||
|
// Protocol 3.0: the shard's published ruleset. Object-shaped, not a board — the
|
||||||
|
// sidecar answers `{ ruleset: null }` until the shard has published one.
|
||||||
|
const getRuleset = () => call('/ruleset')
|
||||||
|
// Protocol 3.0: points/loyalty leaderboards. `/points` is board-shaped (an array
|
||||||
|
// under `boards`); the per-system read 404s for a system the shard never published.
|
||||||
|
const getPoints = () => call('/points')
|
||||||
|
const getPointsBoard = (system) => call(`/points/${encodeURIComponent(system)}`)
|
||||||
|
// Protocol 3.0: the player-vendor market index. The one PAGED sidecar read — a
|
||||||
|
// whole-world market does not fit in a response — so it answers with
|
||||||
|
// `{ vendors, total, limit, offset }` and the caller walks it (see uoLinkSocket).
|
||||||
|
const getMarket = ({ limit = 200, offset = 0 } = {}) =>
|
||||||
|
call(`/market?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`)
|
||||||
|
|
||||||
// ── Commands ──────────────────────────────────────────────────────────────
|
// ── Commands ──────────────────────────────────────────────────────────────
|
||||||
const confirmLink = (code, websiteUserId) =>
|
const confirmLink = (code, websiteUserId) =>
|
||||||
@@ -191,6 +203,10 @@ module.exports = {
|
|||||||
getGovernors,
|
getGovernors,
|
||||||
getHouses,
|
getHouses,
|
||||||
getPresence,
|
getPresence,
|
||||||
|
getRuleset,
|
||||||
|
getPoints,
|
||||||
|
getPointsBoard,
|
||||||
|
getMarket,
|
||||||
confirmLink,
|
confirmLink,
|
||||||
linkLookup,
|
linkLookup,
|
||||||
createAccount,
|
createAccount,
|
||||||
|
|||||||
@@ -63,6 +63,55 @@ async function ingestEach(events) {
|
|||||||
for (const ev of events) await shardIngest.ingest(ev, { fromBackfill: true })
|
for (const ev of events) await shardIngest.ingest(ev, { fromBackfill: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Market backfill ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The market is the only board that does not fit in one response, so /market is
|
||||||
|
// paged and this walks it. Two bounds, both deliberate:
|
||||||
|
//
|
||||||
|
// • MARKET_SNAPSHOT_MAX caps the walk. A pathological world (or a sidecar whose
|
||||||
|
// store was never pruned) must not be able to hang startup — backfill runs
|
||||||
|
// before the site is serving the live feed, so an unbounded loop here is
|
||||||
|
// downtime, not slowness.
|
||||||
|
// • The loop stops on a SHORT page as well as on `total`, because a concurrent
|
||||||
|
// sweep can shrink the index underneath the walk and paging to a stale total
|
||||||
|
// would spin.
|
||||||
|
//
|
||||||
|
// Vendors are upserted, never reconciled-by-replacement. A vendor absent from the
|
||||||
|
// snapshot is absent because the sidecar dropped it on vendor.listing.remove —
|
||||||
|
// which our own ingest already processed — so clearing the table first would only
|
||||||
|
// create a window where the market page is empty.
|
||||||
|
const MARKET_SNAPSHOT_MAX = 5000
|
||||||
|
const MARKET_PAGE = 200
|
||||||
|
|
||||||
|
async function backfillMarket() {
|
||||||
|
let offset = 0
|
||||||
|
let seen = 0
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const res = await uoLinkClient.getMarket({ limit: MARKET_PAGE, offset })
|
||||||
|
if (!res.ok || !res.data || !Array.isArray(res.data.vendors)) return
|
||||||
|
|
||||||
|
const page = res.data.vendors
|
||||||
|
if (page.length === 0) break
|
||||||
|
|
||||||
|
await ingestEach(page)
|
||||||
|
seen += page.length
|
||||||
|
offset += page.length
|
||||||
|
|
||||||
|
if (page.length < MARKET_PAGE) break
|
||||||
|
if (seen >= MARKET_SNAPSHOT_MAX) {
|
||||||
|
log.warn('market snapshot truncated at the safety cap', {
|
||||||
|
cap: MARKET_SNAPSHOT_MAX,
|
||||||
|
total: res.data.total,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (Number.isFinite(res.data.total) && offset >= res.data.total) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen > 0) log.info('snapshotted player-vendor market from /market', { count: seen })
|
||||||
|
}
|
||||||
|
|
||||||
// Pull recent events from the sidecar's own store and replay them through the
|
// Pull recent events from the sidecar's own store and replay them through the
|
||||||
// dispatcher (fromBackfill = no SSE re-broadcast). dedupe_key + INSERT IGNORE
|
// dispatcher (fromBackfill = no SSE re-broadcast). dedupe_key + INSERT IGNORE
|
||||||
// make this idempotent, so overlap with what we already stored is harmless.
|
// make this idempotent, so overlap with what we already stored is harmless.
|
||||||
@@ -88,6 +137,32 @@ async function backfill() {
|
|||||||
await snapshot(() => uoLinkClient.getGovernors(), 'cities', (c) => shardState.replaceGovernors(c), 'snapshotted governor board from /governors')
|
await snapshot(() => uoLinkClient.getGovernors(), 'cities', (c) => shardState.replaceGovernors(c), 'snapshotted governor board from /governors')
|
||||||
await snapshot(() => uoLinkClient.getHouses(), 'houses', ingestEach, 'snapshotted house registry from /houses')
|
await snapshot(() => uoLinkClient.getHouses(), 'houses', ingestEach, 'snapshotted house registry from /houses')
|
||||||
|
|
||||||
|
// ── Protocol 3.0 ─────────────────────────────────────────────────────
|
||||||
|
// The ruleset is object-shaped, not a board, so it can't go through
|
||||||
|
// snapshot() (which asserts an array under `key`). The shard also re-emits
|
||||||
|
// world.ruleset on its own connect — this covers the other order, where the
|
||||||
|
// sidecar was already up and holding the ruleset when WE reconnected.
|
||||||
|
//
|
||||||
|
// Routed through the dispatcher rather than straight to shardState, exactly as
|
||||||
|
// ingestEach does for the array-shaped boards: the two orders must produce the
|
||||||
|
// same stored frame, and calling setRuleset directly here made this a second
|
||||||
|
// write path that silently skipped the shard-name normalization the live frame
|
||||||
|
// gets. One writer, one set of rules.
|
||||||
|
const ruleset = await uoLinkClient.getRuleset()
|
||||||
|
if (ruleset.ok && ruleset.data && ruleset.data.ruleset) {
|
||||||
|
await shardIngest.ingest(ruleset.data.ruleset, { fromBackfill: true })
|
||||||
|
log.info('snapshotted shard ruleset from /ruleset', { rev: ruleset.data.ruleset.rev })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Points boards ARE array-shaped, so they go through snapshot() — but with
|
||||||
|
// ingestEach rather than a replace*: there is no points.remove and the shard's
|
||||||
|
// system set is fixed, so upserting is the whole reconciliation. A system the
|
||||||
|
// operator has since excluded keeps its last-known board rather than vanishing,
|
||||||
|
// which is the right answer for a month-scale standing.
|
||||||
|
await snapshot(() => uoLinkClient.getPoints(), 'boards', ingestEach, 'snapshotted points boards from /points')
|
||||||
|
|
||||||
|
await backfillMarket()
|
||||||
|
|
||||||
const presence = await uoLinkClient.getPresence()
|
const presence = await uoLinkClient.getPresence()
|
||||||
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
|
if (presence.ok && presence.data && typeof presence.data.count === 'number') {
|
||||||
await shardState.setPresence(presence.data)
|
await shardState.setPresence(presence.data)
|
||||||
@@ -128,7 +203,7 @@ async function connect() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state.protocol = config.protocol || 1
|
state.protocol = config.protocol || 3
|
||||||
helloSeen = false
|
helloSeen = false
|
||||||
const url = buildUrl(config.wsUrl, config.token)
|
const url = buildUrl(config.wsUrl, config.token)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user