Compare commits
16 Commits
a183634f4e
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e81b61d044 | |||
| a0c24456c7 | |||
| 044211fd41 | |||
| 5cdcf0fbb6 | |||
| e468bbd3b9 | |||
| d70e5e10d0 | |||
| f7bb3d912e | |||
| ba092efd8c | |||
| 9d559091c5 | |||
| e4af7dd9a8 | |||
| 493cf296ab | |||
| 28f4b9afe2 | |||
| 050a02c21d | |||
| 6b99d7e220 | |||
| 740a677f92 | |||
| fe3251a543 |
@@ -27,11 +27,29 @@
|
||||
# Building the chunk in CI is not only a check: it is how the chunk that ships is
|
||||
# produced, since an operator never builds (MODULE_SYSTEM.md §1.14).
|
||||
#
|
||||
# Not here yet, deliberately, because there is nothing for them to act on until
|
||||
# the extraction is further along: the release workflow (the
|
||||
# `module-uo-<version>.tar.gz` artifact and its sha256 manifest) and the module's
|
||||
# own frozen route manifest, which needs core checked out at a pinned ref
|
||||
# (MODULE_API.md §5.3). Each lands with the slice it checks.
|
||||
# • `server: check:swagger` — `swagger-fragment.json` describes the routes this
|
||||
# module registers, today. Core has no way to generate it: core is a prebuilt
|
||||
# image, this module arrived on a volume afterwards, and it mounts through a
|
||||
# call no static parser can follow. So the fragment core merges into
|
||||
# `/api/docs.json` is whatever this repo committed, and a stale one documents
|
||||
# a URL surface that does not exist (§2.8).
|
||||
#
|
||||
# • `frozen-manifest` — the job with the interesting shape. It clones CORE at
|
||||
# the ref pinned in `ci/core-ref.json`, generates its route manifest twice
|
||||
# (without this module, then with) and takes the difference. That difference
|
||||
# is what this module serves, and it is checked three ways: it must match the
|
||||
# committed `routes.manifest.json`, it must not have REMOVED or changed one of
|
||||
# core's own routes, and every route in it must have an operation in
|
||||
# `swagger-fragment.json` — the per-module form of core's rule that a route
|
||||
# which isn't in the spec doesn't ship (§5.3, §2.8).
|
||||
#
|
||||
# Nothing else can ask those questions. Every other check here runs against
|
||||
# this repo alone, where a mount prefix is a string in `server/index.js` and a
|
||||
# documented path is a string in a JSON file; whether they name the same URL
|
||||
# is a fact about a running core, and this is the only job that has one.
|
||||
#
|
||||
# Still not here, deliberately: nothing. The release workflow is
|
||||
# `.gitea/workflows/release.yml` and runs on a tag rather than on a PR.
|
||||
#
|
||||
# Enforcement (one-time, in the Gitea UI):
|
||||
# Repository Settings → Branches → Branch Protection (rule for `main`)
|
||||
@@ -55,6 +73,15 @@ concurrency:
|
||||
group: pr-checks-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# npm's own retry, turned up. The shared runner reads ETIMEDOUT from the registry
|
||||
# often enough to matter, and there are five `npm ci` calls across these jobs — a
|
||||
# red X that means "the network hiccuped" costs a reviewer more than it costs the
|
||||
# runner to retry, and teaches everyone to re-run rather than read a failure.
|
||||
env:
|
||||
NPM_CONFIG_FETCH_RETRIES: 5
|
||||
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||
|
||||
jobs:
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -79,6 +106,9 @@ jobs:
|
||||
- name: Check the module boundary (MODULE_API.md §5.1)
|
||||
run: npm run check:imports --prefix server
|
||||
|
||||
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
|
||||
run: npm run check:swagger --prefix server
|
||||
|
||||
client-build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@@ -94,11 +124,83 @@ jobs:
|
||||
- name: Install client deps
|
||||
run: npm ci --prefix client
|
||||
|
||||
- name: Run client tests
|
||||
run: npm test --prefix client
|
||||
|
||||
# The build comes FIRST, and that ordering is load-bearing as of slice 3.
|
||||
# Two of the client tests read `dist/entry.js` — the chunk's externals, and
|
||||
# what it registers when imported against a fake `window.__rg` — and both
|
||||
# skip when there is no build. Run the other way round they skip silently
|
||||
# in CI, which is the worst of both: green, and not asking the question.
|
||||
- name: Build the client chunk
|
||||
run: npm run build --prefix client
|
||||
|
||||
- name: Run client tests
|
||||
run: npm test --prefix client
|
||||
|
||||
- name: Check the built chunk's externals (MODULE_API.md §3.6)
|
||||
run: npm run check:externals --prefix client
|
||||
|
||||
# ── The URLs this module actually serves ──────────────────────────────────
|
||||
#
|
||||
# Everything above proves the module against itself. This proves it against a
|
||||
# real core: the one place where "the prefix I register" and "the path I
|
||||
# document" are the same fact rather than two strings that ought to agree.
|
||||
#
|
||||
# The module is COPIED into the core checkout, never symlinked — core's loader
|
||||
# filters its scan with `entry.isDirectory()`, which reports a link as a link
|
||||
# and skips it silently, so a symlinked module produces a manifest with no
|
||||
# module routes in it and a diff that looks like the module registering
|
||||
# nothing.
|
||||
frozen-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: module
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
# Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a
|
||||
# commit sha, and `--depth 1` can only fetch a branch tip.
|
||||
- name: Clone core at the pinned ref (MODULE_API.md §5.3)
|
||||
run: |
|
||||
REPO=$(node -p "require('./module/ci/core-ref.json').repo")
|
||||
REF=$(node -p "require('./module/ci/core-ref.json').ref")
|
||||
echo "core: $REPO @ $REF"
|
||||
git clone --quiet "$REPO" core
|
||||
git -C core checkout --quiet "$REF"
|
||||
|
||||
- name: Install core's server deps
|
||||
run: npm ci --prefix core/server
|
||||
|
||||
# Core alone. `--check` first, so a pin that no longer regenerates its own
|
||||
# committed manifest fails HERE, naming the pin, instead of showing up below
|
||||
# as this module having removed a route it never touched.
|
||||
- name: Generate core's manifest without this module
|
||||
run: |
|
||||
npm run routes:manifest --prefix core/server -- --check
|
||||
cp core/server/routes.manifest.json before.json
|
||||
|
||||
# The chunk has to exist before the loader will accept the module at all —
|
||||
# `client.entry` is validated during the manifest step of the scan, and a
|
||||
# missing one is a load failure, not a warning.
|
||||
- name: Build the client chunk
|
||||
run: |
|
||||
npm ci --prefix module/client
|
||||
npm run build --prefix module/client
|
||||
|
||||
- name: Install the module into core
|
||||
run: |
|
||||
mkdir -p core/modules/uo
|
||||
tar -C module --exclude=.git --exclude=node_modules -cf - . | tar -C core/modules/uo -xf -
|
||||
npm ci --omit=dev --prefix core/modules/uo/server
|
||||
|
||||
- name: Generate core's manifest with this module
|
||||
run: |
|
||||
npm run routes:manifest --prefix core/server
|
||||
cp core/server/routes.manifest.json after.json
|
||||
|
||||
- name: Check the frozen manifest and the fragment's coverage
|
||||
working-directory: module
|
||||
run: node server/scripts/frozenManifest.js --before ../before.json --after ../after.json --check
|
||||
|
||||
258
.gitea/workflows/release.yml
Normal file
258
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,258 @@
|
||||
# Build and publish the installable bundle: `module-uo-<version>.tar.gz` plus the
|
||||
# manifest carrying its sha256 (docs/website/MODULE_SYSTEM.md §2.3, §2.5).
|
||||
#
|
||||
# ── What a release IS here ──────────────────────────────────────────────────
|
||||
#
|
||||
# **An operator never builds anything** (MODULE_SYSTEM.md §1.14 — the constraint
|
||||
# the whole module system is shaped around). So a release is not source: it is the
|
||||
# directory core's loader expects to find at `modules/uo/`, already assembled —
|
||||
# the prebuilt client chunk, the one runtime dependency installed, the schema
|
||||
# fragment and the OpenAPI fragment — packed as it will be unpacked. Phase 4's
|
||||
# admin install downloads the tarball, verifies it against the `sha256` in the
|
||||
# manifest, and unpacks it onto the volume. Nothing runs `npm` on the way.
|
||||
#
|
||||
# ── The version is DECLARED, not derived ────────────────────────────────────
|
||||
#
|
||||
# Unlike RunicGateway/link and RunicGateway/installer, whose release engines read
|
||||
# conventional-commit subjects to compute the next version, this repo already has
|
||||
# one authoritative version — `module.json`'s, which is the version core records
|
||||
# in `installed_modules` and shows on the admin screen, and which sits beside the
|
||||
# `coreApi` range a bump usually has to be considered against. Two sources for one
|
||||
# number is how they drift, so: **a release happens when a merge to `main` leaves
|
||||
# `module.json` at a version that has no release yet.** Bumping the version is an
|
||||
# ordinary reviewed PR; publishing is this file's business.
|
||||
#
|
||||
# It follows that this workflow never writes to a branch — it tags and publishes,
|
||||
# nothing else — so `main` needs no push exception. That is the installer's model,
|
||||
# adopted here for the reason it was adopted there: `main` is protected, and a
|
||||
# release engine that has to push to it is a release engine that stops working the
|
||||
# day someone tightens the rule.
|
||||
#
|
||||
# Re-running on a version that is already released is a no-op, so a rerun after an
|
||||
# unrelated failure is safe.
|
||||
#
|
||||
# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo):
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag
|
||||
# and create the release.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: release-module-uo
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: RunicGateway/Module-uo
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Decide whether this commit releases
|
||||
id: plan
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="$(node -p "require('./module.json').version")"
|
||||
echo "module.json version: ${VERSION}"
|
||||
|
||||
# Does a release already exist for this version? A 404 means no, a 200
|
||||
# means yes, and anything else — a network failure, a bad token — is not
|
||||
# evidence of absence. Guessing "no" would publish over a good release,
|
||||
# so refuse instead. (The installer learned this one the expensive way.)
|
||||
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
|
||||
|
||||
case "$HTTP" in
|
||||
404) RELEASE=true ;;
|
||||
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
|
||||
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Before anything is built or tagged, so a repo without secrets fails
|
||||
# legibly rather than half-publishing: the tag push can succeed on the
|
||||
# credential actions/checkout left in the local git config while the
|
||||
# release API call 401s, leaving the repo tagged and unreleased.
|
||||
- name: Verify release credentials are configured
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then
|
||||
echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}."
|
||||
exit 1
|
||||
fi
|
||||
echo "Release credentials present."
|
||||
|
||||
- name: Build the client chunk
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
npm ci --prefix client
|
||||
npm run build --prefix client
|
||||
|
||||
# `--omit=dev` and then PACKED: express, express-validator and swagger-autogen
|
||||
# are build- and test-time only — the shipped half is handed express on `ctx`
|
||||
# (MODULE_API.md §2.3) — and `ws` is the one runtime dependency. Node resolves
|
||||
# it by walking up from `modules/uo/server/`, which is why it ships inside the
|
||||
# tarball rather than being installed on the operator's box.
|
||||
- name: Install the shipped runtime dependency
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: npm ci --omit=dev --prefix server
|
||||
|
||||
# ── Assemble exactly what an operator's volume gets ──────────────────
|
||||
#
|
||||
# Stated as an INCLUDE list, not an exclude list. An exclude list ships
|
||||
# whatever it forgot: the day someone adds `server/tools/` with a scratch
|
||||
# credential in it, an exclude list packs it and nobody finds out.
|
||||
- name: Assemble the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
OUT="dist/module-uo-${VERSION}"
|
||||
rm -rf dist && mkdir -p "$OUT"
|
||||
|
||||
# The manifest core reads, the two fragments, and the licence the code
|
||||
# is under — a bundle that ships GPL code without its licence is not
|
||||
# distributable.
|
||||
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
|
||||
|
||||
# The server half, minus what never runs inside core's process.
|
||||
mkdir -p "$OUT/server"
|
||||
for d in boot.js core.js index.js config data db model router utils; do
|
||||
cp -r "server/$d" "$OUT/server/"
|
||||
done
|
||||
cp server/package.json "$OUT/server/"
|
||||
cp -r server/node_modules "$OUT/server/"
|
||||
|
||||
# The client half is the BUILT chunk only. `client/src` is 5,000 lines
|
||||
# of source an operator has no use for and core will never read.
|
||||
mkdir -p "$OUT/client/dist"
|
||||
cp client/dist/entry.js "$OUT/client/dist/"
|
||||
|
||||
# Prove the bundle is loadable before it is published: these are the
|
||||
# paths core's loader resolves out of module.json, and a release whose
|
||||
# entry point is missing fails on an operator's box with a
|
||||
# `startup_failed` row instead of here.
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = process.argv[1];
|
||||
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
|
||||
for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) {
|
||||
if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); }
|
||||
}
|
||||
console.log("bundle contents check: ok");
|
||||
' "$OUT"
|
||||
|
||||
tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}"
|
||||
rm -rf "$OUT"
|
||||
|
||||
SHA="$(sha256sum "dist/module-uo-${VERSION}.tar.gz" | cut -d' ' -f1)"
|
||||
SIZE="$(stat -c%s "dist/module-uo-${VERSION}.tar.gz")"
|
||||
|
||||
# The install manifest. Same shape as the installer's bundle JSON — a
|
||||
# per-asset sha256 fetched over HTTPS, no signatures — because that is
|
||||
# the model this project already has and a second one would be a second
|
||||
# thing to get right (MODULE_SYSTEM.md §1.11).
|
||||
jq -n \
|
||||
--arg id "$(node -p "require('./module.json').id")" \
|
||||
--arg name "$(node -p "require('./module.json').name")" \
|
||||
--arg version "$VERSION" \
|
||||
--arg coreApi "$(node -p "require('./module.json').coreApi")" \
|
||||
--arg artifact "module-uo-${VERSION}.tar.gz" \
|
||||
--arg sha256 "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
--arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-uo-${VERSION}.tar.gz" \
|
||||
'{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi,
|
||||
artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \
|
||||
> "dist/module-uo-${VERSION}.json"
|
||||
|
||||
echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
cat "dist/module-uo-${VERSION}.json"
|
||||
|
||||
- name: Write the changelog
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
|
||||
{
|
||||
echo "## module-uo v${VERSION}"
|
||||
echo
|
||||
echo "Install from the website's Admin → Modules screen, or unpack onto the"
|
||||
echo "modules volume as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\`"
|
||||
echo "satisfies \`$(node -p "require('./module.json').coreApi")\`."
|
||||
echo
|
||||
echo "### Changes"
|
||||
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
|
||||
git log --no-merges --format='- %s' $RANGE || true
|
||||
echo
|
||||
echo "### Verifying this download"
|
||||
echo
|
||||
echo "Releases are **unsigned** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the"
|
||||
echo "trust anchor, and the website verifies it before unpacking."
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "sha256sum -c SHA256SUMS --ignore-missing"
|
||||
echo '```'
|
||||
} > dist/CHANGELOG.md
|
||||
|
||||
- name: Tag the release
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
git config user.name 'Runic Gateway CI'
|
||||
git config user.email 'ci@whitlocktech.net'
|
||||
git tag -a "$TAG" -m "module-uo ${TAG}"
|
||||
git push origin "$TAG"
|
||||
|
||||
- name: Create the Gitea release and upload the bundle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
|
||||
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}.json" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -19,7 +19,13 @@ client/coverage/
|
||||
*.env
|
||||
!.env.example
|
||||
|
||||
# Release staging
|
||||
# Release staging. `.gitea/workflows/release.yml` assembles the bundle under
|
||||
# /dist and packs it from there. Note this is the ROOT dist only — the module's
|
||||
# two committed generated artifacts, swagger-fragment.json and
|
||||
# routes.manifest.json, are deliberately NOT ignored: core merges the first
|
||||
# verbatim and the second is the frozen URL surface, so both have to be
|
||||
# reviewable in a diff (MODULE_API.md §2.8, §5.3).
|
||||
/dist/
|
||||
*.tar.gz
|
||||
|
||||
# logs / os
|
||||
|
||||
126
README.md
126
README.md
@@ -24,7 +24,7 @@ The module's **id** is `uo` — that is what appears in `module.json`, in the `i
|
||||
table, in the `modules/<id>/` path on disk and in the URL segment (`/uo/*`, `/admin/uo/*`,
|
||||
`/player/uo/*`). `Module-uo` is the repository; `module-uo` is the module and its release artifact.
|
||||
|
||||
## Status: the bundle skeleton exists; the extraction has started
|
||||
## Status: the extraction is complete; this repo is the UO half of the site
|
||||
|
||||
The design of record is
|
||||
[`website/MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md)
|
||||
@@ -37,32 +37,50 @@ in the docs repo — **read them before opening a PR here.** Where the two diffe
|
||||
| 0 — CI trigger fix, cut `website` `edge`, bootstrap this repo | `website`, here | ✅ done |
|
||||
| 1 — module API contract (`docs/website/MODULE_API.md`) + the atlas spike | `docs`, `website` | ✅ done |
|
||||
| 2 — core scaffolding: loader, `installed_modules`, registries, client registry | `website` | ✅ done |
|
||||
| 3 — extract the UO half of the site into this repo | `website`, here | 🟡 in progress |
|
||||
| 3 — extract the UO half of the site into this repo | `website`, here | ✅ done |
|
||||
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ |
|
||||
|
||||
Phase 3 moves the UO half of `website/` here in ten slices (`MODULE_SYSTEM.md` §2.7.1), server-first
|
||||
and then client. Each slice is one PR here that adds, and one PR in `website` that deletes — this one
|
||||
merging first, so `website`'s `edge` branch serves the feature from core right up to the moment core
|
||||
drops it.
|
||||
Phase 3 moved the UO half of `website/` here in six slices (`MODULE_SYSTEM.md` §2.7.1): the bundle
|
||||
skeleton, the whole server half, core's client extension slots, the whole client half, the de-UO of
|
||||
core's own copy, and this one — the artifacts that make the result installable and checkable. Each
|
||||
slice was one PR here that added and one in `website` that deleted, this one merging first, so
|
||||
`website`'s `edge` branch served each feature from core right up to the moment core dropped it.
|
||||
|
||||
**Slice 0 is the bundle skeleton, and it registers nothing on purpose.** What it proves is the
|
||||
delivery path itself: core discovers the module, validates `module.json`, calls `register()`, serves
|
||||
the client chunk, injects it, and reports the module `started` — and the chunk resolves React, the
|
||||
renderer and the router from core's `window.__rg` rather than bundling its own. Every slice after
|
||||
this one adds registrations to `server/index.js` and `client/src/entry.jsx`.
|
||||
Neither half sliced by feature in the end, and for the same reason on both sides: a mount prefix is
|
||||
claimed whole and a shared leaf moves with its **last** consumer, so the closure of either half is
|
||||
the whole half.
|
||||
|
||||
**What core serves and what this repo serves is now a fact you can read**, not a claim: 72 URLs, in
|
||||
[`routes.manifest.json`](routes.manifest.json), derived by loading this module into a real core and
|
||||
diffing. Not one of core's own URLs moved — that is the promise `MODULE_SYSTEM.md` §1.2 makes to the
|
||||
shipped Android app and the Discord bot, and it is checked on every PR.
|
||||
|
||||
## Working on it
|
||||
|
||||
```bash
|
||||
npm ci --prefix server && npm test --prefix server && npm run check:imports --prefix server
|
||||
npm run check:swagger --prefix server # is swagger-fragment.json still current?
|
||||
npm ci --prefix client && npm test --prefix client && npm run build --prefix client
|
||||
npm run check:externals --prefix client # asks the BUILT chunk, so it runs after the build
|
||||
```
|
||||
|
||||
The two `check:*` scripts are the contract's acceptance criteria rather than this module's own tests:
|
||||
no import may escape the module root (`MODULE_API.md` §5.1), and no bare specifier may survive into
|
||||
the built chunk (§3.6). The matching failure — a shared dependency being *bundled* — fails the build
|
||||
itself, from a guard inside `vite.config.js`.
|
||||
The `check:*` scripts are the contract's acceptance criteria rather than this module's own tests: no
|
||||
import may escape the module root (`MODULE_API.md` §5.1), no bare specifier may survive into the
|
||||
built chunk (§3.6), and the OpenAPI fragment core merges must describe the routes registered today
|
||||
(§2.8). The matching build failure — a shared dependency being *bundled* — comes from a guard inside
|
||||
`vite.config.js`.
|
||||
|
||||
**Changed a route, or its `#swagger` annotations?** `npm run swagger --prefix server` regenerates
|
||||
`swagger-fragment.json`; commit it. Core cannot generate it — core is a prebuilt image and this
|
||||
module mounts through a call no static parser can follow — so the file this repo commits is the one
|
||||
an operator's `/api/docs` shows.
|
||||
|
||||
**Changed a mount prefix, or added a route?** `routes.manifest.json` is regenerated by the
|
||||
`frozen-manifest` CI job, which clones core at the ref pinned in [`ci/core-ref.json`](ci/core-ref.json),
|
||||
loads this module into it and takes the difference. To do it locally, check this repo out into that
|
||||
core as `modules/uo` (**copy it — a symlink is silently skipped by the loader**), run core's
|
||||
`npm run routes:manifest` with and without it, and hand both files to
|
||||
`server/scripts/frozenManifest.js`.
|
||||
|
||||
Running it against a real core means checking this repo out as `website/modules/uo`, building the
|
||||
client half, and booting core. The four-step browser smoke in `MODULE_API.md` §7.7 is the only thing
|
||||
@@ -72,24 +90,35 @@ neither has a shape a DOM-less test runner can see.
|
||||
## What it contains
|
||||
|
||||
One repo, one bundle: the server half and the client half live side by side and version together, so
|
||||
a route and the screen that calls it can never be mismatched. A ✅ is in the tree today.
|
||||
a route and the screen that calls it can never be mismatched.
|
||||
|
||||
```
|
||||
module.json ✅ id, version, coreApi range, mounts, extensions
|
||||
server/index.js ✅ the entry point — register(ctx, api), synchronous, no database
|
||||
server/scripts/ ✅ checkImports.js — the §5.1 boundary check
|
||||
server/test/ ✅ node --test, with a fake ctx standing in for core
|
||||
server/ routers, controllers, models, utils
|
||||
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
|
||||
server/db/purge.sql destructive; only ever run by an explicit purge
|
||||
client/src/entry.jsx ✅ the chunk's entry — registers routes, nav, feature provider
|
||||
client/src/shim/ ✅ react, react-dom, react-router-dom, jsx-runtime, from window.__rg
|
||||
client/vite.config.js ✅ the library build, the aliases, the not-bundled guard
|
||||
client/src/ route components, nav registrations, feature provider
|
||||
client/dist/ ✅ PREBUILT ESM chunk, built by CI — never by an operator
|
||||
module.json id, version, coreApi range, mounts, extensions
|
||||
swagger-fragment.json generated · the OpenAPI core merges into /api/docs.json
|
||||
routes.manifest.json generated · the 72 URLs this module serves
|
||||
ci/core-ref.json the core commit the two above were proved against
|
||||
server/index.js the entry point — register(ctx, api), synchronous, no database
|
||||
server/router/ routers + controllers, one directory per tier
|
||||
server/model/ one directory per table family; nothing crosses the boundary
|
||||
server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas
|
||||
server/config/ the push stream catalog
|
||||
server/db/schema.sql idempotent fragment, replayed by core's ensureSchema()
|
||||
server/db/purge.sql destructive; only ever run by an explicit purge
|
||||
server/scripts/ the three checks: imports, the fragment, the frozen manifest
|
||||
server/test/ node --test, with a fake ctx standing in for core
|
||||
client/src/entry.jsx the chunk's entry — registers routes, nav, slots, feature provider
|
||||
client/src/shim/ react, react-dom, react-router-dom, jsx-runtime, from window.__rg
|
||||
client/vite.config.js the library build, the aliases, the not-bundled guard
|
||||
client/dist/ PREBUILT ESM chunk, built by CI — never by an operator
|
||||
```
|
||||
|
||||
Release artifact: `module-uo-<version>.tar.gz`, plus a manifest carrying its `sha256`.
|
||||
**The three generated files are committed on purpose.** Two of them are what core reads instead of
|
||||
looking at this source — it never has it — and the third records which core they were proved against.
|
||||
A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a
|
||||
diff.
|
||||
|
||||
Release artifact: `module-uo-<version>.tar.gz`, plus `module-uo-<version>.json` carrying its
|
||||
`sha256`. See below.
|
||||
|
||||
## How it reaches an operator
|
||||
|
||||
@@ -103,6 +132,45 @@ The [installer](https://gitea.whitlocktech.com/RunicGateway/installer) is **not*
|
||||
It deploys the *shard* side — the plugin overlay and the uo-link sidecar — and never contacts the
|
||||
website. Module delivery is website-side only.
|
||||
|
||||
### Releases
|
||||
|
||||
A merge to `main` that leaves `module.json` at a version with no release yet publishes one. The
|
||||
version is **declared**, not computed from commit subjects: `module.json`'s version is what core
|
||||
records in `installed_modules` and shows on the admin screen, and it sits beside the `coreApi` range
|
||||
a bump usually has to be weighed against — two sources for one number is how they drift. Bumping it
|
||||
is an ordinary reviewed PR.
|
||||
|
||||
Each release carries:
|
||||
|
||||
| Asset | What it is |
|
||||
|---|---|
|
||||
| `module-uo-<version>.tar.gz` | the directory core expects at `modules/uo/` — already assembled, with the chunk built and `ws` installed |
|
||||
| `module-uo-<version>.json` | id, version, `coreApi`, the artifact's URL, size and **`sha256`** |
|
||||
| `SHA256SUMS` | the same hash, in the shape every other repo here publishes |
|
||||
|
||||
Releases are **unsigned**; the `sha256` is the trust anchor, and the website verifies it before
|
||||
unpacking. That is the model `installer`'s bundles already use, and a second trust model would be a
|
||||
second thing to get right.
|
||||
|
||||
The tarball is assembled from an **include** list, never an exclude list — an exclude list ships
|
||||
whatever it forgot. Tests, scripts, `client/src` and the dev dependencies are not in it.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Four, all optional, all read by this module rather than by core — which is why they are documented
|
||||
here and not in core's `.env.example`. In Docker they go in the Compose `.env`, since that is what
|
||||
reaches the container.
|
||||
|
||||
| Var | Default | What |
|
||||
|---|---|---|
|
||||
| `UOLINK_BASE_URL` | — | Default sidecar base URL for a site with nothing saved yet. The admin panel's stored value wins. |
|
||||
| `UOLINK_WS_URL` | — | Same, for the WebSocket URL. |
|
||||
| `UOLINK_PROTOCOL` | `3` | Wire protocol this build speaks. Again only a fallback — set it lower only if you deliberately run an older sidecar. |
|
||||
| `TOWNCRIER_DURATION_SEC` | `3600` | How long a published news post's in-game town-crier message stays up (≤ `86400`). |
|
||||
|
||||
**The sidecar's auth token is deliberately not here.** It is entered in Admin → Shard, encrypted at
|
||||
rest with core's `SECRET_ENC_KEY`, and write-only in the API — never returned to any client.
|
||||
|
||||
## Compatibility
|
||||
|
||||
`module.json` declares a `coreApi` semver range, checked at boot against core's `MODULE_API_VERSION`.
|
||||
|
||||
6
ci/core-ref.json
Normal file
6
ci/core-ref.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "87230c879aa6e9adde3507718aed6bc4e4d86009",
|
||||
"refName": "edge @ phase 3 slice 4 (website#140)"
|
||||
}
|
||||
@@ -27,53 +27,146 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const CHUNK = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'entry.js')
|
||||
|
||||
if (!fs.existsSync(CHUNK)) {
|
||||
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
||||
process.exit(1)
|
||||
/**
|
||||
* Which characters of the chunk are inside a string, template or comment.
|
||||
*
|
||||
* **A check that reads code with a regexp fails on code that talks about
|
||||
* itself.** The first real chunk this script ever saw — slice 3's, the first
|
||||
* with any content in it — was rejected for importing `" }),\n !l && …`,
|
||||
* because a button reading "Approve and import" put the token `import`
|
||||
* immediately before a quote and the pattern could not tell that from a
|
||||
* statement. Slice 0's chunk was 0.2 kB and this branch had never run against
|
||||
* anything.
|
||||
*
|
||||
* The server half hit the same wall from the other side and answered it the same
|
||||
* way (`server/scripts/checkImports.js`): a character walk, not a cleverer
|
||||
* regexp. There is no regexp that distinguishes a keyword from the same letters
|
||||
* inside a string, because that distinction is a property of the parse.
|
||||
*
|
||||
* A mask rather than a rewrite, because the two halves of a real import — the
|
||||
* keyword and the specifier — sit on opposite sides of the boundary: the keyword
|
||||
* must be OUTSIDE a string and the specifier must be a string. Blanking strings
|
||||
* would take the answer with the noise.
|
||||
*/
|
||||
export function stringMask(src) {
|
||||
const inString = new Uint8Array(src.length)
|
||||
let i = 0
|
||||
while (i < src.length) {
|
||||
const c = src[i]
|
||||
const two = src.slice(i, i + 2)
|
||||
if (two === '//') {
|
||||
const nl = src.indexOf('\n', i)
|
||||
const end = nl === -1 ? src.length : nl
|
||||
inString.fill(1, i, end)
|
||||
i = end
|
||||
} else if (two === '/*') {
|
||||
const close = src.indexOf('*/', i + 2)
|
||||
const end = close === -1 ? src.length : close + 2
|
||||
inString.fill(1, i, end)
|
||||
i = end
|
||||
} else if (c === '"' || c === "'" || c === '`') {
|
||||
// The opening quote itself stays unmasked: a specifier is read starting
|
||||
// at its quote, and the regexp below anchors on that.
|
||||
i += 1
|
||||
while (i < src.length && src[i] !== c) {
|
||||
// A backslash escapes the next character, including the closing quote.
|
||||
const step = src[i] === '\\' ? 2 : 1
|
||||
inString.fill(1, i, Math.min(i + step, src.length))
|
||||
i += step
|
||||
}
|
||||
i += 1
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return inString
|
||||
}
|
||||
|
||||
const chunk = fs.readFileSync(CHUNK, 'utf8')
|
||||
const problems = []
|
||||
|
||||
// Static and dynamic imports that survived into the output. A relative or
|
||||
// absolute specifier is a chunk that was split, which this build does not do —
|
||||
// `lib` mode with one entry emits one file — so anything here is a bare name.
|
||||
const IMPORTS = /(?:^|[\s;}])(?:import\s+[^'"]*?from\s*|import\s*|import\()\s*['"]([^'"]+)['"]/g
|
||||
const bare = new Set()
|
||||
for (const [, specifier] of chunk.matchAll(IMPORTS)) {
|
||||
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
|
||||
}
|
||||
if (bare.size) {
|
||||
problems.push(
|
||||
`the chunk still imports ${[...bare].map((s) => `"${s}"`).join(', ')} — ` +
|
||||
'nothing can resolve a bare specifier in the browser without an import map, ' +
|
||||
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
//
|
||||
// **This pattern used to require whitespace after `import`, and so could not see
|
||||
// the one shape the build actually emits.** Minified Rollup output is
|
||||
// `import{useState}from"react"`, with no space anywhere in it; the old
|
||||
// `import\s+[^'"]*?from` needed at least one, fell through to the bare-specifier
|
||||
// alternative, met `{` instead of a quote and matched nothing. A bare named
|
||||
// import — the most likely way for an alias to miss — would have passed this
|
||||
// check silently. It was found by writing the test for the false POSITIVE above
|
||||
// it, which is the argument for testing a check against both answers.
|
||||
//
|
||||
// `(?:^|[^\w$.])` rather than a whitespace class, so `a.import(x)` and
|
||||
// `myimport"x"` are excluded for the right reason: `import` must not be preceded
|
||||
// by an identifier character or a dot. `[^'"()]*?` cannot swallow a dynamic
|
||||
// import's parenthesis.
|
||||
const IMPORTS = /(?:^|[^\w$.])import\s*(?:\(\s*|[^'"()]*?from\s*)?['"]([^'"]+)['"]/g
|
||||
|
||||
/** Every bare specifier the chunk still imports at runtime. */
|
||||
export function bareImports(chunk) {
|
||||
const masked = stringMask(chunk)
|
||||
const bare = new Set()
|
||||
for (const match of chunk.matchAll(IMPORTS)) {
|
||||
// Where the `import` keyword itself starts — one past the leading delimiter,
|
||||
// unless the match began at position 0.
|
||||
const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
|
||||
if (masked[keywordAt]) continue // the letters, inside a string. Not a statement.
|
||||
const specifier = match[1]
|
||||
if (!specifier.startsWith('.') && !specifier.startsWith('/')) bare.add(specifier)
|
||||
}
|
||||
return [...bare]
|
||||
}
|
||||
|
||||
// Fingerprints from the shared libraries' own source. Each is a string those
|
||||
// packages ship and this module has no other reason to contain.
|
||||
//
|
||||
// These are matched against the RAW chunk, deliberately unmasked: a bundled
|
||||
// library's source arrives as code AND as its own error-message strings, and
|
||||
// masking would discard half the evidence. The direction of the risk is opposite
|
||||
// to the import check's — here a false positive is a fingerprint too generic,
|
||||
// which is a fixable choice of probe, not a property of the parse.
|
||||
const BUNDLED = [
|
||||
{ what: 'react', probe: 'react.development.js' },
|
||||
{ what: 'react', probe: 'Invalid hook call' },
|
||||
{ what: 'react-dom', probe: 'react-dom.development.js' },
|
||||
{ what: 'react-router-dom', probe: 'useRoutes() may be used only in the context of a <Router> component' },
|
||||
]
|
||||
for (const { what, probe } of BUNDLED) {
|
||||
if (chunk.includes(probe)) {
|
||||
|
||||
/** Every problem with this chunk, as sentences. Empty means it ships. */
|
||||
export function problemsWith(chunk) {
|
||||
const problems = []
|
||||
const bare = bareImports(chunk)
|
||||
if (bare.length) {
|
||||
problems.push(
|
||||
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
|
||||
'There is exactly one React in the page and core owns it — a second copy ' +
|
||||
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
|
||||
`the chunk still imports ${bare.map((s) => `"${s}"`).join(', ')} — ` +
|
||||
'nothing can resolve a bare specifier in the browser without an import map, ' +
|
||||
'and CSP forbids one. Alias it to a shim in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
}
|
||||
for (const { what, probe } of BUNDLED) {
|
||||
if (chunk.includes(probe)) {
|
||||
problems.push(
|
||||
`the chunk appears to BUNDLE ${what} (found ${JSON.stringify(probe)}). ` +
|
||||
'There is exactly one React in the page and core owns it — a second copy ' +
|
||||
'loads fine and then fails at the first hook (MODULE_API.md §3.2).',
|
||||
)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
|
||||
for (const p of problems) console.error(` - ${p}\n`)
|
||||
process.exit(1)
|
||||
// Only when run as a script. Importing this from a test must not read a chunk
|
||||
// that may not have been built, and must not call process.exit.
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
if (!fs.existsSync(CHUNK)) {
|
||||
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
|
||||
if (problems.length) {
|
||||
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
|
||||
for (const p of problems) console.error(` - ${p}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
|
||||
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
|
||||
}
|
||||
|
||||
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
|
||||
console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
|
||||
|
||||
231
client/src/api.js
Normal file
231
client/src/api.js
Normal file
@@ -0,0 +1,231 @@
|
||||
// ── This module's own API bindings ─────────────────────────────────────────
|
||||
//
|
||||
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
|
||||
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, `ApiError` on
|
||||
// a non-2xx. The paths are ours, because the routes at the other end are ours —
|
||||
// `server/router/**` in this repo serves every one of them.
|
||||
//
|
||||
// This file is the client half of the pair that moved in slice 1, and the two
|
||||
// halves are checked against each other by nothing but review, so the ordering
|
||||
// below mirrors the router tree deliberately: public, then admin, then player.
|
||||
//
|
||||
// **The URLs are unchanged from the ones core used to call.** MODULE_SYSTEM.md
|
||||
// §1.2 freezes the API surface across the extraction — the shipped Android app
|
||||
// calls `/api/v1/admin/shard/kick` and six of its neighbours — so what moved is
|
||||
// which repo declares them, never what they are. Only the SPA route paths
|
||||
// changed (`/uo/*`, `/admin/uo/*`, `/player/uo/*`), and those are not API URLs.
|
||||
|
||||
import rg from './core.js'
|
||||
|
||||
const { request: req, BASE } = rg.api
|
||||
|
||||
/** Prefix a non-empty query string with "?" — core's `withQs`, which is not in the kit. */
|
||||
const withQs = (s) => (s ? `?${s}` : '')
|
||||
|
||||
// ── public: live shard data (uo-link) ──────────────────────────────────────
|
||||
// Token-free, same-origin reads backed by the ingested feed plus a cached live
|
||||
// character round-trip.
|
||||
export const shard = {
|
||||
status: () => req('/public/shard/status'),
|
||||
feed: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.kind) qs.set('kind', opts.kind)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
return req(`/public/shard/feed${withQs(qs.toString())}`)
|
||||
},
|
||||
economy: (limit) => req(`/public/shard/economy${withQs(limit ? `limit=${limit}` : '')}`),
|
||||
online: () => req('/public/shard/online'),
|
||||
idoc: () => req('/public/shard/idoc'),
|
||||
champs: () => req('/public/shard/champs'),
|
||||
// Protocol 2.0 boards.
|
||||
guilds: () => req('/public/shard/guilds'),
|
||||
governors: () => req('/public/shard/governors'),
|
||||
governorHistory: (city, limit) =>
|
||||
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),
|
||||
presence: () => req('/public/shard/presence'),
|
||||
houses: () => req('/public/shard/houses'),
|
||||
// Protocol 3.0: the shard's published ruleset. Resolves to null when the shard
|
||||
// has never published one — a real answer, not an error.
|
||||
ruleset: () => req('/public/shard/ruleset'),
|
||||
// Protocol 3.0: points/loyalty leaderboards, one board per point system.
|
||||
// `pointsBoard` 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 — and, as
|
||||
// of slice 3, also carries `gameAccountSignup`: whether this site offers
|
||||
// game-account creation at all (see server/router/public/shard.controller.js).
|
||||
features: () => req('/public/shard/features'),
|
||||
}
|
||||
|
||||
// ── public: the 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.
|
||||
export const atlas = {
|
||||
creatures: (opts = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (opts.q) qs.set('q', opts.q)
|
||||
if (opts.facet) qs.set('facet', opts.facet)
|
||||
if (opts.limit) qs.set('limit', opts.limit)
|
||||
if (opts.offset) qs.set('offset', opts.offset)
|
||||
return 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'),
|
||||
}
|
||||
|
||||
// ── admin ──────────────────────────────────────────────────────────────────
|
||||
export const admin = {
|
||||
// The account/character/house reads a staff member makes across the whole shard.
|
||||
shard: {
|
||||
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
||||
accounts: () => req('/admin/shard/accounts'),
|
||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/admin/shard/sales'),
|
||||
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
||||
createAccount: (account, password) =>
|
||||
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
|
||||
},
|
||||
|
||||
// The sidecar's own configuration and the town crier it drives.
|
||||
getUoLinkConfig: () => req('/admin/uo-link/config'),
|
||||
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
|
||||
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
|
||||
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// Whether this site offers game-account creation, and in which direction.
|
||||
// Core's Site Settings used to carry this; it is ours as of slice 3, because
|
||||
// "the game server's own SignupMode must agree" is not a sentence core can own.
|
||||
getSignupMode: () => req('/admin/uo-link/signup-mode'),
|
||||
saveSignupMode: (mode) => req('/admin/uo-link/signup-mode', { method: 'PUT', body: { mode } }),
|
||||
|
||||
// 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 } }),
|
||||
|
||||
// The atlas re-derives itself from the ServUO tree on every boot; these are for
|
||||
// applying a map change without a restart, and for the approve/reject decision
|
||||
// on a refresh that would remove a facet.
|
||||
atlas: {
|
||||
status: () => req('/admin/shard/atlas'),
|
||||
import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }),
|
||||
approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }),
|
||||
reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }),
|
||||
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
||||
},
|
||||
|
||||
// In-game staff operations: write plane + support queue (admin/moderator).
|
||||
// `actor` is stamped server-side from the session — never sent from here.
|
||||
shardOps: {
|
||||
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
|
||||
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
|
||||
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
|
||||
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
|
||||
pages: () => req('/admin/shard/pages'),
|
||||
respondPage: (id, data) => req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
|
||||
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
|
||||
audit: (limit) => req(`/admin/shard/audit${withQs(limit ? `limit=${limit}` : '')}`),
|
||||
},
|
||||
|
||||
/**
|
||||
* One user's shard presence, for the `admin.users.detail` extension slot.
|
||||
*
|
||||
* A factory rather than a flat namespace because every call is scoped to the
|
||||
* user whose page this is. The three that are NOT — roster, vendors, char —
|
||||
* are keyed by an account or a serial the scoped calls just returned, and they
|
||||
* are the same routes `admin.shard` uses; they are repeated here so the slot's
|
||||
* components take one `scope` object and never reach for a second one.
|
||||
*/
|
||||
userShard: (id) => ({
|
||||
accounts: () => req(`/admin/users/${id}/shard/accounts`),
|
||||
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req(`/admin/users/${id}/shard/sales`),
|
||||
houses: () => req(`/admin/users/${id}/shard/houses`),
|
||||
online: () => req(`/admin/users/${id}/shard/online`),
|
||||
standing: () => req(`/admin/users/${id}/shard/standing`),
|
||||
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
|
||||
}),
|
||||
}
|
||||
|
||||
// ── player self-service ────────────────────────────────────────────────────
|
||||
// Mirrors `admin.shard`, self-scoped: the server derives the caller from the
|
||||
// session and never takes an account id from the client.
|
||||
export const player = {
|
||||
shard: {
|
||||
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
|
||||
accounts: () => req('/player/shard/accounts'),
|
||||
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
|
||||
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
|
||||
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
|
||||
sales: () => req('/player/shard/sales'),
|
||||
houses: () => req('/player/shard/houses'), // the caller's own houses
|
||||
createAccount: (account, password) =>
|
||||
req('/player/shard/account', { method: 'POST', body: { account, password } }),
|
||||
},
|
||||
}
|
||||
|
||||
// ── SSE endpoints ──────────────────────────────────────────────────────────
|
||||
// Full paths including `/api/v1`, because `request` is fetch-only and an
|
||||
// EventSource builds its own URL. `BASE` is core's — it owns where the API is
|
||||
// mounted, and a module hardcoding `/api/v1` would be asserting something about
|
||||
// core that core has not promised (MODULE_API.md §3.5).
|
||||
//
|
||||
// The admin stream carries every kind, including audit and cheat detection, and
|
||||
// needs the staff session cookie.
|
||||
export const shardStreamUrl = `${BASE}/public/shard/stream`
|
||||
export const adminShardStreamUrl = `${BASE}/admin/uo-link/stream`
|
||||
|
||||
export const api = { shard, atlas, admin, player, shardStreamUrl, adminShardStreamUrl }
|
||||
|
||||
export default api
|
||||
293
client/src/components/CharacterSheet.jsx
Normal file
293
client/src/components/CharacterSheet.jsx
Normal file
@@ -0,0 +1,293 @@
|
||||
// Reusable character-sheet renderer for the char.profile shape returned by
|
||||
// /public/shard/char/:serial. Presentational only — the parent handles loading
|
||||
// and errors. Styled with the shared theme vocabulary (panel/grid/stat tiles).
|
||||
//
|
||||
// `moderation` opts in the in-game kick/ban controls for the character's account;
|
||||
// they self-gate to staff (ShardAccountActions), so passing it from a page a
|
||||
// player can reach is safe.
|
||||
|
||||
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
|
||||
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||
|
||||
// What to call an equipped item.
|
||||
//
|
||||
// Items on the wire carry a `LabelNumber`, not a name, so this used to be able
|
||||
// to show nothing but the layer and `id 12345`. The server now resolves the
|
||||
// cliloc against its own table and attaches `clilocName` (see
|
||||
// docs/website/CLILOCS.md); a shard with no cliloc file configured sends none,
|
||||
// and the layer fallback below is exactly what the sheet did before.
|
||||
//
|
||||
// A player-given `name` outranks the resolved type name — "Bob's lucky axe"
|
||||
// should not be relabelled "hatchet" — and the server applies the same
|
||||
// precedence, so this only re-states it for a profile that arrived with both.
|
||||
const itemName = (it) => it.name || it.clilocName || it.layer || 'Item'
|
||||
|
||||
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
|
||||
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
|
||||
// literal string.
|
||||
//
|
||||
// `rewardResolved` is the server's parallel array with the numeric entries turned
|
||||
// into words (null where the cliloc table had nothing, or is not configured at
|
||||
// all). Prefer it, and keep the literal-only path as the fallback for a profile
|
||||
// served before the cliloc table existed — a numeric entry with no resolution is
|
||||
// still skipped rather than shown as a raw number.
|
||||
function displayTitles(titles) {
|
||||
if (!titles) return []
|
||||
const out = []
|
||||
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||
if (titles.skill) out.push(titles.skill)
|
||||
const raw = Array.isArray(titles.reward) ? titles.reward : []
|
||||
const resolved = Array.isArray(titles.rewardResolved) ? titles.rewardResolved : null
|
||||
const reward = raw.map((r, i) => resolved?.[i] ?? (/^\d+$/.test(String(r)) ? null : String(r)))
|
||||
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||
// Prefer the selected reward title; fall back to the first one that resolved.
|
||||
// The `??` matters: a selected title whose cliloc did not resolve must fall
|
||||
// through to the fallback rather than suppress the chip entirely.
|
||||
const candidate = (sel >= 0 && sel < reward.length ? reward[sel] : null) ?? reward.find(Boolean)
|
||||
if (candidate) out.push(String(candidate))
|
||||
return [...new Set(out.filter(Boolean))]
|
||||
}
|
||||
|
||||
// The char.profile `points` block (Protocol 3.0 §7.3): one entry per point system
|
||||
// the character actually holds a score in. Systems at zero are omitted by the
|
||||
// shard, so an empty list means "this character has earned nothing anywhere",
|
||||
// which is a normal state for a new character and renders as nothing at all.
|
||||
//
|
||||
// `nameString` may be null when the system's name is a cliloc; fall back to
|
||||
// humanising the PointsType key, exactly as the leaderboards page does. `rank` is
|
||||
// absent unless the shard runs with Bridge.cfg PointsProfileRank=true — absent and
|
||||
// "unranked" are different, so the chip only appears when it was actually sent.
|
||||
const humanisePoints = (key) =>
|
||||
String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
|
||||
function PointsRow({ entry }) {
|
||||
const label = entry.nameString || humanisePoints(entry.system)
|
||||
const max = Number.isFinite(entry.maxPoints) && entry.maxPoints > 0 ? entry.maxPoints : 0
|
||||
const pct = max ? Math.min(100, Math.round((entry.points / max) * 100)) : 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3, gap: 10 }}>
|
||||
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>
|
||||
{label}
|
||||
{Number.isFinite(entry.rank) && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}> · #{entry.rank}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem', flex: 'none' }}>
|
||||
{(entry.points ?? 0).toLocaleString()}
|
||||
{max > 0 && <span className="dim"> / {max.toLocaleString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
{/* Only systems with a real cap get a bar; an uncapped score has nothing to
|
||||
be a fraction of, and a full-width bar would imply completion. */}
|
||||
{max > 0 && (
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
|
||||
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatTile({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.35rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 4 }}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Vital({ label, cur, max }) {
|
||||
const pct = max ? Math.min(100, Math.round((cur / max) * 100)) : 0
|
||||
return (
|
||||
<div className="panel" style={{ padding: '12px 14px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||
<span className="sans" style={{ color: 'var(--accent)', fontSize: '0.64rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>{label}</span>
|
||||
<span className="display" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>{cur ?? '—'}<span className="dim" style={{ fontSize: '0.8rem' }}> / {max ?? '—'}</span></span>
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CharacterSheet({ char, moderation = false }) {
|
||||
if (!char) return null
|
||||
const stats = char.stats || {}
|
||||
const resist = stats.resist || {}
|
||||
// Skills the character actually has, best first.
|
||||
const skills = (char.skills || [])
|
||||
.filter((s) => (s.value || s.base || 0) > 0)
|
||||
.sort((a, b) => (b.value || 0) - (a.value || 0))
|
||||
const equipment = char.equipment || []
|
||||
// Best standing first, so the character's strongest loyalty leads. Guarded for
|
||||
// an older shard plugin that sends no `points` block at all.
|
||||
const points = (Array.isArray(char.points) ? char.points : [])
|
||||
.filter((p) => p && (p.points || 0) > 0)
|
||||
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
{/* Identity */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>{char.name || 'Unknown'}</h2>
|
||||
{char.title && <span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>{char.title}</span>}
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px', borderRadius: 999,
|
||||
border: '1px solid var(--line)', fontSize: '0.74rem',
|
||||
color: char.online ? '#7fd0a4' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: char.online ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{char.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||
</div>
|
||||
|
||||
{/* Titles + standing (guild led / governorship) — all optional */}
|
||||
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
|
||||
{char.governorOf && char.governorOf.map((city) => (
|
||||
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
|
||||
))}
|
||||
{char.guild && (
|
||||
<TitleChip tone="var(--accent)">
|
||||
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
|
||||
</TitleChip>
|
||||
)}
|
||||
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Staff moderation for this character's account (self-gates to staff). */}
|
||||
{moderation && char.acct && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
|
||||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>Account <strong style={{ color: 'var(--ink)' }}>{char.acct}</strong></span>
|
||||
<ShardAccountActions account={char.acct} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Core stats */}
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Attributes</div>
|
||||
<div className="grid-3" style={{ gap: 12 }}>
|
||||
<StatTile value={stats.str ?? '—'} label="Strength" />
|
||||
<StatTile value={stats.dex ?? '—'} label="Dexterity" />
|
||||
<StatTile value={stats.int ?? '—'} label="Intelligence" />
|
||||
</div>
|
||||
<div className="grid-3" style={{ gap: 12, marginTop: 12 }}>
|
||||
<Vital label="Hits" cur={stats.hits} max={stats.hitsMax} />
|
||||
<Vital label="Mana" cur={stats.mana} max={stats.manaMax} />
|
||||
<Vital label="Stamina" cur={stats.stam} max={stats.stamMax} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Resistances */}
|
||||
{Object.keys(resist).length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Resistances</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{['phys', 'fire', 'cold', 'pois', 'energy'].map((k) => (
|
||||
<div key={k} className="panel" style={{ padding: '10px 16px', textAlign: 'center', minWidth: 84 }}>
|
||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.1rem' }}>{resist[k] ?? 0}</div>
|
||||
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.66rem', textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 2 }}>{RESIST_LABELS[k]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Skills <span className="dim">({skills.length})</span></div>
|
||||
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||
{skills.map((s) => {
|
||||
const cap = s.cap || 100
|
||||
const pct = Math.min(100, Math.round(((s.value || 0) / cap) * 100))
|
||||
return (
|
||||
<div key={s.n}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }}>
|
||||
<span className="sans" style={{ color: 'var(--ink)', fontSize: '0.86rem' }}>{s.n}</span>
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.82rem' }}>{s.value}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 999, background: 'var(--line)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: 'var(--accent)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Loyalty & points — one entry per system this character has scored in */}
|
||||
{points.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>
|
||||
Loyalty & points <span className="dim">({points.length})</span>
|
||||
</div>
|
||||
<div className="grid-2" style={{ gap: '8px 18px' }}>
|
||||
{points.map((p) => (
|
||||
<PointsRow key={p.system} entry={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{equipment.length > 0 && (
|
||||
<section>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Equipment</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{equipment.map((it) => {
|
||||
const label = itemName(it)
|
||||
const layer = it.layer || 'Item'
|
||||
// The layer only earns its own line once the headline is a real
|
||||
// name; when it IS the headline, repeating it is just noise.
|
||||
const detail = [label === layer ? null : layer, `id ${it.itemId}`, it.hue ? `hue ${it.hue}` : null]
|
||||
return (
|
||||
<div key={it.serial} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ flex: 'none', width: 22, height: 22, borderRadius: 5, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.05)' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.88rem' }}>{label}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem' }}>{detail.filter(Boolean).join(' · ')}</div>
|
||||
</div>
|
||||
{it.mods && Object.keys(it.mods).length > 0 && (
|
||||
<div className="sans" style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: '55%' }}>
|
||||
{Object.entries(it.mods).map(([k, v]) => (
|
||||
<span key={k} className="pill" style={{ fontSize: '0.7rem', padding: '2px 8px' }}>{k} {v}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
client/src/components/CharacterStats.jsx
Normal file
79
client/src/components/CharacterStats.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// A small stat-tile row for a "My Characters" page: total characters, how many
|
||||
// are online right now, and how many game accounts are linked. `scope` is the
|
||||
// shard api object (admin or player self-service). Renders nothing until an
|
||||
// account is linked, so the empty/link-prompt state below it stands alone.
|
||||
//
|
||||
// It fetches the same rosters GameAccounts loads; for a personal page that's at
|
||||
// most a couple of extra live round-trips, and keeps this presentational bit
|
||||
// decoupled from GameAccounts' per-account roster loading.
|
||||
|
||||
function Tile({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.68rem', fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase', marginTop: 8 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fold the settled roster results into totals. `complete` is false when any
|
||||
// account's roster failed (a partial result — shown as a dash rather than a
|
||||
// misleadingly low count).
|
||||
function summarizeRosters(rosters) {
|
||||
let chars = 0
|
||||
let online = 0
|
||||
let complete = true
|
||||
for (const r of rosters) {
|
||||
if (r.status !== 'fulfilled') {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
const cs = r.value.chars || []
|
||||
chars += cs.length
|
||||
online += cs.filter((c) => c.online).length
|
||||
}
|
||||
return { chars, online, complete }
|
||||
}
|
||||
|
||||
export default function CharacterStats({ scope }) {
|
||||
const [stats, setStats] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const accounts = await scope.accounts()
|
||||
const linked = accounts.length
|
||||
if (linked === 0) {
|
||||
if (!cancelled) setStats({ linked: 0 })
|
||||
return
|
||||
}
|
||||
// Roster is a live round-trip and can be unavailable (503); tolerate a
|
||||
// partial result so a restarting shard doesn't blank the whole row.
|
||||
const rosters = await Promise.allSettled(accounts.map((a) => scope.roster(a.account)))
|
||||
if (!cancelled) setStats({ linked, ...summarizeRosters(rosters) })
|
||||
} catch {
|
||||
if (!cancelled) setStats({ error: true })
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [scope])
|
||||
|
||||
// Hidden until we know an account is linked (or while first loading).
|
||||
if (!stats || stats.error || stats.linked === 0) return null
|
||||
|
||||
// Counts depend on live rosters; show a dash if none came back.
|
||||
const count = (n) => (stats.complete || stats.chars > 0 ? n : '—')
|
||||
|
||||
return (
|
||||
<section className="grid-3" style={{ gap: 14, marginBottom: 26 }}>
|
||||
<Tile value={count(stats.chars)} label="Characters" />
|
||||
<Tile value={count(stats.online)} label="Online now" />
|
||||
<Tile value={stats.linked} label={stats.linked === 1 ? 'Linked account' : 'Linked accounts'} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
69
client/src/components/CreateGameAccountForm.jsx
Normal file
69
client/src/components/CreateGameAccountForm.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// Reusable "create a game account" form (its own username + password — the game
|
||||
// client credentials, distinct from the website login). Calls `submit(account,
|
||||
// password)` which should POST /player/shard/account; on success calls onCreated.
|
||||
// Used by the player portal (self-serve) and the invite-accept page alike.
|
||||
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
|
||||
const [account, setAccount] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setMsg(''); setError('')
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
|
||||
return setError('Account name must be 3–30 letters, numbers, . _ or -.')
|
||||
}
|
||||
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||||
setBusy(true)
|
||||
try {
|
||||
await submit(account, password)
|
||||
setMsg(`Game account “${account}” created and linked.`)
|
||||
setAccount(''); setPassword('')
|
||||
if (onCreated) await onCreated()
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That account name is already taken.')
|
||||
else if (err.status === 429) setError('The account limit for your network has been reached.')
|
||||
else if (err.status === 403) setError('Game-account signup is not available right now.')
|
||||
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
|
||||
else setError(err.message || 'Could not create the account right now.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
{!compact && (
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Choose the username and password you’ll type into the game client. These are your
|
||||
<strong style={{ color: 'var(--head)' }}> game</strong> credentials — separate from your website login.
|
||||
</p>
|
||||
)}
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span className="field-label">Game account name</span>
|
||||
<input
|
||||
type="text" autoComplete="off" value={account}
|
||||
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Game password</span>
|
||||
<input
|
||||
type="password" autoComplete="new-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Creating…' : 'Create game account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
228
client/src/components/GameAccounts.jsx
Normal file
228
client/src/components/GameAccounts.jsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
|
||||
import api from '../api.js'
|
||||
import { useGameAccountSignup } from '../lib/useShardFeatures.js'
|
||||
import { ErrorState, Loading } from '../core.js'
|
||||
|
||||
// Shared game-account linking + character roster, used by both the player portal
|
||||
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||||
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
||||
// maps a serial to the route for that character's sheet. `readOnly` drops the
|
||||
// link forms and self-voice copy for the admin case where staff view *another*
|
||||
// user's accounts (no `scope.link`) at /admin/users/:id.
|
||||
|
||||
function LinkForm({ scope, onLinked, compact }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setMsg(''); setError('')
|
||||
if (!code.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const { account } = await scope.link(code.trim())
|
||||
setMsg(`Linked ${account}.`)
|
||||
setCode('')
|
||||
await onLinked()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not link that code.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
{!compact && <span className="field-label">Link code</span>}
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
placeholder="AB12CD"
|
||||
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Linking…' : 'Link account'}
|
||||
</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountRoster({ scope, account, charTo }) {
|
||||
const [roster, setRoster] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(''); setUnavailable(false)
|
||||
try {
|
||||
setRoster(await scope.roster(account))
|
||||
} catch (err) {
|
||||
if (err.status === 503) setUnavailable(true)
|
||||
else setError(err.message || 'Could not load this account.')
|
||||
}
|
||||
}, [scope, account])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div>
|
||||
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||||
<button className="pill" onClick={load}>Retry</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||||
|
||||
const chars = roster.chars || []
|
||||
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||||
|
||||
return (
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{chars.map((c) => (
|
||||
<Link
|
||||
key={c.serial}
|
||||
to={charTo(c.serial)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||||
{(c.name || '?').charAt(0)}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||||
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||||
</div>
|
||||
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
|
||||
// then calls onUnlink(account) and reloads. Errors surface inline.
|
||||
function UnlinkButton({ account, onUnlink }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
async function go() {
|
||||
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await onUnlink(account)
|
||||
} catch (err) {
|
||||
const byStatus = { 403: 'Protected account — refused.', 404: 'Not linked.' }
|
||||
setError(byStatus[err.status] || err.message || 'Could not unlink.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
|
||||
{busy ? 'Unlinking…' : 'Unlink'}
|
||||
</button>
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
|
||||
const [accounts, setAccounts] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
// Whether the site currently offers game-account creation. Only relevant for
|
||||
// the self-service (non-readOnly) view with a createAccount scope.
|
||||
//
|
||||
// From OUR public features endpoint as of slice 3, not core's public settings:
|
||||
// the flag derives from the `uo.game_account_signup` setting, which this module
|
||||
// owns, because "the game server's own SignupMode must agree" is not a sentence
|
||||
// core can own. Same cached call the nav gates use, so this costs no round-trip.
|
||||
const signupOk = useGameAccountSignup()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
setAccounts(await scope.accounts())
|
||||
} catch {
|
||||
setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.')
|
||||
}
|
||||
}, [scope, readOnly])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk === true
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!accounts) return <Loading />
|
||||
|
||||
// No linked accounts. In read-only (admin viewing another user) this is just an
|
||||
// empty state; otherwise it's the link-your-account prompt.
|
||||
if (accounts.length === 0) {
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
This user has not linked a game account.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||||
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||||
</p>
|
||||
<LinkForm scope={scope} onLinked={load} />
|
||||
</div>
|
||||
{canCreate && (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
|
||||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Linked — characters grouped by account.
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||
{accounts.map((a) => (
|
||||
<section key={a.account}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||
{a.account}
|
||||
</div>
|
||||
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
|
||||
</div>
|
||||
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
|
||||
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||
</section>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||
<LinkForm scope={scope} onLinked={load} compact />
|
||||
{canCreate && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
|
||||
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
client/src/components/InviteGameAccountStep.jsx
Normal file
51
client/src/components/InviteGameAccountStep.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react'
|
||||
import api from '../api.js'
|
||||
import { useGameAccountSignup } from '../lib/useShardFeatures.js'
|
||||
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
|
||||
|
||||
// ── This module's fill for the `player.invite.accepted` slot ───────────────
|
||||
//
|
||||
// Core's invite-acceptance page (`routes/player/AcceptInvite.jsx`) used to render
|
||||
// this step itself: it read `gameAccountSignup` out of core's public settings and
|
||||
// posted to `api.player.shard.createAccount`. Both of those are ours, and the
|
||||
// page they sat on is not — an invite is a core concept and staff get invited
|
||||
// too. So slice 3 declared a third extension slot rather than moving the page or
|
||||
// leaving core importing a module component. MODULE_API.md §3.7.
|
||||
//
|
||||
// **The whole decision about whether there is a step at all is on this side.**
|
||||
// Core renders the shell and a "skip" control whenever the slot is filled, and
|
||||
// hands us `onDone`. If this shard does not offer website-created game accounts
|
||||
// there is nothing to do here, so we call `onDone` and the invitee goes straight
|
||||
// to the portal — which is exactly what core's own code did when the flag was
|
||||
// off, only now the flag is not core's to read.
|
||||
//
|
||||
// The spinner while the answer is in flight is the honest cost of that split: the
|
||||
// invitee sees core's chrome for one cached request before this either renders or
|
||||
// stands aside. Rendering the form optimistically and retracting it would be
|
||||
// worse, and asking core to wait on a module before painting would put a module's
|
||||
// latency in front of a core page.
|
||||
export default function InviteGameAccountStep({ onDone }) {
|
||||
const signupOk = useGameAccountSignup()
|
||||
|
||||
useEffect(() => {
|
||||
if (signupOk === false) onDone()
|
||||
}, [signupOk, onDone])
|
||||
|
||||
// `null` is "not yet", not "no" — see useGameAccountSignup.
|
||||
if (signupOk !== true) {
|
||||
return (
|
||||
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
|
||||
<span className="spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
|
||||
</p>
|
||||
<CreateGameAccountForm submit={api.player.shard.createAccount} onCreated={onDone} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
84
client/src/components/PlayersOnline.jsx
Normal file
84
client/src/components/PlayersOnline.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShardFeed } from '../lib/useShardFeed.js'
|
||||
import { bucketize } from '../data/regionBuckets.js'
|
||||
import api from '../api.js'
|
||||
import { useAsync } from '../core.js'
|
||||
|
||||
// Compact live "Players Online" widget. Loads the presence.online aggregate once,
|
||||
// then keeps the total + region breakdown current from the presence.online SSE
|
||||
// kind. The raw byRegion map is rolled up into display buckets (see
|
||||
// data/regionBuckets.js). NOT a page — drop it into any panel/column.
|
||||
const PRESENCE_KINDS = new Set(['presence.online'])
|
||||
|
||||
export default function PlayersOnline() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.presence())
|
||||
const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 })
|
||||
|
||||
// The freshest snapshot wins: the newest buffered presence.online event, else
|
||||
// the initial fetch.
|
||||
const snapshot = events[0] || data
|
||||
|
||||
const { total, rows } = useMemo(() => {
|
||||
const count = Number(snapshot?.count) || 0
|
||||
const { rows: bucketRows } = bucketize(snapshot?.byRegion)
|
||||
return { total: count, rows: bucketRows }
|
||||
}, [snapshot])
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div
|
||||
className="sans"
|
||||
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Players online
|
||||
</span>
|
||||
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)', lineHeight: 1 }}>
|
||||
{loading ? '—' : total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans dim" style={{ margin: '12px 0 0', fontSize: '0.84rem' }}>
|
||||
Population is unavailable right now.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>
|
||||
{total > 0 ? 'Locations are settling…' : 'The realm is quiet.'}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
fontSize: '0.9rem',
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
<span>{r.label}</span>
|
||||
{/* tabular figures keep the right-aligned counts in a clean column */}
|
||||
<span className="dim" style={{ fontVariantNumeric: 'tabular-nums' }}>{r.count}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
88
client/src/components/ShardAccountActions.jsx
Normal file
88
client/src/components/ShardAccountActions.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import api from '../api.js'
|
||||
import { useAuth } from '../core.js'
|
||||
|
||||
// Compact in-game moderation controls (kick / ban / unban) scoped to a single
|
||||
// game account. Reused wherever a linked account or character is shown to staff:
|
||||
// the admin user-detail account list and the character sheet. Self-gates on role
|
||||
// (admin/moderator) so it is safe to render inside components that players also
|
||||
// see — a player never gets the controls, and the API enforces the same gate.
|
||||
//
|
||||
// `actor` is stamped server-side from the session; nothing here sends it. Kick is
|
||||
// reversible (they reconnect) so it acts immediately; Ban reveals an inline
|
||||
// confirm with an optional duration + reason before it fires.
|
||||
export default function ShardAccountActions({ account, style }) {
|
||||
const { user } = useAuth()
|
||||
const [busy, setBusy] = useState('')
|
||||
const [ok, setOk] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [banOpen, setBanOpen] = useState(false)
|
||||
const [durationSec, setDurationSec] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
// Only staff who can actually use the write plane see the controls.
|
||||
if (!user || !['admin', 'moderator'].includes(user.role) || !account) return null
|
||||
|
||||
async function run(label, fn, done) {
|
||||
setBusy(label); setOk(''); setErr('')
|
||||
try {
|
||||
const r = await fn()
|
||||
setOk(done(r))
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const kick = () =>
|
||||
run('kick', () => api.admin.shardOps.kick({ account }), (r) => {
|
||||
const n = r && r.sessions != null ? r.sessions : null
|
||||
const plural = n === 1 ? '' : 's'
|
||||
const sessions = n != null ? ` (${n} session${plural})` : ''
|
||||
return `Kicked${sessions}.`
|
||||
})
|
||||
const unban = () => run('unban', () => api.admin.shardOps.unban(account), () => 'Unbanned.')
|
||||
const ban = () =>
|
||||
run('ban', () =>
|
||||
api.admin.shardOps.ban({
|
||||
account,
|
||||
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
||||
reason: reason.trim() || undefined,
|
||||
}),
|
||||
() => {
|
||||
setBanOpen(false)
|
||||
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
||||
return `Banned${when}.`
|
||||
})
|
||||
|
||||
const btn = { fontSize: '0.72rem', padding: '4px 10px' }
|
||||
|
||||
return (
|
||||
<div className="sans" style={{ display: 'flex', flexDirection: 'column', gap: 8, ...style }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||||
<button onClick={kick} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'kick' ? '…' : 'Kick'}</button>
|
||||
<button onClick={() => { setBanOpen((v) => !v); setOk(''); setErr('') }} disabled={!!busy} className="btn btn-sq" style={{ ...btn, borderColor: '#d98b84', color: '#d98b84' }}>Ban…</button>
|
||||
<button onClick={unban} disabled={!!busy} className="btn btn-sq" style={btn}>{busy === 'unban' ? '…' : 'Unban'}</button>
|
||||
{ok && <span style={{ color: '#7fd0a4', fontSize: '0.8rem' }}>{ok}</span>}
|
||||
{err && <span style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||
</div>
|
||||
|
||||
{banOpen && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', gap: 8, padding: '10px 12px', border: '1px solid var(--line)', borderRadius: 8, background: 'rgba(217,139,132,0.06)' }}>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Duration (sec, blank = permanent)</span>
|
||||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" style={{ maxWidth: 150 }} />
|
||||
</label>
|
||||
<label style={{ display: 'block', flex: 1, minWidth: 160 }}>
|
||||
<span className="field-label">Reason (optional)</span>
|
||||
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||
</label>
|
||||
<button onClick={ban} disabled={busy === 'ban'} className="btn btn-primary btn-sq" style={{ borderColor: '#d98b84', background: '#d98b84', ...btn }}>
|
||||
{busy === 'ban' ? 'Banning…' : `Confirm ban ${account}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
client/src/components/ShardStatusLink.jsx
Normal file
24
client/src/components/ShardStatusLink.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
// ── Core's fill for the `site.footer.status` extension slot ────────────────
|
||||
//
|
||||
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the contract is
|
||||
// MODULE_API.md §3.7.
|
||||
//
|
||||
// This is the whole of what used to be four lines inline in SiteFooter.jsx, and
|
||||
// it is a file now for one reason: `/uo/shard` is a UO page, so the link goes
|
||||
// when the client half goes, and core should be deleting a registration rather
|
||||
// than editing its footer under extraction pressure.
|
||||
//
|
||||
// Note what core kept and what it handed over. Core owns the position in the row
|
||||
// and the separator around it, and passes `linkStyle` so the row stays visually
|
||||
// one row. The label, the destination, and the decision to render at all are
|
||||
// this file's — which is exactly the division a module inherits.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function ShardStatusLink({ linkStyle }) {
|
||||
return (
|
||||
<Link to="/uo/shard" style={linkStyle}>
|
||||
Shard Status
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
41
client/src/components/VendorSales.jsx
Normal file
41
client/src/components/VendorSales.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ago } from '../lib/format.js'
|
||||
|
||||
// Owner-private recent player-vendor sales. `fetchSales` is the scope method
|
||||
// (api.player.shard.sales / api.admin.shard.sales) — the server only returns
|
||||
// sales for accounts linked to the caller.
|
||||
export default function VendorSales({ fetchSales }) {
|
||||
const [sales, setSales] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
fetchSales()
|
||||
.then((rows) => active && setSales(rows))
|
||||
.catch(() => active && setError('Could not load your vendor sales.'))
|
||||
return () => { active = false }
|
||||
}, [fetchSales])
|
||||
|
||||
if (error) return null
|
||||
if (!sales) return null
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>Recent vendor sales</div>
|
||||
{sales.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No vendor sales recorded yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{sales.map((s) => (
|
||||
<li key={`${s.t}-${s.itemType}-${s.price}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.itemType || 'An item'}{s.amount > 1 ? ` ×${s.amount}` : ''} — {Number(s.price || 0).toLocaleString()}gp
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(s.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
72
client/src/core.js
Normal file
72
client/src/core.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// ── What core hands this module, on the client side ────────────────────────
|
||||
//
|
||||
// The client twin of `server/core.js`, and deliberately much simpler than it.
|
||||
// Every ported page imports its layout, its state components and its hooks from
|
||||
// here, so the boundary is one file and `client/scripts/checkExternals.js` has
|
||||
// one place to look. The normative contract is MODULE_API.md §3.2 and §3.4.
|
||||
//
|
||||
// **Why this is a plain read and the server's is a lazy accessor.** On the
|
||||
// server, `ctx` arrives at `register(ctx)` — after every `require` has already
|
||||
// run — so `server/core.js` has to defer resolution to call time or a router
|
||||
// would capture `undefined` at file scope. There is no such gap here.
|
||||
// `window.__rg` is published by core's own bundle (client/src/modules/shared.js),
|
||||
// and every module chunk is a deferred script the server injects *after* that
|
||||
// bundle's tag, so by the time the first line of this file executes the global
|
||||
// is already there. Reading it once, at module scope, is safe — and it means a
|
||||
// ported component keeps the ordinary `import { PageHeader } from '…'` shape
|
||||
// rather than being wrapped in an accessor that would cost it its identity.
|
||||
//
|
||||
// The absent-global case is handled by `shim/rg.js`, which every shim beside it
|
||||
// also goes through — the shims touch the global before this file does, so a
|
||||
// check here would be unreachable.
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { rg as shared } from './shim/rg.js'
|
||||
|
||||
const rg = shared()
|
||||
|
||||
// ── The shared-dependency self-check ───────────────────────────────────────
|
||||
//
|
||||
// Slice 0 carried this in entry.jsx, back when nothing else imported React and
|
||||
// an unexercised alias was an unproven one. The aliases are thoroughly exercised
|
||||
// now — thirty-five files import React and ten import the router — so what is
|
||||
// left for a runtime check to do is narrower, and worth keeping for exactly that
|
||||
// reason: the two BUILD guards (`assertSharedNotBundled` at resolution time,
|
||||
// `checkExternals.js` on the artifact) both reason about the chunk in isolation,
|
||||
// and neither can see the one failure that only exists once the chunk meets a
|
||||
// core: a `window.__rg` whose React is not the React that rendered the page.
|
||||
//
|
||||
// Identity is the only question worth asking. A second React satisfies every
|
||||
// type check, renders its first element happily, and then throws about an invalid
|
||||
// hook call somewhere unrelated.
|
||||
if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.createRoot || Link !== rg.router.Link) {
|
||||
console.error(
|
||||
'[module-uo] the bindings this chunk imported are not the ones core published — it has bundled ' +
|
||||
'its own copy of a shared dependency. Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
}
|
||||
|
||||
// The curated kit (§3.4). Seven members, closed: anything else this module needs
|
||||
// it bundles itself, which is why `components/` next door exists at all.
|
||||
export const {
|
||||
PublicLayout,
|
||||
PageHeader,
|
||||
Loading,
|
||||
ErrorState,
|
||||
EmptyState,
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
} = rg.ui
|
||||
|
||||
// The registry, for entry.jsx. Everything else here is read by pages.
|
||||
export const registry = rg.registry
|
||||
|
||||
// The core API version this module was loaded against. Logged by entry.jsx —
|
||||
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||
// ever served, so there is nothing to re-check, only something to report.
|
||||
export const coreApiVersion = rg.version
|
||||
|
||||
export default rg
|
||||
31
client/src/data/cityCrests.js
Normal file
31
client/src/data/cityCrests.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple
|
||||
// emoji sigil + a ring colour — enough to make the Governors board and the
|
||||
// governor badge read as distinct "crests" today, swappable for real artwork
|
||||
// later WITHOUT touching any component: drop an `img` (an imported asset URL or a
|
||||
// public path) onto an entry and update CityCrest to prefer it.
|
||||
//
|
||||
// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4:
|
||||
// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia).
|
||||
|
||||
export const CITY_CRESTS = {
|
||||
Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' },
|
||||
Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' },
|
||||
Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' },
|
||||
Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' },
|
||||
Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' },
|
||||
Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' },
|
||||
SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' },
|
||||
NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' },
|
||||
}
|
||||
|
||||
const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' }
|
||||
|
||||
// Look up a crest by the raw city key, tolerating spacing variants
|
||||
// ("Skara Brae" / "New Magincia"). `label` falls back to the given name.
|
||||
export function crestFor(city) {
|
||||
if (!city) return FALLBACK
|
||||
const key = String(city).replace(/\s+/g, '')
|
||||
const crest = CITY_CRESTS[city] || CITY_CRESTS[key]
|
||||
if (crest) return crest
|
||||
return { ...FALLBACK, label: String(city) }
|
||||
}
|
||||
72
client/src/data/regionBuckets.js
Normal file
72
client/src/data/regionBuckets.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO
|
||||
// regions) up into a handful of labelled display buckets for the "Players Online"
|
||||
// widget. This is the ONE place to retune the grouping — edit BUCKETS (order +
|
||||
// membership) and the widget follows. Anything not matched lands in "Wilderness"
|
||||
// so the bucket counts always reconcile to the true total.
|
||||
|
||||
// Named cities/towns, matched as a prefix on the (space/apostrophe-stripped)
|
||||
// region name so "skara brae", "serpent's hold", etc. all resolve. Kept as a
|
||||
// list rather than one giant alternation regex (simpler to read and retune).
|
||||
const TOWN_PREFIXES = [
|
||||
'moonglow', 'minoc', 'trinsic', 'jhelom', 'yew', 'skarabrae', 'magincia',
|
||||
'newmagincia', 'vesper', 'nujelm', 'cove', 'ocllo', 'serpenthold', 'serpentshold',
|
||||
'wind', 'delucia', 'papua',
|
||||
]
|
||||
const normalizeRegion = (r) => String(r).toLowerCase().replace(/['’\s]/g, '')
|
||||
|
||||
// Ordered list of buckets. `label` shows in the widget; `match(region)` decides
|
||||
// membership. First matching bucket wins; the last bucket is the catch-all.
|
||||
export const BUCKETS = [
|
||||
{
|
||||
id: 'britain',
|
||||
label: 'Britain',
|
||||
// Passthrough for the capital + its immediate surrounds.
|
||||
match: (r) => /^britain/i.test(r),
|
||||
},
|
||||
{
|
||||
id: 'towns',
|
||||
label: 'Towns',
|
||||
// The other named cities/towns.
|
||||
match: (r) => {
|
||||
const norm = normalizeRegion(r)
|
||||
return TOWN_PREFIXES.some((t) => norm.startsWith(t))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dungeons',
|
||||
label: 'Dungeons',
|
||||
match: (r) =>
|
||||
/(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test(
|
||||
r,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'housing',
|
||||
label: 'Housing',
|
||||
// House regions expose themselves as named house/townhouse regions.
|
||||
match: (r) => /(house|townhouse|homestead|tent)/i.test(r),
|
||||
},
|
||||
{
|
||||
id: 'wilderness',
|
||||
label: 'Wilderness',
|
||||
// Catch-all: the unnamed "Wilderness" region + anything unmatched above.
|
||||
match: () => true,
|
||||
},
|
||||
]
|
||||
|
||||
// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS
|
||||
// order, dropping empty buckets, with the summed total also returned.
|
||||
export function bucketize(byRegion = {}) {
|
||||
const totals = new Map(BUCKETS.map((b) => [b.id, 0]))
|
||||
let total = 0
|
||||
for (const [region, n] of Object.entries(byRegion || {})) {
|
||||
const count = Number(n) || 0
|
||||
total += count
|
||||
const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1]
|
||||
totals.set(bucket.id, totals.get(bucket.id) + count)
|
||||
}
|
||||
const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter(
|
||||
(r) => r.count > 0,
|
||||
)
|
||||
return { rows, total }
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// ── module-uo's client entry point ─────────────────────────────────────────
|
||||
//
|
||||
// This file is the whole of the chunk's top-level behaviour: core injects
|
||||
// `dist/entry.js` as a `<script type="module" src>` before `</body>`, the module
|
||||
// registers what it has, and core renders it. The normative contract is
|
||||
// MODULE_API.md §3.3.
|
||||
// Core injects `dist/entry.js` as a `<script type="module" src>` before
|
||||
// `</body>`, this file registers what the module has, and core renders it. The
|
||||
// normative contract is MODULE_API.md §3.3.
|
||||
//
|
||||
// **Registration is synchronous and happens at evaluation time.** Module scripts
|
||||
// are deferred, so this runs after core's bundle — which is where `window.__rg`
|
||||
@@ -14,69 +13,177 @@
|
||||
// bug cost the Phase 2 client PR an afternoon and no unit test in either repo
|
||||
// can see it, which is why §7.7's browser smoke exists.
|
||||
//
|
||||
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) registers NOTHING,
|
||||
// on purpose. What it proves is the delivery path itself, and the imports below
|
||||
// are how it proves the hardest part of it.
|
||||
// So everything below is a plain top-level call, and every page is a static
|
||||
// import. Lazy-loading the routes would be the natural instinct for a chunk this
|
||||
// size and it is the one thing this seam cannot have.
|
||||
|
||||
// These four specifiers are the whole shared-dependency contract, written the
|
||||
// ordinary way — which is the point. `vite.config.js` aliases each to a shim
|
||||
// that re-exports from `window.__rg`, so what ends up in the chunk is core's
|
||||
// React, core's renderer and core's router, and no second copy of any of them.
|
||||
// A module author writes these imports exactly as they would in any app.
|
||||
import { registry, coreApiVersion } from './core.js'
|
||||
import { IconShard, IconUser } from './icons.jsx'
|
||||
import { useShardFlags } from './lib/useShardFeatures.js'
|
||||
|
||||
// Public pages — the twelve that used to live at /site/*.
|
||||
import Shard from './routes/public/Shard.jsx'
|
||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||
import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
||||
import Guilds from './routes/public/Guilds.jsx'
|
||||
import Governors from './routes/public/Governors.jsx'
|
||||
import Houses from './routes/public/Houses.jsx'
|
||||
import Rules from './routes/public/Rules.jsx'
|
||||
import Atlas from './routes/public/Atlas.jsx'
|
||||
import AtlasCreature from './routes/public/AtlasCreature.jsx'
|
||||
import Leaderboards from './routes/public/Leaderboards.jsx'
|
||||
import Market from './routes/public/Market.jsx'
|
||||
import MarketVendor from './routes/public/MarketVendor.jsx'
|
||||
|
||||
// Admin views.
|
||||
import ShardAdmin from './routes/admin/ShardAdmin.jsx'
|
||||
import ShardOps from './routes/admin/ShardOps.jsx'
|
||||
import ShardVisibility from './routes/admin/ShardVisibility.jsx'
|
||||
import SpawnAtlas from './routes/admin/SpawnAtlas.jsx'
|
||||
import HousesAdmin from './routes/admin/HousesAdmin.jsx'
|
||||
import AdminCharacters from './routes/admin/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/AdminCharacter.jsx'
|
||||
|
||||
// Player-portal views.
|
||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
|
||||
// Extension-slot fills (§3.7) — module content inside a core page.
|
||||
import ShardStatusLink from './components/ShardStatusLink.jsx'
|
||||
import UserShardSections from './routes/admin/UserShardSections.jsx'
|
||||
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
|
||||
|
||||
const ID = 'uo'
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// They are here in slice 0 rather than arriving with the first page because an
|
||||
// unexercised alias is an unproven one: with nothing importing `react`, the
|
||||
// build emits a 0.2 kB chunk, `checkExternals` passes vacuously, and the seam
|
||||
// this whole slice exists to prove has not been touched.
|
||||
import { createElement, isValidElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
// Paths are relative to this module's namespace and core prefixes them:
|
||||
// `/uo/…`, `/admin/uo/…`, `/player/uo/…`. A module cannot write the segment its
|
||||
// routes hang under however it spells `path`, which is the point.
|
||||
//
|
||||
// **These SPA paths changed and the API paths did not.** `/site/shard` is now
|
||||
// `/uo/shard` and `/admin/shard-ops` is now `/admin/uo/ops` — a clean break with
|
||||
// no redirects, settled in MODULE_SYSTEM.md §2.7. Every URL in `api.js` is
|
||||
// byte-identical to the one core called, because §1.2 freezes the API surface
|
||||
// and the shipped Android app calls seven of these routes.
|
||||
//
|
||||
// The admin paths lost their `shard-` prefixes on the way through: under a `/uo/`
|
||||
// namespace `/admin/uo/shard-visibility` says "shard" twice, and a clean break is
|
||||
// the only moment that tidy-up is free.
|
||||
//
|
||||
// `gate` is core's own RoleGate, applied by core. A module cannot supply an auth
|
||||
// wrapper — the sidebar and the route table have to agree about who may see what.
|
||||
const STAFF = { roles: ['admin', 'moderator'] }
|
||||
|
||||
const rg = window.__rg
|
||||
registry.registerRoutes(ID, {
|
||||
public: [
|
||||
{ path: 'shard', element: <Shard /> },
|
||||
{ path: 'shard/activity', element: <ShardActivity /> },
|
||||
{ path: 'champs', element: <ChampSpawns /> },
|
||||
{ path: 'guilds', element: <Guilds /> },
|
||||
{ path: 'governors', element: <Governors /> },
|
||||
{ path: 'houses', element: <Houses /> },
|
||||
{ path: 'rules', element: <Rules /> },
|
||||
{ path: 'atlas', element: <Atlas /> },
|
||||
{ path: 'atlas/:slug', element: <AtlasCreature /> },
|
||||
{ path: 'leaderboards', element: <Leaderboards /> },
|
||||
{ path: 'market', element: <Market /> },
|
||||
{ path: 'market/vendors/:serial', element: <MarketVendor /> },
|
||||
],
|
||||
admin: [
|
||||
// Admin-only: the sidecar's configuration, who may see which surface, and
|
||||
// the atlas import. No `gate` on the other three because AdminLayout already
|
||||
// requires staff and these carry their own role rows below.
|
||||
{ path: 'link', element: <ShardAdmin /> },
|
||||
{ path: 'visibility', element: <ShardVisibility /> },
|
||||
{ path: 'atlas', element: <SpawnAtlas /> },
|
||||
{ path: 'ops', element: <ShardOps />, gate: STAFF },
|
||||
{ path: 'houses', element: <HousesAdmin />, gate: STAFF },
|
||||
// Self-service, and deliberately ungated: a staff member's own characters
|
||||
// are theirs to read whatever their role. Staff are a superset of players.
|
||||
{ path: 'characters', element: <AdminCharacters /> },
|
||||
{ path: 'characters/:serial', element: <AdminCharacter /> },
|
||||
],
|
||||
player: [
|
||||
{ path: 'characters', element: <PlayerCharacters /> },
|
||||
{ path: 'characters/:serial', element: <PlayerCharacter /> },
|
||||
],
|
||||
})
|
||||
|
||||
// A module that cannot see the global is a module core did not load — which
|
||||
// means the injection or the ordering broke, not the module. Say so, once,
|
||||
// rather than throwing a TypeError about a property of undefined three frames
|
||||
// deep in a component.
|
||||
if (!rg) {
|
||||
console.error('[module-uo] window.__rg is missing — core did not publish its shared dependencies before this chunk evaluated.')
|
||||
} else {
|
||||
// JSX, so the `react/jsx-runtime` alias is exercised too. That one is the
|
||||
// easiest of the four to get wrong and the hardest to notice: Vite's
|
||||
// object-form alias prefix-matches, so a `react` key silently captures
|
||||
// `react/jsx-runtime` as well, and the failure surfaces as `jsx is not a
|
||||
// function` in whichever component happens to render first.
|
||||
const probe = <span>module-uo</span>
|
||||
// ── Nav ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Rows interleave into CORE groups rather than appending as a "UO" block, which
|
||||
// is what keeps the extraction invisible in the sidebar (MODULE_SYSTEM.md §1.4).
|
||||
//
|
||||
// `feature` names a flag resolved by the provider registered below — by THIS
|
||||
// module, so the strings are the bare names they have always been and nothing
|
||||
// parses a namespace out of them.
|
||||
registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [
|
||||
{ label: 'Shard', to: '/uo/shard', feature: 'status' },
|
||||
{ label: 'Champions', to: '/uo/champs', feature: 'champs' },
|
||||
{ label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
|
||||
{ label: 'Governors', to: '/uo/governors', feature: 'governors' },
|
||||
{ label: 'Houses', to: '/uo/houses', feature: 'houses' },
|
||||
{ label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
|
||||
{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
|
||||
{ label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
|
||||
{ label: 'Market', to: '/uo/market', feature: 'market' },
|
||||
],
|
||||
})
|
||||
|
||||
// The self-check: are the bindings this chunk imported the SAME objects core
|
||||
// published? Identity is the only question worth asking. A bundled second
|
||||
// React satisfies every type check, renders its first element happily, and
|
||||
// then throws about an invalid hook call somewhere unrelated.
|
||||
const shared = [
|
||||
['react', createElement === rg.react.createElement],
|
||||
['react/jsx-runtime', isValidElement(probe)],
|
||||
['react-dom/client', createRoot === rg.reactDom.createRoot],
|
||||
['react-router-dom', Link === rg.router.Link],
|
||||
]
|
||||
const bundled = shared.filter(([, ok]) => !ok).map(([name]) => name)
|
||||
registry.registerNav(ID, {
|
||||
area: 'admin',
|
||||
items: [
|
||||
// Moderation: no `order`, because these two are last in that group today and
|
||||
// "append after core's rows" is exactly that — and stays that way if core
|
||||
// adds a moderation row later, which an explicit index would not.
|
||||
{ label: 'In-Game Ops', to: '/admin/uo/ops', icon: IconShard, group: 'Moderation', roles: ['admin', 'moderator'] },
|
||||
{ label: 'Houses', to: '/admin/uo/houses', icon: IconShard, group: 'Moderation', roles: ['admin', 'moderator'] },
|
||||
// System: these three sit MID-list, between Discord Bot and Web Bot Activity.
|
||||
// Core's rows are keyed by their index and an explicit `order` beats a
|
||||
// coincidental one at a tie, so all three asking for 8 — Web Bot Activity's
|
||||
// index once the UO rows are gone — lands them ahead of it, in this order.
|
||||
{ label: 'Shard (uo-link)', to: '/admin/uo/link', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Shard Visibility', to: '/admin/uo/visibility', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Spawn Atlas', to: '/admin/uo/atlas', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
// No group: a trailing untitled group of its own, below core's Account row
|
||||
// rather than beside it (§3.3). One position lower than it sits today, and
|
||||
// the alternative — letting a module into core's furniture groups — is worse.
|
||||
{ label: 'My Characters', to: '/admin/uo/characters', icon: IconShard },
|
||||
],
|
||||
})
|
||||
|
||||
if (bundled.length) {
|
||||
console.error(
|
||||
`[module-uo] ${bundled.join(', ')} did not come from window.__rg — the chunk has bundled its own copy. ` +
|
||||
'Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
} else {
|
||||
// Registrations land here, slice by slice:
|
||||
//
|
||||
// rg.registry.registerRoutes('uo', { public: [...], admin: [...], player: [...] })
|
||||
// rg.registry.registerNav('uo', { area: 'public', items: [...] })
|
||||
// rg.registry.registerFeatureProvider('uo', 'uo', useShardFeatures)
|
||||
//
|
||||
// `MODULE_API_VERSION` is checked by core against `module.json`'s `coreApi`
|
||||
// before this file is ever served, so there is nothing to re-check here. It
|
||||
// is logged because a mismatch between the core that validated the manifest
|
||||
// and the core that published this global would otherwise be invisible from
|
||||
// the browser, which is where the client half actually fails.
|
||||
console.info(`[module-uo] loaded against core API ${rg.version}; shared dependencies OK`)
|
||||
}
|
||||
}
|
||||
registry.registerNav(ID, {
|
||||
area: 'player',
|
||||
// Order 0: Characters is the portal's first row today, and with the module
|
||||
// installed it is also what core's `/player` index resolves to.
|
||||
items: [{ label: 'Characters', to: '/player/uo/characters', icon: IconUser, order: 0 }],
|
||||
})
|
||||
|
||||
// ── Feature provider ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Core keeps a generic flag context and owns none of the semantics. Until this
|
||||
// slice core registered this same hook itself under owner id `core`, so that the
|
||||
// seam was exercised by real content from the day it was built; the registration
|
||||
// moves here and core's is deleted.
|
||||
registry.registerFeatureProvider(ID, ID, useShardFlags)
|
||||
|
||||
// ── Extension slots ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Three core pages have a piece of this module in them. Each was core's own fill
|
||||
// under owner id `core` until this slice, so all three are a swap rather than an
|
||||
// addition — and each throws rather than failing open if the slot is unknown or
|
||||
// already filled, which is how a slice that forgot to delete core's half finds
|
||||
// out immediately instead of rendering core's content forever (§3.7).
|
||||
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
|
||||
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
|
||||
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
|
||||
|
||||
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||
// ever served, so there is nothing to re-check here. It is logged because a
|
||||
// mismatch between the core that validated the manifest and the core that
|
||||
// published this global would otherwise be invisible from the browser, which is
|
||||
// where the client half actually fails.
|
||||
console.info(`[module-uo] registered against core API ${coreApiVersion}`)
|
||||
|
||||
66
client/src/icons.jsx
Normal file
66
client/src/icons.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
// The nav glyph for this module's sidebar rows.
|
||||
//
|
||||
// `icon` is part of the nav-item contract as of MODULE_API 1.3.0 (§3.3): core
|
||||
// renders whatever component the row carries, exactly as it renders its own
|
||||
// rows' icons. Before that it did not, and the six UO rows would have extracted
|
||||
// as the only text-only entries in a sidebar where everything else has a glyph —
|
||||
// which reads as breakage rather than as a design.
|
||||
//
|
||||
// The wrapper matches core's own `Icon` (AdminLayout.jsx) — 18px, currentColor,
|
||||
// 1.6 stroke — deliberately and by copy, not by import. It is four attributes of
|
||||
// presentation, not a component: putting it in the shared kit would freeze core's
|
||||
// icon sizing into the contract, where changing it later would be a MAJOR bump.
|
||||
// A module that wants to look like the sidebar it is in matches the sidebar.
|
||||
const Icon = ({ children }) => (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** A faceted gem — the glyph core used for all six of these rows before they moved. */
|
||||
export const IconShard = () => (
|
||||
<Icon>
|
||||
<path d="M12 2l7 6-7 14-7-14z" />
|
||||
<path d="M5 8h14" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
/**
|
||||
* A figure — the glyph core used for the portal's "Characters" row.
|
||||
*
|
||||
* A second icon rather than reusing IconShard, because these two rows sit in
|
||||
* different navs and each matched its neighbours before the extraction: the
|
||||
* admin sidebar's UO rows were all gems, and the portal's Characters row was a
|
||||
* person beside Appeals' shield and Account's gear. Copied from core's
|
||||
* PlayerPortalLayout, which uses a 16px frame and a heavier stroke than the
|
||||
* admin one — matching the nav a row lands in is the whole reason `icon` exists.
|
||||
*/
|
||||
export const IconUser = () => (
|
||||
<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"
|
||||
>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21a8 8 0 0 1 16 0" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default IconShard
|
||||
36
client/src/lib/format.js
Normal file
36
client/src/lib/format.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// A vendored copy of the one helper this module uses from core's
|
||||
// `client/src/lib/format.js`.
|
||||
//
|
||||
// **Vendored rather than added to the kit, and trimmed rather than copied
|
||||
// whole.** The kit is curated and closed (MODULE_API.md §3.4): every member
|
||||
// added to it is a minor version bump core can never take back, and a date
|
||||
// formatter is not the kind of thing a module should be unable to write. Copying
|
||||
// all six of core's helpers to get one would leave five with no consumer here
|
||||
// and a standing question about which copy is authoritative.
|
||||
//
|
||||
// The vendoring line, from the server half of the extraction (slice 1): **pure
|
||||
// leaf helpers may be copied, security controls may not.** This is a pure leaf.
|
||||
// Core's HTML sanitiser sits two files away and stays exactly where it is.
|
||||
//
|
||||
// The two copies will drift, and that is correct — core's is core's to change.
|
||||
// Nothing here reads a shared format.
|
||||
|
||||
function parse(value) {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** "3m ago". Coarse on purpose: the live feed's timestamps are approximate. */
|
||||
export function ago(value) {
|
||||
const d = parse(value)
|
||||
if (!d) return ''
|
||||
const secs = Math.max(1, Math.floor((Date.now() - d.getTime()) / 1000))
|
||||
if (secs < 60) return `${secs}s ago`
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
128
client/src/lib/shardEvents.js
Normal file
128
client/src/lib/shardEvents.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// Shared formatting for shard events — used by the public Shard page, the
|
||||
// Activity feed, and the admin live feed. One place decides how each kind reads
|
||||
// and which category/badge it belongs to.
|
||||
|
||||
function nameOf(who) {
|
||||
if (!who) return 'Someone'
|
||||
if (typeof who === 'string') return who
|
||||
return who.name || who.acct || 'Someone'
|
||||
}
|
||||
|
||||
const n = (v) => Number(v || 0).toLocaleString()
|
||||
|
||||
// A one-line human description of each event kind, keyed by kind. Each formatter
|
||||
// takes the payload and returns a string. Conditional suffixes are pulled into
|
||||
// locals so no template literal is nested inside another.
|
||||
const DESCRIBERS = {
|
||||
'vendor.sale': (p) => {
|
||||
const qty = p.amount > 1 ? ` ×${p.amount}` : ''
|
||||
return `${p.itemType || 'An item'}${qty} sold for ${n(p.price)}gp`
|
||||
},
|
||||
'player.death': (p) => {
|
||||
const by = p.killer ? ` by ${nameOf(p.killer)}` : ''
|
||||
return `${nameOf(p.who)} was slain${by}`
|
||||
},
|
||||
'player.murdered': (p) => {
|
||||
const by = p.murderer ? ` by ${nameOf(p.murderer)}` : ''
|
||||
return `${nameOf(p.victim)} was murdered${by}`
|
||||
},
|
||||
'mob.killed': (p) => `${nameOf(p.killer)} killed ${nameOf(p.killed)}`,
|
||||
'skill.gain': (p) => {
|
||||
const base = p.base != null ? ` (${p.base})` : ''
|
||||
return `${nameOf(p.who)} gained ${p.skill}${base}`
|
||||
},
|
||||
'fame.change': (p) => `${nameOf(p.who)}’s fame changed to ${n(p.new)}`,
|
||||
'karma.change': (p) => `${nameOf(p.who)}’s karma changed to ${n(p.new)}`,
|
||||
'quest.complete': (p) => `${nameOf(p.who)} completed “${p.quest}”`,
|
||||
'house.decay': (p) => {
|
||||
const region = p.region ? ` — ${p.region}` : ''
|
||||
return `${p.name || 'A house'} is now ${p.to || p.stage}${region}`
|
||||
},
|
||||
'mob.login': (p) => `${nameOf(p.who)} entered the world`,
|
||||
'mob.logout': (p) => `${nameOf(p.who)} left the world`,
|
||||
'economy.supply': (p) => `Gold supply: ${n(p.gold)} across ${n(p.accounts)} accounts`,
|
||||
'server.hello': (p) => `Shard online — ${n(p.accounts)} accounts, ${n(p.mobiles)} mobiles`,
|
||||
'server.shutdown': () => 'Shard shut down',
|
||||
'server.crashed': (p) => {
|
||||
const err = p.error ? `: ${p.error}` : ''
|
||||
return `Shard crashed${err}`
|
||||
},
|
||||
'champ.update': (p) => {
|
||||
const where = p.name || p.type || 'A champion spawn'
|
||||
if (p.status === 'active' && p.bossUp) {
|
||||
const boss = p.boss ? ` (${p.boss})` : ''
|
||||
return `${where}: boss is up${boss}`
|
||||
}
|
||||
if (p.status === 'active') {
|
||||
const level = p.level != null ? ` — level ${p.level}` : ''
|
||||
return `${where} is active${level}`
|
||||
}
|
||||
if (p.status === 'cooldown') return `${where} is on cooldown`
|
||||
return `${where} is ${p.status || 'idle'}`
|
||||
},
|
||||
'champ.remove': () => `A champion spawn ended`,
|
||||
// Support (help-page) queue + in-game moderation (admin channel only)
|
||||
'page.new': (p) => `New ${p.type || 'help'} page from ${nameOf(p.sender)}`,
|
||||
'page.updated': (p) => {
|
||||
const claimed = p.handled ? ' (claimed)' : ''
|
||||
return `Help page from ${nameOf(p.sender)} updated${claimed}`
|
||||
},
|
||||
'page.closed': (p) => `Help page ${p.pageId || ''} closed`,
|
||||
'admin.audit': (p) => {
|
||||
const on = p.target ? ` on ${p.target}` : ''
|
||||
const origin = p.origin ? ` [${p.origin}]` : ''
|
||||
return `${p.actor || 'Staff'} ${p.action || 'acted'}${on}${origin}`
|
||||
},
|
||||
// Staff / sensitive (admin channel only)
|
||||
'audit.set': (p) =>
|
||||
`${nameOf(p.staff) || 'Staff'} set ${p.prop} on ${p.target || p.targetSerial} (${p.old} → ${p.new})`,
|
||||
'audit.command': (p) => {
|
||||
const args = p.args ? ` ${p.args}` : ''
|
||||
return `${nameOf(p.staff) || 'Staff'} ran ${p.command}${args}`
|
||||
},
|
||||
'cheat.fastwalk': (p) => {
|
||||
const ip = p.ip ? ` (${p.ip})` : ''
|
||||
return `Fast-walk flagged: ${nameOf(p.who)}${ip}`
|
||||
},
|
||||
'account.login.attempt': (p) => {
|
||||
const ip = p.ip ? ` from ${p.ip}` : ''
|
||||
return `Login attempt: ${p.acct}${ip}`
|
||||
},
|
||||
'gold.change': (p) => {
|
||||
const sign = p.delta >= 0 ? '+' : ''
|
||||
return `${p.acct}: gold ${sign}${n(p.delta)} → ${n(p.new)}`
|
||||
},
|
||||
}
|
||||
|
||||
// A one-line human description of an event. Accepts either a stored event
|
||||
// (with .payload) or a raw live frame (fields at top level).
|
||||
export function describe(ev) {
|
||||
const fmt = DESCRIBERS[ev.kind]
|
||||
return fmt ? fmt(ev.payload || ev) : ev.kind
|
||||
}
|
||||
|
||||
// Category grouping for the filter tabs.
|
||||
// Vendor sales are intentionally NOT a public category — they are owner-private
|
||||
// (a linked player sees their own under the portal). The admin live feed still
|
||||
// describes vendor.sale via describe() below.
|
||||
export const CATEGORIES = [
|
||||
{ id: 'all', label: 'All', kinds: null },
|
||||
{ id: 'pvp', label: 'Deaths & PvP', kinds: ['player.death', 'player.murdered', 'mob.killed'] },
|
||||
{ id: 'progress', label: 'Progression', kinds: ['skill.gain', 'fame.change', 'karma.change', 'quest.complete'] },
|
||||
{ id: 'world', label: 'World', kinds: ['house.decay', 'mob.login', 'mob.logout', 'server.hello', 'server.shutdown', 'server.crashed', 'economy.supply'] },
|
||||
]
|
||||
|
||||
const CATEGORY_OF = (() => {
|
||||
const m = {}
|
||||
for (const c of CATEGORIES) if (c.kinds) for (const k of c.kinds) m[k] = c.id
|
||||
return m
|
||||
})()
|
||||
|
||||
export function categoryOf(kind) {
|
||||
return CATEGORY_OF[kind] || 'other'
|
||||
}
|
||||
|
||||
// Short badge label for a kind (the part after the dot, title-cased-ish).
|
||||
export function kindLabel(kind) {
|
||||
return String(kind || '').replace(/[._]/g, ' ')
|
||||
}
|
||||
95
client/src/lib/useShardFeatures.js
Normal file
95
client/src/lib/useShardFeatures.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../api.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 || []),
|
||||
// Not a visibility flag and deliberately carried alongside them: it
|
||||
// is the same per-viewer, once-a-session answer from the same
|
||||
// endpoint, and GameAccounts asking for it separately would be a
|
||||
// second round-trip for a field already on the wire.
|
||||
gameAccountSignup: Boolean(data.gameAccountSignup),
|
||||
}
|
||||
return cached
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed lookup must not blank the nav — fall back to "show
|
||||
// everything" and let the server do the gating.
|
||||
cached = null
|
||||
inFlight = null
|
||||
return null
|
||||
})
|
||||
inFlight.then((result) => {
|
||||
if (alive) setFeatures(result)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
// Convenience: true when `name` is visible, or when we don't know yet.
|
||||
export function canSee(features, name) {
|
||||
return !features || features.set.has(name)
|
||||
}
|
||||
|
||||
// The same answer in the shape core's generic feature seam takes: a Set-like of
|
||||
// the flags this viewer may see, or null while we do not know yet
|
||||
// (core's modules/featureGate.js). This module registers it as the provider for
|
||||
// the `uo` namespace in entry.jsx, and the nine shard-gated rows in the public
|
||||
// header are ours to gate as of slice 3.
|
||||
//
|
||||
// It used to be core that registered this hook, under owner id `core`, so that
|
||||
// the seam was exercised from the day it was built. That prediction held exactly
|
||||
// — this slice deleted a registration and a file rather than rewriting a header.
|
||||
export function useShardFlags() {
|
||||
const features = useShardFeatures()
|
||||
return features ? features.set : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this site offer game-account creation right now?
|
||||
*
|
||||
* `null` while unknown, which callers must treat as "not yet" rather than "no":
|
||||
* the form it guards would 403 anyway, and flashing it in and out is worse than
|
||||
* arriving a beat late. Unlike the visibility flags above this one fails CLOSED
|
||||
* on a lookup error — showing a create-account form on a shard that refuses them
|
||||
* is a dead end the player cannot tell from a bug, whereas a hidden nav row has
|
||||
* another way round.
|
||||
*/
|
||||
export function useGameAccountSignup() {
|
||||
const features = useShardFeatures()
|
||||
return features ? features.gameAccountSignup : null
|
||||
}
|
||||
54
client/src/lib/useShardFeed.js
Normal file
54
client/src/lib/useShardFeed.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import api from '../api.js'
|
||||
|
||||
// Subscribe to the public shard live-event SSE stream and keep a rolling buffer
|
||||
// of the most recent events. The browser talks to our own /public/shard/stream
|
||||
// route (plain HTTP EventSource) — never the sidecar's WebSocket — so the token
|
||||
// stays server-side and it works through any reverse proxy.
|
||||
//
|
||||
// EventSource auto-reconnects on drop, so there is no manual retry loop here; a
|
||||
// `connected` flag is exposed for a small live/offline indicator. `filter` (a
|
||||
// Set of kinds, optional) limits which events are buffered. `max` caps the
|
||||
// buffer length.
|
||||
export function useShardFeed({ url, filter, max = 40 } = {}) {
|
||||
const [events, setEvents] = useState([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
// Keep the latest filter in a ref so re-renders don't tear down the stream.
|
||||
const filterRef = useRef(filter)
|
||||
filterRef.current = filter
|
||||
const streamUrl = url || api.shardStreamUrl
|
||||
|
||||
useEffect(() => {
|
||||
// EventSource isn't available during SSR / very old browsers — degrade to
|
||||
// "no live feed" rather than throwing.
|
||||
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return undefined
|
||||
|
||||
const es = new EventSource(streamUrl, { withCredentials: true })
|
||||
|
||||
es.onopen = () => setConnected(true)
|
||||
es.onerror = () => setConnected(false) // EventSource will retry on its own
|
||||
|
||||
es.onmessage = (msg) => {
|
||||
let event
|
||||
try {
|
||||
event = JSON.parse(msg.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!event || !event.kind) return
|
||||
const f = filterRef.current
|
||||
if (f && !f.has(event.kind)) return
|
||||
setEvents((prev) => {
|
||||
// Tag with a stable-ish local id for React keys (events carry t but can
|
||||
// collide within a ms) and cap the buffer.
|
||||
const next = [{ ...event, _id: `${event.kind}-${event.t}-${prev.length}` }, ...prev]
|
||||
return next.slice(0, max)
|
||||
})
|
||||
}
|
||||
|
||||
return () => es.close()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [max, streamUrl])
|
||||
|
||||
return { events, connected }
|
||||
}
|
||||
28
client/src/routes/admin/AdminCharacter.jsx
Normal file
28
client/src/routes/admin/AdminCharacter.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
|
||||
// A staff member's own character sheet inside the admin shell. Owner-checked —
|
||||
// the endpoint only returns a sheet for a character on the caller's linked account.
|
||||
export default function AdminCharacter() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.admin.shard.char(serial), [serial])
|
||||
const restarting = error && error.status === 503
|
||||
const forbidden = error && error.status === 403
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
<p style={{ margin: '0 0 18px' }}>
|
||||
<Link to="/admin/uo/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||
← Back to my characters
|
||||
</Link>
|
||||
</p>
|
||||
{loading && <Loading />}
|
||||
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||
{!loading && !error && data && <CharacterSheet char={data} moderation />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
client/src/routes/admin/AdminCharacters.jsx
Normal file
18
client/src/routes/admin/AdminCharacters.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import CharacterStats from '../../components/CharacterStats.jsx'
|
||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../components/VendorSales.jsx'
|
||||
import api from '../../api.js'
|
||||
|
||||
// Staff link their OWN in-game account and view their characters — the same
|
||||
// shared component players use, pointed at the staff self-service endpoints.
|
||||
// Sits inside the Admin shell, which supplies the "My Characters" page header;
|
||||
// stat tiles bring it to parity with the Player Portal's Characters page.
|
||||
export default function AdminCharacters() {
|
||||
return (
|
||||
<section style={{ maxWidth: 760 }}>
|
||||
<CharacterStats scope={api.admin.shard} />
|
||||
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/uo/characters/${serial}`} />
|
||||
<VendorSales fetchSales={api.admin.shard.sales} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
118
client/src/routes/admin/HousesAdmin.jsx
Normal file
118
client/src/routes/admin/HousesAdmin.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
|
||||
// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and
|
||||
// decay — everything the public board hides. Loaded from /admin/shard/houses, kept
|
||||
// live from the admin SSE channel (house.update / house.remove).
|
||||
const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay'])
|
||||
|
||||
const DECAY_TONE = {
|
||||
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||
}
|
||||
|
||||
function DecayBadge({ decay, isIdoc }) {
|
||||
const label = isIdoc ? 'IDOC' : decay
|
||||
if (!label) return null
|
||||
const tone = DECAY_TONE[label] || 'var(--muted)'
|
||||
return (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ownerLabel(h) {
|
||||
return h.ownerName || h.ownerAcct || null
|
||||
}
|
||||
|
||||
function HouseRow({ h }) {
|
||||
const owner = ownerLabel(h)
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<strong className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.name || 'An unnamed house'}
|
||||
</strong>
|
||||
<DecayBadge decay={h.decay} isIdoc={h.isIdoc} />
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 3 }}>
|
||||
{owner ? <>Owned by <span style={{ color: 'var(--ink)' }}>{owner}</span></> : 'No owner'}
|
||||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
{h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{h.price != null && (
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '0.92rem', color: 'var(--head)', fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()}</div>
|
||||
<div className="dim" style={{ fontSize: '0.64rem', letterSpacing: '0.04em', textTransform: 'uppercase' }}>placement value</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HousesAdmin() {
|
||||
const { loading, error, data } = useAsync(() => api.admin.shard.houses())
|
||||
// Full registry deltas ride the admin SSE channel (never the public one).
|
||||
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 })
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (!ev.serial) continue
|
||||
if (ev.kind === 'house.update') {
|
||||
map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct })
|
||||
} else if (ev.kind === 'house.remove') {
|
||||
map.delete(ev.serial)
|
||||
} else if (ev.kind === 'house.decay') {
|
||||
const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y }
|
||||
map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' })
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle
|
||||
? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)))
|
||||
: board
|
||||
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
}, [board, q])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the house registry." />
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.82rem', margin: 0 }}>
|
||||
{board.length.toLocaleString()} houses
|
||||
<span className="dim" style={{ marginLeft: 10, color: connected ? '#7fd0a4' : 'var(--muted)' }}>{connected ? '● live' : '○ offline'}</span>
|
||||
</p>
|
||||
<input className="input sans" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} />
|
||||
</div>
|
||||
{board.length === 0 ? (
|
||||
<div className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No houses are being tracked right now.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||
</div>
|
||||
)}
|
||||
{board.length > 0 && filtered.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No houses match “{q}”.</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
323
client/src/routes/admin/ShardAdmin.jsx
Normal file
323
client/src/routes/admin/ShardAdmin.jsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { describe, kindLabel } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.js'
|
||||
|
||||
// Full live feed from the admin SSE channel — every kind, incl. staff audit,
|
||||
// cheat detection and login attempts that the public channel never carries.
|
||||
function AdminLiveFeed() {
|
||||
const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, max: 60 })
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Live feed (all events)</h3>
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Waiting for shard events…</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 360, overflowY: 'auto' }}>
|
||||
{events.map((e) => (
|
||||
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.6rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>{kindLabel(e.kind)}</span>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// uo-link sidecar control panel. The auth token is write-only over this API —
|
||||
// stored encrypted, never returned — same convention as the Discord bot token.
|
||||
// Saving (re)starts the WS ingest client, so Enabled/URL/token changes take
|
||||
// effect immediately with no redeploy.
|
||||
|
||||
function Toggle({ checked, onChange, label }) {
|
||||
return (
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_COLOR = {
|
||||
connected: '#7fd0a4',
|
||||
reconnecting: '#e0b070',
|
||||
error: '#d98b84',
|
||||
disconnected: 'var(--muted)',
|
||||
}
|
||||
|
||||
function StatusPanel({ config }) {
|
||||
const color = STATUS_COLOR[config.status] || 'var(--muted)'
|
||||
const ingest = config.ingest || {}
|
||||
const health = config.health || {}
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
|
||||
<span className="sans" style={{ fontSize: '0.9rem', color: 'var(--ink)', textTransform: 'capitalize' }}>
|
||||
{config.status || 'disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
{config.statusDetail && (
|
||||
<p className="sans" style={{ margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }}>{config.statusDetail}</p>
|
||||
)}
|
||||
<div className="sans dim" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 16px', fontSize: '0.78rem', marginTop: 2 }}>
|
||||
<span>Shard link: <strong style={{ color: 'var(--ink)' }}>{config.pluginConnected ? 'up' : 'down'}</strong></span>
|
||||
<span>WS ingest: <strong style={{ color: 'var(--ink)' }}>{ingest.connected ? 'connected' : 'offline'}</strong></span>
|
||||
<span>Reconnects: <strong style={{ color: 'var(--ink)' }}>{ingest.reconnects ?? 0}</strong></span>
|
||||
<span>SSE clients: <strong style={{ color: 'var(--ink)' }}>{(config.sse?.publicClients ?? 0) + (config.sse?.adminClients ?? 0)}</strong></span>
|
||||
{config.lastEventAt && <span style={{ gridColumn: '1 / -1' }}>Last event: {new Date(config.lastEventAt).toLocaleString()}</span>}
|
||||
{health.uptime && <span style={{ gridColumn: '1 / -1' }}>Sidecar uptime: {health.uptime}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Game-account signup ─────────────────────────────────────────────────────
|
||||
//
|
||||
// This field lived in core's Site Settings until slice 3 of the extraction. It
|
||||
// moved here rather than being deleted or left behind, because its help text has
|
||||
// always described an agreement between this site and a ServUO shard — and half
|
||||
// of that agreement is configured in Bridge.cfg, which core has never heard of.
|
||||
//
|
||||
// The setting key and value are unchanged (`game_account_signup`), so an
|
||||
// instance that had this configured finds it here, set to what it was.
|
||||
const SIGNUP_MODES = [
|
||||
{ value: 'disabled', label: 'Disabled — link an existing account only' },
|
||||
{ value: 'website', label: 'Website — the site creates game accounts' },
|
||||
{ value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
|
||||
{ value: 'game', label: 'Game only — created in the game client, not the site' },
|
||||
]
|
||||
|
||||
function GameSignup() {
|
||||
const [mode, setMode] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.admin.getSignupMode()
|
||||
.then((r) => active && setMode(r.mode))
|
||||
.catch(() => active && setError('Could not load the signup mode.'))
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
async function save(next) {
|
||||
const previous = mode
|
||||
setMode(next); setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.admin.saveSignupMode(next)
|
||||
setMsg('Saved.')
|
||||
} catch (err) {
|
||||
setMode(previous) // the select must not show a mode the server did not take
|
||||
setError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Game-account creation</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
Whether players can create a GAME account (for the game client) from the site. The game server’s own
|
||||
SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.
|
||||
When enabled, a “Create a game account” form appears in the player portal and after an invite is accepted.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Mode</span>
|
||||
<select
|
||||
value={mode ?? ''}
|
||||
onChange={(e) => save(e.target.value)}
|
||||
disabled={busy || mode === null}
|
||||
className="input"
|
||||
style={{ maxWidth: 420 }}
|
||||
>
|
||||
{mode === null && <option value="">Loading…</option>}
|
||||
{SIGNUP_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', minHeight: 20 }}>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Town crier ──────────────────────────────────────────────────────────────
|
||||
function TownCrier() {
|
||||
const [id, setId] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
const [durationSec, setDurationSec] = useState(3600)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function post() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
if (!id.trim() || lines.length === 0) {
|
||||
setBusy(false)
|
||||
return setError('An id and at least one line are required.')
|
||||
}
|
||||
try {
|
||||
await api.admin.postTownCrier({ id: id.trim(), lines, durationSec: Number(durationSec) || undefined })
|
||||
setMsg(`Posted “${id.trim()}”.`)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not post.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
if (!id.trim()) return setError('Enter the id to remove.')
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.admin.deleteTownCrier(id.trim())
|
||||
setMsg(`Removed “${id.trim()}”.`)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Town crier</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
Broadcast a message that every in-game town crier announces until it expires. Re-posting the same id replaces it.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Message id</span>
|
||||
<input type="text" value={id} onChange={(e) => setId(e.target.value)} className="input" placeholder="news-42" autoComplete="off" style={{ maxWidth: 220 }} />
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Lines (one per line)</span>
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} className="input" rows={3} placeholder={'Hear ye!\nMarket tax is now 5%.'} style={{ resize: 'vertical' }} />
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Duration (seconds)</span>
|
||||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={1} max={86400} style={{ maxWidth: 160 }} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={post} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Working…' : 'Post message'}</button>
|
||||
<button onClick={remove} disabled={busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>Remove by id</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShardAdmin() {
|
||||
const [config, setConfig] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [wsUrl, setWsUrl] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [protocol, setProtocol] = useState(3)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const pollRef = useRef(null)
|
||||
const initializedRef = useRef(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const c = await api.admin.getUoLinkConfig()
|
||||
setConfig(c)
|
||||
// Seed the editable fields once; later polls only refresh the status panel
|
||||
// so they never clobber what the admin is mid-typing.
|
||||
if (!initializedRef.current) {
|
||||
setBaseUrl(c.baseUrl || '')
|
||||
setWsUrl(c.wsUrl || '')
|
||||
setProtocol(c.protocol || 3)
|
||||
setEnabled(c.enabled)
|
||||
initializedRef.current = true
|
||||
}
|
||||
} catch {
|
||||
setError('Could not load uo-link config.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
pollRef.current = setInterval(load, 5000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [load])
|
||||
|
||||
async function save() {
|
||||
setBusy(true); setMsg(''); setSaveError('')
|
||||
try {
|
||||
const body = { baseUrl, wsUrl, protocol: Number(protocol), enabled }
|
||||
if (token) body.token = token
|
||||
const saved = await api.admin.saveUoLinkConfig(body)
|
||||
setConfig(saved)
|
||||
setToken('')
|
||||
setMsg('Saved.')
|
||||
} catch (err) {
|
||||
setSaveError(err.message || 'Could not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!config) return <Loading />
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>Shard (uo-link)</h2>
|
||||
|
||||
<StatusPanel config={config} />
|
||||
|
||||
<Toggle checked={enabled} onChange={setEnabled} label="Enable the shard integration" />
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Base URL (REST)</span>
|
||||
<input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} className="input" autoComplete="off" placeholder="http://127.0.0.1:8080" />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">WebSocket URL (feed)</span>
|
||||
<input type="text" value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} className="input" autoComplete="off" placeholder="ws://127.0.0.1:8080/ws" />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Auth token</span>
|
||||
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} className="input" autoComplete="new-password" placeholder={config.hasToken ? '•••••••• configured — leave blank to keep' : 'Shared secret from sidecar.toml'} />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||
<span className="field-label">Protocol</span>
|
||||
<input type="number" value={protocol} onChange={(e) => setProtocol(e.target.value)} className="input" min={1} max={99} />
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Saving…' : 'Save changes'}</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{saveError && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{saveError}</span>}
|
||||
</div>
|
||||
|
||||
<GameSignup />
|
||||
|
||||
<TownCrier />
|
||||
|
||||
<AdminLiveFeed />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
291
client/src/routes/admin/ShardOps.jsx
Normal file
291
client/src/routes/admin/ShardOps.jsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { describe } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
// In-game staff operations: the uo-link write plane (broadcast / kick / ban /
|
||||
// unban) and the help-page support queue, plus a live audit log. Open to admins
|
||||
// and moderators. The acting staff member (`actor`) is attached server-side from
|
||||
// the session — nothing here sends it — so every action is attributable.
|
||||
|
||||
function Flash({ ok, err }) {
|
||||
if (ok) return <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{ok}</span>
|
||||
if (err) return <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Broadcast ────────────────────────────────────────────────────────────────
|
||||
function Broadcast() {
|
||||
const [text, setText] = useState('')
|
||||
const [hue, setHue] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [ok, setOk] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function send() {
|
||||
if (!text.trim()) return setErr('Enter a message.')
|
||||
setBusy(true); setOk(''); setErr('')
|
||||
try {
|
||||
await api.admin.shardOps.broadcast({ text: text.trim(), hue: hue === '' ? undefined : Number(hue) })
|
||||
setOk('Broadcast sent.')
|
||||
setText('')
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Could not broadcast.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Broadcast</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||
A system message shown to everyone online right now.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Message</span>
|
||||
<input type="text" value={text} onChange={(e) => setText(e.target.value)} className="input" maxLength={300} placeholder="Server restart in 5 minutes" autoComplete="off" />
|
||||
</label>
|
||||
<label style={{ display: 'block', maxWidth: 140 }}>
|
||||
<span className="field-label">Hue (optional)</span>
|
||||
<input type="number" value={hue} onChange={(e) => setHue(e.target.value)} className="input" min={0} max={3000} placeholder="53" />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={send} disabled={busy} className="btn btn-primary btn-sq">{busy ? 'Sending…' : 'Broadcast'}</button>
|
||||
<Flash ok={ok} err={err} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Account actions (kick / ban / unban) ─────────────────────────────────────
|
||||
function AccountActions() {
|
||||
const [account, setAccount] = useState('')
|
||||
const [durationSec, setDurationSec] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
const [ok, setOk] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const acct = account.trim()
|
||||
function guard() {
|
||||
if (!acct) {
|
||||
setErr('Enter an account name.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function run(label, fn, done) {
|
||||
if (!guard()) return
|
||||
setBusy(label); setOk(''); setErr('')
|
||||
try {
|
||||
const r = await fn()
|
||||
setOk(done(r))
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const kick = () =>
|
||||
run('kick', () => api.admin.shardOps.kick({ account: acct }), (r) => {
|
||||
const n = r?.sessions != null ? r.sessions : null
|
||||
const plural = n === 1 ? '' : 's'
|
||||
const sessions = n != null ? ` (${n} session${plural})` : ''
|
||||
return `Kicked ${acct}${sessions}.`
|
||||
})
|
||||
const ban = () =>
|
||||
run(
|
||||
'ban',
|
||||
() =>
|
||||
api.admin.shardOps.ban({
|
||||
account: acct,
|
||||
durationSec: durationSec === '' ? undefined : Number(durationSec),
|
||||
reason: reason.trim() || undefined,
|
||||
}),
|
||||
() => {
|
||||
const when = durationSec ? ` for ${durationSec}s` : ' indefinitely'
|
||||
return `Banned ${acct}${when}.`
|
||||
},
|
||||
)
|
||||
const unban = () => run('unban', () => api.admin.shardOps.unban(acct), () => `Unbanned ${acct}.`)
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Account actions</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||
Kick, ban or unban a game account. Bans work even if the account is offline; the shard refuses to act on staff at or above co-owner.
|
||||
</p>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Account</span>
|
||||
<input type="text" value={account} onChange={(e) => setAccount(e.target.value)} className="input" placeholder="griefer42" autoComplete="off" style={{ maxWidth: 260 }} />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<label style={{ display: 'block', maxWidth: 200 }}>
|
||||
<span className="field-label">Ban duration (seconds, blank = permanent)</span>
|
||||
<input type="number" value={durationSec} onChange={(e) => setDurationSec(e.target.value)} className="input" min={0} placeholder="604800" />
|
||||
</label>
|
||||
<label style={{ display: 'block', flex: 1, minWidth: 200 }}>
|
||||
<span className="field-label">Ban reason (optional)</span>
|
||||
<input type="text" value={reason} onChange={(e) => setReason(e.target.value)} className="input" maxLength={500} placeholder="harassment" autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={kick} disabled={!!busy} className="btn btn-sq">{busy === 'kick' ? 'Kicking…' : 'Kick'}</button>
|
||||
<button onClick={ban} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'ban' ? 'Banning…' : 'Ban'}</button>
|
||||
<button onClick={unban} disabled={!!busy} className="btn btn-sq">{busy === 'unban' ? 'Unbanning…' : 'Unban'}</button>
|
||||
<Flash ok={ok} err={err} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Support (help-page) queue ────────────────────────────────────────────────
|
||||
function PageRow({ page, onDone }) {
|
||||
const [message, setMessage] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
async function respond(close) {
|
||||
if (!message.trim()) return setErr('Enter a reply first.')
|
||||
setBusy(close ? 'respond-close' : 'respond'); setErr('')
|
||||
try {
|
||||
await api.admin.shardOps.respondPage(page.pageId, { message: message.trim(), close })
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Could not send.')
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
async function close() {
|
||||
setBusy('close'); setErr('')
|
||||
try {
|
||||
await api.admin.shardOps.closePage(page.pageId)
|
||||
onDone()
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Could not close.')
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<span className="sans" style={{ fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)' }}>{page.type || 'Page'}</span>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||
{page.sender?.name || page.pageId}
|
||||
{page.handled && <span className="dim" style={{ fontSize: '0.72rem' }}> · claimed{page.handler ? ` by ${page.handler}` : ''}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{page.sentMs ? ago(page.sentMs) : ''}</span>
|
||||
</div>
|
||||
{page.message && <p className="sans" style={{ margin: 0, color: 'var(--ink)', fontSize: '0.88rem', lineHeight: 1.5 }}>{page.message}</p>}
|
||||
<div className="sans dim" style={{ fontSize: '0.72rem' }}>
|
||||
{page.map || '—'}{page.x != null ? ` (${page.x}, ${page.y})` : ''}
|
||||
</div>
|
||||
<textarea value={message} onChange={(e) => setMessage(e.target.value)} className="input" rows={2} placeholder="A GM is on the way." style={{ resize: 'vertical' }} />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => respond(false)} disabled={!!busy} className="btn btn-sq">{busy === 'respond' ? 'Sending…' : 'Reply'}</button>
|
||||
<button onClick={() => respond(true)} disabled={!!busy} className="btn btn-primary btn-sq">{busy === 'respond-close' ? 'Sending…' : 'Reply & close'}</button>
|
||||
<button onClick={close} disabled={!!busy} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>{busy === 'close' ? 'Closing…' : 'Close'}</button>
|
||||
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.8rem' }}>{err}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SupportQueue() {
|
||||
const [pages, setPages] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
const pollRef = useRef(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setPages(await api.admin.shardOps.pages())
|
||||
} catch {
|
||||
setErr('Could not load the support queue.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
pollRef.current = setInterval(load, 7000)
|
||||
return () => clearInterval(pollRef.current)
|
||||
}, [load])
|
||||
|
||||
let queueBody
|
||||
if (pages == null) {
|
||||
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>Loading…</p>
|
||||
} else if (pages.length === 0) {
|
||||
queueBody = <p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>The queue is empty.</p>
|
||||
} else {
|
||||
queueBody = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{pages.map((p) => <PageRow key={p.pageId} page={p} onDone={load} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>Support queue</h3>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.86rem' }}>
|
||||
Open help pages from players. A reply reaches them in game (or on their next login).
|
||||
</p>
|
||||
{err && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{err}</span>}
|
||||
{queueBody}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Audit log ────────────────────────────────────────────────────────────────
|
||||
// Seeded from the stored admin.audit history, then kept live from the admin SSE
|
||||
// channel (which carries every kind — we filter to admin.audit here).
|
||||
function AuditLog() {
|
||||
const [seed, setSeed] = useState([])
|
||||
const { events } = useShardFeed({ url: api.adminShardStreamUrl, filter: new Set(['admin.audit']), max: 50 })
|
||||
|
||||
useEffect(() => {
|
||||
api.admin.shardOps
|
||||
.audit(50)
|
||||
.then((rows) => setSeed(rows.map((r) => ({ ...r, _id: `seed-${r.id}` }))))
|
||||
.catch(() => setSeed([]))
|
||||
}, [])
|
||||
|
||||
// Live events on top; fall back to the seed for anything older than the live tail.
|
||||
const oldestLive = events.length ? Math.min(...events.map((e) => e.t || 0)) : Infinity
|
||||
const rows = [...events, ...seed.filter((s) => (s.t || 0) < oldestLive)].slice(0, 60)
|
||||
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 22 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)', marginBottom: 12 }}>Audit log</h3>
|
||||
{rows.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No moderation actions recorded yet.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 320, overflowY: 'auto' }}>
|
||||
{rows.map((e) => (
|
||||
<li key={e._id} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.85rem' }}>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(e)}</span>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.74rem' }}>{ago(e.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShardOps() {
|
||||
return (
|
||||
<section style={{ maxWidth: 620, display: 'flex', flexDirection: 'column', gap: 22 }}>
|
||||
<Broadcast />
|
||||
<AccountActions />
|
||||
<SupportQueue />
|
||||
<AuditLog />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
325
client/src/routes/admin/ShardVisibility.jsx
Normal file
325
client/src/routes/admin/ShardVisibility.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.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/SpawnAtlas.jsx
Normal file
285
client/src/routes/admin/SpawnAtlas.jsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.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>
|
||||
)
|
||||
}
|
||||
163
client/src/routes/admin/UserShardSections.jsx
Normal file
163
client/src/routes/admin/UserShardSections.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// ── Core's fill for the `admin.users.detail` extension slot ────────────────
|
||||
//
|
||||
// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1. Every section below
|
||||
// is UO, and every one of them leaves core with the client half in slice 3 —
|
||||
// this file exists so that when they do, core deletes a registration and a file
|
||||
// instead of unpicking a page.
|
||||
//
|
||||
// Core registers it through the same seam a module uses
|
||||
// (`registerExtension('core', …)` in main.jsx), which is the client twin of the
|
||||
// server's `registries.registerCore()` and the same trick `useShardFlags`
|
||||
// already uses for the feature seam. The mechanism is therefore exercised by
|
||||
// core's own content from the day it lands, rather than first proved by the
|
||||
// change that depends on it.
|
||||
//
|
||||
// The slot hands over `userId` and nothing else — deliberately, not `scope`.
|
||||
// `api.admin.userShard` is a UO binding that leaves core in slice 3, so a slot
|
||||
// that passed it would be handing a module something core is about to delete.
|
||||
// An extension builds its own client for the routes it registered at the other
|
||||
// end (MODULE_API.md §3.5), and this file does exactly what the module will.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import CharacterStats from '../../components/CharacterStats.jsx'
|
||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../components/VendorSales.jsx'
|
||||
import { useAsync } from '../../core.js'
|
||||
|
||||
// Its own copy, not an export from UserDetail.jsx: six lines of presentational
|
||||
// furniture that is not in the §3.4 kit, so a module filling this slot would
|
||||
// vendor the same thing. Core's copy stays behind with core's own security
|
||||
// panel, which is the other caller.
|
||||
function SectionTitle({ children }) {
|
||||
return (
|
||||
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Currently-online characters on the user's accounts, with where they are. The
|
||||
// per-character Online/Offline badge lives in the roster; this adds location.
|
||||
function OnlineNow({ scope }) {
|
||||
const { data } = useAsync(() => scope.online(), [scope])
|
||||
if (!data) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Online now</SectionTitle>
|
||||
{data.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((c) => (
|
||||
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
|
||||
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||||
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Shard "standing": city governorships held and guilds led by this user's
|
||||
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
||||
function Standing({ scope }) {
|
||||
const { data } = useAsync(() => scope.standing(), [scope])
|
||||
if (!data) return null
|
||||
const govs = data.governorOf || []
|
||||
const guilds = data.guildsLed || []
|
||||
if (govs.length === 0 && guilds.length === 0) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Standing</SectionTitle>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{govs.map((g) => (
|
||||
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
||||
Governor of {g.city}
|
||||
</span>
|
||||
))}
|
||||
{guilds.map((g) => (
|
||||
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
||||
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// One house row — the many optional detail fields are gathered here so the
|
||||
// Houses list stays a simple map.
|
||||
function HouseRow({ house: h }) {
|
||||
const location = h.region || (h.map != null ? `map ${h.map}` : 'unknown')
|
||||
const coords = h.x != null ? ` · ${h.x}, ${h.y}` : ''
|
||||
const owner = h.ownerAcct ? ` · ${h.ownerAcct}` : ''
|
||||
const shares = h.coOwners || h.friends ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''
|
||||
return (
|
||||
<li
|
||||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||||
{h.name || 'Unnamed house'}
|
||||
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||||
{location}
|
||||
{coords}
|
||||
{owner}
|
||||
{shares}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
||||
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
||||
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||
function Houses({ scope }) {
|
||||
const { data } = useAsync(() => scope.houses(), [scope])
|
||||
if (!data) return null
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||||
<SectionTitle>Houses</SectionTitle>
|
||||
{data.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{data.map((h) => (
|
||||
<HouseRow key={h.serial} house={h} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
export default function UserShardSections({ userId }) {
|
||||
// Memoized so the child components' effects (keyed on `scope`) don't refetch
|
||||
// on every render — the same reason UserDetail memoized it before this moved.
|
||||
const scope = useMemo(() => api.admin.userShard(userId), [userId])
|
||||
return (
|
||||
<>
|
||||
<CharacterStats scope={scope} />
|
||||
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/uo/characters/${serial}`} />
|
||||
<Standing scope={scope} />
|
||||
<OnlineNow scope={scope} />
|
||||
<Houses scope={scope} />
|
||||
<VendorSales fetchSales={scope.sales} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
28
client/src/routes/player/PlayerCharacter.jsx
Normal file
28
client/src/routes/player/PlayerCharacter.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import CharacterSheet from '../../components/CharacterSheet.jsx'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
|
||||
// A player's character sheet inside the portal. Owner-checked: the endpoint only
|
||||
// returns a sheet for a character on an account linked to the caller.
|
||||
export default function PlayerCharacter() {
|
||||
const { serial } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.player.shard.char(serial), [serial])
|
||||
const restarting = error && error.status === 503
|
||||
const forbidden = error && error.status === 403
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ margin: '0 0 18px' }}>
|
||||
<Link to="/player/uo/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||
← Back to characters
|
||||
</Link>
|
||||
</p>
|
||||
{loading && <Loading />}
|
||||
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||
{forbidden && <ErrorState message="That character is not on an account linked to you." />}
|
||||
{error && !restarting && !forbidden && <ErrorState message="Could not load that character right now." />}
|
||||
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
client/src/routes/player/PlayerCharacters.jsx
Normal file
58
client/src/routes/player/PlayerCharacters.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||
import VendorSales from '../../components/VendorSales.jsx'
|
||||
import api from '../../api.js'
|
||||
import { useAsync } from '../../core.js'
|
||||
|
||||
// The logged-in player's characters. Shows the link prompt when no game account
|
||||
// is linked, otherwise their characters grouped by account (shared component),
|
||||
// plus their own home status and recent vendor sales.
|
||||
|
||||
const DECAY_TONE = {
|
||||
LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a',
|
||||
Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5',
|
||||
}
|
||||
|
||||
// The caller's own houses (home status). Only their own — never anyone else's.
|
||||
function MyHouses() {
|
||||
const { data } = useAsync(() => api.player.shard.houses(), [])
|
||||
if (!data || data.length === 0) return null
|
||||
return (
|
||||
<section style={{ marginTop: 30 }}>
|
||||
<div className="field-label" style={{ marginBottom: 12 }}>My houses</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{data.map((h) => {
|
||||
const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage)
|
||||
const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)')
|
||||
return (
|
||||
<div key={h.serial} className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)' }}>{h.name || 'An unnamed house'}</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{label && (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', color: tone, border: `1px solid ${tone}66`, borderRadius: 999, padding: '2px 9px' }}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.76rem' }}>
|
||||
Keep an eye on the decay status — refresh a house in game before it reaches IDOC.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlayerCharacters() {
|
||||
return (
|
||||
<div>
|
||||
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/uo/characters/${serial}`} />
|
||||
<MyHouses />
|
||||
<VendorSales fetchSales={api.player.shard.sales} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
307
client/src/routes/public/Atlas.jsx
Normal file
307
client/src/routes/public/Atlas.jsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
|
||||
// ── The spawn atlas ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// What the shard CONTAINS, as opposed to what it is doing: which creatures
|
||||
// spawn, where, and which champion altars are configured. There is no live feed
|
||||
// here and no `connected` indicator, deliberately — this is parsed from the
|
||||
// shard's own files and stays complete while the shard is down.
|
||||
//
|
||||
// Facet names come from the shard's data, never from a list in this file. A
|
||||
// shard running custom maps gets its own names in the filter with no code
|
||||
// change (docs/link/v3.md §6.1 R2).
|
||||
|
||||
const PAGE = 50
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
const TABS = [
|
||||
{ key: 'creatures', label: 'Creatures' },
|
||||
{ key: 'champions', label: 'Champion altars' },
|
||||
{ key: 'places', label: 'Places' },
|
||||
]
|
||||
|
||||
function Chip({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '5px 12px',
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
color: active ? 'var(--bg-deep)' : 'var(--muted)',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatureCard({ creature }) {
|
||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||
return (
|
||||
<Link
|
||||
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
|
||||
className="panel"
|
||||
style={{
|
||||
padding: '13px 15px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
style={{
|
||||
fontSize: '0.98rem',
|
||||
color: 'var(--head)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{creature.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{facets.length === 0
|
||||
? '—'
|
||||
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
|
||||
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
|
||||
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
|
||||
{num(creature.points)} spawners
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// The creature list owns its own paging rather than going through useAsync: a
|
||||
// "load more" appends to what is already on screen, which a hook that resets to
|
||||
// `{ loading: true, data: null }` on every dependency change cannot express.
|
||||
function Creatures({ q, facet }) {
|
||||
const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 })
|
||||
const [more, setMore] = useState(false)
|
||||
|
||||
const load = useCallback(
|
||||
async (offset) => {
|
||||
const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset })
|
||||
return page
|
||||
},
|
||||
[q, facet],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ loading: true, error: null, items: [], total: 0 })
|
||||
load(0)
|
||||
.then((page) => {
|
||||
if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 })
|
||||
})
|
||||
.catch((error) => alive && setState({ loading: false, error, items: [], total: 0 }))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const loadMore = async () => {
|
||||
setMore(true)
|
||||
try {
|
||||
const page = await load(state.items.length)
|
||||
setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total }))
|
||||
} catch {
|
||||
// A failed "load more" leaves what is already on screen alone; the button
|
||||
// simply stays available to retry.
|
||||
} finally {
|
||||
setMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) return <Loading />
|
||||
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
|
||||
if (state.items.length === 0) {
|
||||
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
|
||||
Showing {num(state.items.length)} of {num(state.total)}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.items.map((c) => (
|
||||
<CreatureCard key={c.slug} creature={c} />
|
||||
))}
|
||||
</div>
|
||||
{state.items.length < state.total && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<button type="button" className="btn" onClick={loadMore} disabled={more}>
|
||||
{more ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The CONFIGURED altar roster — where the altars are and what each summons. The
|
||||
// live board ("it is on level 3 right now") is a different page, /uo/champs,
|
||||
// fed by the sidecar. Both exist; they are not the same thing.
|
||||
function Champions({ facet }) {
|
||||
const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet])
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load the champion altars right now." />
|
||||
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map((champ) => (
|
||||
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
|
||||
{champ.label || champ.name}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
|
||||
{champ.facet}
|
||||
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
|
||||
{champ.randomType ? 'Random champion' : champ.type || '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regions and landmarks together: both answer "where is that?", and splitting
|
||||
// them into two tabs would make the visitor guess which list a name lives in.
|
||||
function Places({ q, facet }) {
|
||||
const { loading, error, data } = useAsync(
|
||||
() => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]),
|
||||
[q, facet],
|
||||
)
|
||||
const rows = useMemo(() => {
|
||||
if (!data) return []
|
||||
const [regions, landmarks] = data
|
||||
return [
|
||||
...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })),
|
||||
...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })),
|
||||
].sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [data])
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState message="Could not load places right now." />
|
||||
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
|
||||
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Atlas() {
|
||||
const [tab, setTab] = useState('creatures')
|
||||
const [input, setInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [facet, setFacet] = useState('')
|
||||
const meta = useAsync(() => api.atlas.meta())
|
||||
|
||||
// Debounced: typing "lizardman" should be one request, not nine.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setQ(input.trim()), 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [input])
|
||||
|
||||
const facets = meta.data?.facets || []
|
||||
const counts = meta.data?.counts || null
|
||||
const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title="Spawn atlas"
|
||||
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
|
||||
/>
|
||||
|
||||
{/* The atlas is only as good as its placement rate, so the page states
|
||||
it rather than implying every spawner resolved to a named place. */}
|
||||
{counts && (
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
|
||||
{num(counts.creatures)} creatures across {num(counts.points)} spawners
|
||||
{Number.isFinite(counts.unresolvedPoints) && counts.points
|
||||
? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark`
|
||||
: ''}
|
||||
{imported ? ` · parsed ${imported.toLocaleDateString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{TABS.map((t) => (
|
||||
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab !== 'champions' && (
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
|
||||
style={{ width: '100%', marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{facets.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
<Chip active={facet === ''} onClick={() => setFacet('')}>
|
||||
All facets
|
||||
</Chip>
|
||||
{facets.map((f) => (
|
||||
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
|
||||
{f}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{meta.error && <ErrorState message="Could not load the atlas right now." />}
|
||||
{!meta.error && !meta.loading && !imported && (
|
||||
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
|
||||
)}
|
||||
|
||||
{!meta.error && imported && (
|
||||
<>
|
||||
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
|
||||
{tab === 'champions' && <Champions facet={facet} />}
|
||||
{tab === 'places' && <Places q={q} facet={facet} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
198
client/src/routes/public/AtlasCreature.jsx
Normal file
198
client/src/routes/public/AtlasCreature.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
|
||||
// One creature: where it spawns, and what spawns alongside it.
|
||||
//
|
||||
// `places` is the point of the page — the aggregate that turns 62 raw
|
||||
// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are
|
||||
// available underneath for the reader who actually wants a coordinate, but they
|
||||
// are secondary and collapsed by default.
|
||||
|
||||
const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—')
|
||||
|
||||
// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing.
|
||||
function delay(min, max) {
|
||||
const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`)
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
||||
if (min === max) return fmt(min)
|
||||
return `${fmt(min)}–${fmt(max)}`
|
||||
}
|
||||
|
||||
function Panel({ title, right, children }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
|
||||
{title}
|
||||
</h2>
|
||||
{right}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Places({ places }) {
|
||||
if (places.length === 0) {
|
||||
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{places.map((place) => (
|
||||
<div
|
||||
key={`${place.facet}:${place.label}`}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid var(--line)',
|
||||
fontSize: '0.86rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
|
||||
<span className="dim" style={{ flex: 'none' }}>
|
||||
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
|
||||
{num(place.maxAlive)} at once
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Spawners({ spawners, truncated }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (spawners.length === 0) return null
|
||||
return (
|
||||
<Panel
|
||||
title="Individual spawners"
|
||||
right={
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
|
||||
>
|
||||
{open ? 'Hide' : `Show ${num(spawners.length)}`}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{open && (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
|
||||
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
|
||||
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{spawners.map((s) => (
|
||||
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
|
||||
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
|
||||
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{truncated && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
Only the largest spawners are listed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AtlasCreature() {
|
||||
const { slug } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug])
|
||||
|
||||
// A 404 here means "no such creature in this atlas", which is a real answer
|
||||
// and not a failure — a visitor following a stale link deserves to be told
|
||||
// that plainly rather than shown a generic error box.
|
||||
const missing = error?.status === 404 || error?.message === 'Not Found'
|
||||
|
||||
const facets = useMemo(
|
||||
() => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]),
|
||||
[data],
|
||||
)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<p className="sans" style={{ marginBottom: 8 }}>
|
||||
<Link to="/uo/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
|
||||
← Spawn atlas
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && !missing && <ErrorState message="Could not load that creature right now." />}
|
||||
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Bestiary"
|
||||
title={data.name}
|
||||
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel
|
||||
title="Where it spawns"
|
||||
right={
|
||||
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
|
||||
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Places places={data.places || []} />
|
||||
</Panel>
|
||||
|
||||
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
|
||||
|
||||
{data.alsoHere?.length > 0 && (
|
||||
<Panel title="Shares a spawner with">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{data.alsoHere.map((other) => (
|
||||
<Link
|
||||
key={other.slug}
|
||||
to={`/uo/atlas/${encodeURIComponent(other.slug)}`}
|
||||
className="sans"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
padding: '4px 11px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--line)',
|
||||
color: 'var(--muted)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{other.name} <span className="dim">×{num(other.shared)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
199
client/src/routes/public/ChampSpawns.jsx
Normal file
199
client/src/routes/public/ChampSpawns.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
|
||||
// The champion-spawn board. Loaded once from /public/shard/champs, then kept live
|
||||
// by merging champ.update / champ.remove deltas from the public SSE feed. Three
|
||||
// families share the board, split by category into their own sections.
|
||||
const CHAMP_KINDS = new Set(['champ.update', 'champ.remove'])
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'champion', title: 'Champion altars', blurb: 'Felucca-style altar spawns.' },
|
||||
{ id: 'mini', title: 'Mini champs', blurb: 'TerMur controllers — they re-arm on their own.' },
|
||||
{ id: 'sea', title: 'Sea bosses', blurb: 'High Seas world bosses, alive only while summoned.' },
|
||||
]
|
||||
|
||||
const STATUS_STYLE = {
|
||||
active: { bg: 'rgba(95,185,138,0.16)', fg: '#8fdcae', border: 'rgba(95,185,138,0.45)', label: 'Active' },
|
||||
cooldown: { bg: 'rgba(230,194,106,0.14)', fg: '#e6c26a', border: 'rgba(230,194,106,0.4)', label: 'Cooldown' },
|
||||
dormant: { bg: 'rgba(140,150,165,0.14)', fg: '#aab3c0', border: 'rgba(140,150,165,0.35)', label: 'Dormant' },
|
||||
}
|
||||
|
||||
// A short "in 4m" / "in 2h" for a future ISO timestamp (restartAt / expireAt).
|
||||
function until(iso) {
|
||||
if (!iso) return ''
|
||||
const ms = new Date(iso).getTime() - Date.now()
|
||||
if (!Number.isFinite(ms)) return ''
|
||||
if (ms <= 0) return 'due'
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 60) return `in ${mins}m`
|
||||
const hrs = Math.round(mins / 60)
|
||||
return `in ${hrs}h`
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const s = STATUS_STYLE[status] || STATUS_STYLE.dormant
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
flex: 'none',
|
||||
fontSize: '0.68rem',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
padding: '3px 9px',
|
||||
borderRadius: 999,
|
||||
color: s.fg,
|
||||
background: s.bg,
|
||||
border: `1px solid ${s.border}`,
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// A slim progress bar (kills toward the next level, or a sea boss's hit points).
|
||||
function Meter({ value, max, tone = 'var(--accent)' }) {
|
||||
if (!max) return null
|
||||
const pct = Math.max(0, Math.min(100, (Number(value) / Number(max)) * 100))
|
||||
return (
|
||||
<div style={{ height: 6, borderRadius: 4, background: 'rgba(255,255,255,0.07)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: tone, borderRadius: 4 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Category-specific middle line + meter for one spawn.
|
||||
function ChampDetail({ s }) {
|
||||
const line = { display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.8rem', color: 'var(--muted)', marginTop: 8 }
|
||||
if (s.category === 'sea') {
|
||||
return (
|
||||
<>
|
||||
<div className="sans" style={line}>
|
||||
<span>{s.boss || s.type}</span>
|
||||
{s.hitsMax != null && <span>{Number(s.hits).toLocaleString()} / {Number(s.hitsMax).toLocaleString()} hp</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}><Meter value={s.hits} max={s.hitsMax} tone="#d9736f" /></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
if (s.category === 'mini') {
|
||||
return (
|
||||
<div className="sans" style={line}>
|
||||
<span>Level {s.level ?? 0}{s.maxLevel != null ? ` / ${s.maxLevel}` : ''}</span>
|
||||
<span>{s.status === 'active' ? 'Running' : 'Re-arming'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// champion
|
||||
let progress = ''
|
||||
if (s.status === 'cooldown') progress = until(s.restartAt) || 'restarting'
|
||||
else if (s.status === 'active') {
|
||||
progress = `${Number(s.kills || 0).toLocaleString()} / ${Number(s.maxKills || 0).toLocaleString()} kills`
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="sans" style={line}>
|
||||
<span>
|
||||
Level {s.level ?? 0}
|
||||
{s.bossUp && s.boss ? ` — ${s.boss}` : ''}
|
||||
</span>
|
||||
<span>{progress}</span>
|
||||
</div>
|
||||
{s.status === 'active' && (
|
||||
<div style={{ marginTop: 6 }}><Meter value={s.kills} max={s.maxKills} /></div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ChampCard({ s }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<strong className="display" style={{ fontSize: '1.02rem', color: 'var(--head)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.name || s.type || 'Spawn'}
|
||||
</strong>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
<ChampDetail s={s} />
|
||||
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.74rem' }}>
|
||||
{s.map || '—'}{s.x != null ? ` (${s.x}, ${s.y})` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ChampSpawns() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.champs())
|
||||
const { events, connected } = useShardFeed({ filter: CHAMP_KINDS, max: 60 })
|
||||
|
||||
// Merge the initial snapshot with live deltas: seed a map by serial, then apply
|
||||
// buffered events oldest → newest (the buffer is newest-first) so live wins.
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const s of data || []) if (s && s.serial) map.set(s.serial, s)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (!ev || !ev.serial) continue
|
||||
if (ev.kind === 'champ.update') map.set(ev.serial, ev)
|
||||
else if (ev.kind === 'champ.remove') map.delete(ev.serial)
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
const byCategory = (id) =>
|
||||
board.filter((s) => (s.category || 'champion') === id).sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
|
||||
const activeCount = board.filter((s) => s.status === 'active').length
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Champion spawns" lead="Every altar, mini-champ and sea boss across the shard, updating in real time." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the champion board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No champion spawns are being tracked right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', marginTop: -12, marginBottom: 24 }}>
|
||||
{activeCount} active · {board.length} tracked
|
||||
</p>
|
||||
{SECTIONS.map((sec) => {
|
||||
const rows = byCategory(sec.id)
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<section key={sec.id} style={{ marginBottom: 28 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.1rem', color: 'var(--head)' }}>{sec.title}</h2>
|
||||
<p className="sans dim" style={{ margin: '2px 0 0', fontSize: '0.8rem' }}>{sec.blurb}</p>
|
||||
</div>
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{rows.map((s) => <ChampCard key={s.serial} s={s} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
184
client/src/routes/public/Governors.jsx
Normal file
184
client/src/routes/public/Governors.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { crestFor } from '../../data/cityCrests.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
|
||||
// The town-governor board (City Loyalty). Loaded from /public/shard/governors,
|
||||
// kept live by merging city.update deltas by city. Empty on shards without the
|
||||
// City Loyalty system. Each city card links to its term history (look-back).
|
||||
const GOV_KINDS = new Set(['city.update'])
|
||||
|
||||
const PHASE = {
|
||||
none: null,
|
||||
nominate: { label: 'Nominations open', color: '#7f8fd0' },
|
||||
vote: { label: 'Voting', color: '#e6c26a' },
|
||||
pending: { label: 'Result pending', color: '#c9a24b' },
|
||||
}
|
||||
|
||||
// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt).
|
||||
function until(iso) {
|
||||
if (!iso) return ''
|
||||
const ms = new Date(iso).getTime() - Date.now()
|
||||
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||
const mins = Math.round(ms / 60000)
|
||||
if (mins < 60) return `in ${mins}m`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `in ${hrs}h`
|
||||
return `in ${Math.round(hrs / 24)}d`
|
||||
}
|
||||
|
||||
function fmtDate(ms) {
|
||||
if (ms == null) return ''
|
||||
return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function CityCrest({ city, size = 44 }) {
|
||||
const c = crestFor(city)
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
flex: 'none', width: size, height: size, borderRadius: '50%',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.5, background: 'rgba(255,255,255,0.04)',
|
||||
border: `2px solid ${c.color}`, boxShadow: `0 0 10px ${c.color}22`,
|
||||
}}
|
||||
>
|
||||
{c.sigil}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible term history for one city, fetched on demand from the ledger.
|
||||
function TermHistory({ city }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { loading, error, data } = useAsync(
|
||||
() => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)),
|
||||
[open, city],
|
||||
)
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="sans"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: '0.76rem' }}
|
||||
>
|
||||
{open ? 'Hide past governors' : 'Past governors →'}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{loading && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Loading…</p>}
|
||||
{error && <p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Could not load history.</p>}
|
||||
{data && data.length === 0 && (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>No recorded terms yet.</p>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{data.map((t) => (
|
||||
<li key={`${t.startedAt}-${t.governor?.name ?? 'vacant'}`} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: '0.8rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.governor?.name || 'Vacant'}
|
||||
</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.72rem' }}>
|
||||
{fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CityCard({ c }) {
|
||||
const phase = PHASE[c.electionPhase] || null
|
||||
const gov = c.governor
|
||||
const candidatePlural = c.candidates === 1 ? '' : 's'
|
||||
return (
|
||||
<div className="panel" style={{ padding: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<CityCrest city={c.city} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<strong className="display" style={{ fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||
{crestFor(c.city).label || c.city}
|
||||
</strong>
|
||||
{phase && (
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.66rem', letterSpacing: '0.06em', textTransform: 'uppercase', color: phase.color, border: `1px solid ${phase.color}66`, borderRadius: 999, padding: '2px 8px' }}>
|
||||
{phase.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sans" style={{ marginTop: 3, fontSize: '0.9rem', color: gov ? 'var(--ink)' : 'var(--muted)' }}>
|
||||
{gov ? (
|
||||
<>Governor <strong style={{ color: 'var(--head)' }}>{gov.name}</strong></>
|
||||
) : (
|
||||
'Seat vacant'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{c.electionPhase && c.electionPhase !== 'none' && (
|
||||
<div className="sans dim" style={{ marginTop: 10, fontSize: '0.78rem' }}>
|
||||
{c.candidates ? `${c.candidates} candidate${candidatePlural}` : 'No candidates yet'}
|
||||
{c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TermHistory city={c.city} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Governors() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.governors())
|
||||
const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 })
|
||||
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const c of data || []) if (c && c.city) map.set(c.city, c)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev)
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || ''))
|
||||
}, [data, events])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Governors of Britannia" lead="Who rules each city, and where the next election stands." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the governor board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>
|
||||
City Loyalty governance is not enabled on this shard.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<div className="grid-2" style={{ gap: 12 }}>
|
||||
{board.map((c) => <CityCard key={c.city} c={c} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
166
client/src/routes/public/Guilds.jsx
Normal file
166
client/src/routes/public/Guilds.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
|
||||
// The guild board. Loaded once from /public/shard/guilds, then kept live by
|
||||
// merging guild.update / guild.remove deltas; guild.join drives a small "recently
|
||||
// joined" strip on top of the board.
|
||||
const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join'])
|
||||
|
||||
function Leader({ leader }) {
|
||||
if (!leader || !leader.name) return <span className="dim">—</span>
|
||||
return <span>{leader.name}</span>
|
||||
}
|
||||
|
||||
function GuildRow({ g }) {
|
||||
return (
|
||||
<div
|
||||
className="panel"
|
||||
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
||||
{g.abbr && (
|
||||
<span
|
||||
className="sans"
|
||||
style={{
|
||||
flex: 'none',
|
||||
fontSize: '0.72rem',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--accent)',
|
||||
border: '1px solid rgba(201,162,75,0.4)',
|
||||
borderRadius: 5,
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
{g.abbr}
|
||||
</span>
|
||||
)}
|
||||
<strong
|
||||
className="display"
|
||||
style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{g.name || 'A guild'}
|
||||
</strong>
|
||||
</div>
|
||||
{g.alliance && (
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{g.alliance}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sans" style={{ flex: 'none', textAlign: 'right', fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||
<div>
|
||||
<span style={{ color: '#7fd0a4' }}>{g.online ?? 0}</span>
|
||||
<span className="dim"> / {g.members ?? 0}</span>
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>
|
||||
<Leader leader={g.leader} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Guilds() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.guilds())
|
||||
const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 })
|
||||
const [q, setQ] = useState('')
|
||||
|
||||
// Merge snapshot + live deltas by guild id (apply oldest → newest so live wins).
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const g of data || []) if (g && g.id != null) map.set(g.id, g)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev)
|
||||
else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id)
|
||||
}
|
||||
return [...map.values()]
|
||||
}, [data, events])
|
||||
|
||||
// Recent joins strip (newest first, deduped, capped).
|
||||
const joins = useMemo(
|
||||
() => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6),
|
||||
[events],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase()
|
||||
const rows = needle
|
||||
? board.filter((g) =>
|
||||
[g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)),
|
||||
)
|
||||
: board
|
||||
return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
}, [board, q])
|
||||
|
||||
const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Guilds" lead="Every guild on the shard — rosters, alliances and who's online, updating in real time." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the guild board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No guilds are being tracked right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{joins.length > 0 && (
|
||||
<section className="panel" style={{ padding: '12px 16px', marginBottom: 18 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.66rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||
Recently joined
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{joins.map((j) => (
|
||||
<div key={j._id} className="sans" style={{ fontSize: '0.84rem', color: 'var(--ink)' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{j.who.name}</strong>
|
||||
<span className="dim"> joined </span>
|
||||
{j.abbr ? `[${j.abbr}] ` : ''}{j.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
|
||||
<p className="sans" style={{ color: 'var(--accent)', fontSize: '0.8rem', margin: 0 }}>
|
||||
{board.length} guilds · {totalMembers.toLocaleString()} members
|
||||
</p>
|
||||
<input
|
||||
className="input sans"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search guilds…"
|
||||
style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.map((g) => <GuildRow key={g.id} g={g} />)}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<p className="sans dim" style={{ textAlign: 'center', marginTop: 20 }}>No guilds match “{q}”.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
88
client/src/routes/public/Houses.jsx
Normal file
88
client/src/routes/public/Houses.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
|
||||
// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner,
|
||||
// price, decay detail and the full registry are staff-only (admin Houses view).
|
||||
// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a
|
||||
// house entering IDOC appears, one leaving it drops off.
|
||||
const HOUSE_KINDS = new Set(['house.decay'])
|
||||
|
||||
function HouseRow({ h }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#e05a5a', boxShadow: '0 0 8px rgba(224,90,90,0.7)' }}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="display" style={{ fontSize: '1rem', color: 'var(--head)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.region || 'The wilderness'}
|
||||
</div>
|
||||
<div className="sans dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
|
||||
{h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.68rem', letterSpacing: '0.06em', color: '#e05a5a', border: '1px solid #e05a5a66', borderRadius: 999, padding: '2px 9px' }}>
|
||||
IDOC
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Houses() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.houses())
|
||||
const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 })
|
||||
|
||||
// Merge the IDOC snapshot with live house.decay deltas by serial: entering IDOC
|
||||
// adds/updates the row; anything else (refreshed, collapsed) drops it.
|
||||
const board = useMemo(() => {
|
||||
const map = new Map()
|
||||
for (const h of data || []) if (h && h.serial) map.set(h.serial, h)
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = events[i]
|
||||
if (ev.kind !== 'house.decay' || !ev.serial) continue
|
||||
if (String(ev.to).toUpperCase() === 'IDOC') {
|
||||
map.set(ev.serial, { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y, z: ev.z, isIdoc: true })
|
||||
} else {
|
||||
map.delete(ev.serial)
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => (a.region || '').localeCompare(b.region || ''))
|
||||
}, [data, events])
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
|
||||
<PageHeader eyebrow="Live" title="Houses in danger" lead="Homes that have fallen into IDOC — where to find them before they collapse." />
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)', flex: 'none', marginTop: 6 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the houses board right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
board.length === 0 ? (
|
||||
<section className="panel" style={{ padding: 24, textAlign: 'center' }}>
|
||||
<p className="sans dim" style={{ margin: 0 }}>No houses are collapsing right now.</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<p className="sans" style={{ color: '#e0928a', fontSize: '0.8rem', marginTop: -12, marginBottom: 20 }}>
|
||||
{board.length} in danger
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{board.map((h) => <HouseRow key={h.serial} h={h} />)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
236
client/src/routes/public/Leaderboards.jsx
Normal file
236
client/src/routes/public/Leaderboards.jsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync, useSite } from '../../core.js'
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
322
client/src/routes/public/Market.jsx
Normal file
322
client/src/routes/public/Market.jsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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={`/uo/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>
|
||||
)
|
||||
}
|
||||
99
client/src/routes/public/MarketVendor.jsx
Normal file
99
client/src/routes/public/MarketVendor.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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="/uo/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="/uo/market" className="sans">← Back to the marketplace</Link>
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
338
client/src/routes/public/Rules.jsx
Normal file
338
client/src/routes/public/Rules.jsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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>
|
||||
)
|
||||
}
|
||||
250
client/src/routes/public/Shard.jsx
Normal file
250
client/src/routes/public/Shard.jsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { describe } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import PlayersOnline from '../../components/PlayersOnline.jsx'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync, useAuth } from '../../core.js'
|
||||
|
||||
// Flavor line under the online/offline banner: online, configured-but-down, or
|
||||
// not configured yet.
|
||||
function statusMessage(online, enabled) {
|
||||
if (online) return 'The gate to Britannia stands open.'
|
||||
if (enabled) return 'The link to the game world is down — checking back automatically.'
|
||||
return 'Live shard data is not configured yet.'
|
||||
}
|
||||
|
||||
// ── Gold-supply sparkline ───────────────────────────────────────────────────
|
||||
function Sparkline({ series }) {
|
||||
if (!series || series.length < 2) return null
|
||||
const w = 320
|
||||
const h = 56
|
||||
const golds = series.map((s) => Number(s.gold) || 0)
|
||||
const min = Math.min(...golds)
|
||||
const max = Math.max(...golds)
|
||||
const span = max - min || 1
|
||||
const pts = series
|
||||
.map((s, i) => {
|
||||
const x = (i / (series.length - 1)) * w
|
||||
const y = h - ((Number(s.gold) || 0) - min) / span * h
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
})
|
||||
.join(' ')
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline points={pts} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stat tile (matches Status.jsx) ──────────────────────────────────────────
|
||||
function Stat({ value, label }) {
|
||||
return (
|
||||
<div className="panel" style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="display" style={{ fontSize: '1.6rem', color: 'var(--head)' }}>{value}</div>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginTop: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Shard() {
|
||||
const { loading, error, data } = useAsync(() =>
|
||||
Promise.all([
|
||||
api.shard.status(),
|
||||
api.shard.idoc(),
|
||||
api.shard.economy(60),
|
||||
api.shard.online(),
|
||||
]).then(([status, idoc, economy, online]) => ({ status, idoc, economy, online })),
|
||||
)
|
||||
const { events, connected } = useShardFeed({ max: 30 })
|
||||
const { user } = useAuth()
|
||||
// Staff in-game location is privileged: only admins/moderators see it. Players
|
||||
// and the public see that staff are online but not where. The server enforces
|
||||
// this too (it omits the location fields entirely for non-privileged callers).
|
||||
const canSeeLocation = user?.role === 'admin' || user?.role === 'moderator'
|
||||
|
||||
const status = data?.status
|
||||
const online = status?.pluginConnected
|
||||
const gold = status?.economy?.gold
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard" />
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load shard data right now." />}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<ConnectionBanner online={online} status={status} />
|
||||
|
||||
{/* Stat tiles */}
|
||||
<section className="grid-2" style={{ gap: 14, marginBottom: 24 }}>
|
||||
<Stat value={gold != null ? `${Number(gold).toLocaleString()}` : '—'} label="Gold supply" />
|
||||
<Stat value={online ? 'Up' : 'Down'} label="Shard link" />
|
||||
</section>
|
||||
|
||||
{/* Live players-online breakdown (total + region buckets) */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<PlayersOnline />
|
||||
</div>
|
||||
|
||||
<StaffOnline list={data.online} canSeeLocation={canSeeLocation} />
|
||||
|
||||
{/* Economy sparkline */}
|
||||
{data.economy && data.economy.length > 1 && (
|
||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||
Gold supply over time
|
||||
</div>
|
||||
<Sparkline series={data.economy} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{/* Latest IDOC */}
|
||||
<FeedList
|
||||
title="Houses in danger (IDOC)"
|
||||
empty="No houses are collapsing right now."
|
||||
items={data.idoc.map((h) => {
|
||||
const region = h.region ? ` — ${h.region}` : ''
|
||||
return {
|
||||
id: h.serial,
|
||||
text: `${h.name || 'A house'}${region}`,
|
||||
when: h.updatedAt,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live ticker */}
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
|
||||
Live feed
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<Link to="/uo/shard/activity" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.78rem' }}>
|
||||
View all activity →
|
||||
</Link>
|
||||
<span className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: '0.74rem', color: connected ? '#7fd0a4' : 'var(--muted)' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: connected ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||||
Waiting for something to happen in the world…
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{events.map((ev) => (
|
||||
<li key={ev._id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{describe(ev)}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(ev.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Online/offline banner with the flavor line under it.
|
||||
function ConnectionBanner({ online, status }) {
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '24px 26px',
|
||||
border: `1px solid ${online ? 'rgba(95,185,138,0.45)' : '#5a4a2a'}`,
|
||||
borderRadius: 10,
|
||||
background: online
|
||||
? 'linear-gradient(180deg,rgba(22,46,34,0.5),rgba(16,26,20,0.4))'
|
||||
: 'linear-gradient(180deg,rgba(58,46,22,0.5),rgba(30,26,16,0.4))',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flex: 'none',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: online ? 'var(--mode-live)' : 'var(--mode-maint)',
|
||||
boxShadow: `0 0 12px ${online ? 'rgba(95,185,138,0.7)' : 'rgba(230,194,106,0.7)'}`,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong className="display" style={{ display: 'block', fontSize: '1.2rem', color: online ? '#bfe6cf' : '#f0e3c4' }}>
|
||||
{online ? 'The shard is online' : 'The shard is offline'}
|
||||
</strong>
|
||||
<span className="sans" style={{ color: online ? '#a9cdb8' : '#cdbf9a', fontSize: '0.98rem' }}>
|
||||
{statusMessage(online, status?.enabled)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Linked staff accounts currently online; in-game location is admin/mod-only.
|
||||
function StaffOnline({ list, canSeeLocation }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20, marginBottom: 24 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
Staff online
|
||||
</div>
|
||||
{(!list || list.length === 0) ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>No staff are online right now.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{list.map((p) => (
|
||||
<div key={p.serial} className="sans" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<span style={{ flex: 'none', width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4' }} />
|
||||
{p.name || p.serial}
|
||||
</span>
|
||||
{canSeeLocation && (
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>
|
||||
{p.map || '—'}{p.x != null ? ` (${p.x}, ${p.y})` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedList({ title, items, empty }) {
|
||||
return (
|
||||
<section className="panel" style={{ padding: 20 }}>
|
||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||
{title}
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>{empty}</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{items.map((it) => (
|
||||
<li key={it.id} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.text}</span>
|
||||
<span className="dim" style={{ flex: 'none', fontSize: '0.78rem' }}>{ago(it.when)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
78
client/src/routes/public/ShardActivity.jsx
Normal file
78
client/src/routes/public/ShardActivity.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import { describe, categoryOf, kindLabel, CATEGORIES } from '../../lib/shardEvents.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
|
||||
// Public activity feed: the full shard event log, filterable by category, with a
|
||||
// live tail that prepends new events as they happen.
|
||||
export default function ShardActivity() {
|
||||
const { loading, error, data } = useAsync(() => api.shard.feed({ limit: 150 }))
|
||||
const { events: live } = useShardFeed({ max: 60 })
|
||||
const [cat, setCat] = useState('all')
|
||||
|
||||
// Merge the live tail with the loaded history, de-duped by kind+t, newest first.
|
||||
const merged = useMemo(() => {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const e of [...live, ...(data || [])]) {
|
||||
const key = `${e.kind}-${e.t}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
out.push(e)
|
||||
}
|
||||
return out.sort((a, b) => (b.t || 0) - (a.t || 0))
|
||||
}, [live, data])
|
||||
|
||||
const filtered = cat === 'all' ? merged : merged.filter((e) => categoryOf(e.kind) === cat)
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<PageHeader eyebrow="Live" title="Shard Activity" />
|
||||
<p style={{ marginTop: -8, marginBottom: 18 }}>
|
||||
<Link to="/uo/shard" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>← Back to shard</Link>
|
||||
</p>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 18 }}>
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setCat(c.id)}
|
||||
className="pill"
|
||||
style={cat === c.id ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : undefined}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load the activity feed right now." />}
|
||||
|
||||
{!loading && !error && (
|
||||
filtered.length === 0 ? (
|
||||
<div className="panel" style={{ padding: 22 }}>
|
||||
<p className="sans dim" style={{ margin: 0, fontSize: '0.9rem' }}>Nothing here yet — events will appear as they happen in the world.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filtered.map((e) => (
|
||||
<li key={e._id || `${e.kind}-${e.t}`} className="panel" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span className="sans" style={{ flex: 'none', fontSize: '0.62rem', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--accent)', minWidth: 92 }}>
|
||||
{kindLabel(e.kind)}
|
||||
</span>
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--ink)', fontSize: '0.92rem' }}>{describe(e)}</span>
|
||||
<span className="sans dim" style={{ flex: 'none', fontSize: '0.76rem' }}>{ago(e.t)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,9 @@
|
||||
// itself, only harder to see, because it shows up as a hook dispatcher error in
|
||||
// a component that looks fine.
|
||||
|
||||
const jsxRuntime = window.__rg.jsxRuntime
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const jsxRuntime = rg().jsxRuntime
|
||||
|
||||
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
|
||||
|
||||
|
||||
4
client/src/shim/react-dom.js
vendored
4
client/src/shim/react-dom.js
vendored
@@ -5,7 +5,9 @@
|
||||
// react-dom, and one that resolved to a bundled copy would put a second
|
||||
// renderer in the page.
|
||||
|
||||
const reactDom = window.__rg.reactDom
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const reactDom = rg().reactDom
|
||||
|
||||
export default reactDom.default ?? reactDom
|
||||
|
||||
|
||||
4
client/src/shim/react-router-dom.js
vendored
4
client/src/shim/react-router-dom.js
vendored
@@ -5,7 +5,9 @@
|
||||
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
|
||||
// instead of the SPA, on a page that otherwise renders perfectly.
|
||||
|
||||
const router = window.__rg.router
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const router = rg().router
|
||||
|
||||
export default router.default ?? router
|
||||
|
||||
|
||||
4
client/src/shim/react.js
vendored
4
client/src/shim/react.js
vendored
@@ -13,7 +13,9 @@
|
||||
// compiles to a named import, and a module with only a default export would fail
|
||||
// at link time in the browser with a message about the binding, not about this.
|
||||
|
||||
const react = window.__rg.react
|
||||
import { rg } from './rg.js'
|
||||
|
||||
const react = rg().react
|
||||
|
||||
export default react.default ?? react
|
||||
|
||||
|
||||
29
client/src/shim/rg.js
Normal file
29
client/src/shim/rg.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// The one place this module reads `window.__rg`, and the one place that says
|
||||
// something useful when it is not there.
|
||||
//
|
||||
// Every shim beside this file, and `src/core.js`, go through here. That is not
|
||||
// tidiness — it removes an ordering dependency that was genuinely fragile. ES
|
||||
// modules evaluate dependencies in the source order of their import statements,
|
||||
// so "put the friendly check in the file that is imported first" is a guarantee
|
||||
// that survives exactly until someone sorts the imports. Whichever module the
|
||||
// bundler happens to reach first, it reaches `window.__rg` through this.
|
||||
//
|
||||
// A missing global means core did not publish its shared dependencies before
|
||||
// this chunk evaluated: an injection or ordering fault in CORE (MODULE_API.md
|
||||
// §3.1), not a fault in this module. Without this, the first symptom is
|
||||
// "Cannot read properties of undefined (reading 'react')" thrown from a file
|
||||
// called react.js, which reads like the module bundled React wrong — the
|
||||
// opposite of what happened.
|
||||
export function rg() {
|
||||
const shared = window.__rg
|
||||
if (!shared) {
|
||||
throw new Error(
|
||||
'[module-uo] window.__rg is missing — core did not publish its shared dependencies before this ' +
|
||||
'chunk evaluated. That is an injection or ordering fault in core (MODULE_API.md §3.1), not a ' +
|
||||
'fault in this module.',
|
||||
)
|
||||
}
|
||||
return shared
|
||||
}
|
||||
|
||||
export default rg
|
||||
139
client/test/api.test.js
Normal file
139
client/test/api.test.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// ── The URLs this module calls ─────────────────────────────────────────────
|
||||
//
|
||||
// `src/api.js` binds the paths whose routes live in `server/router/**`, and the
|
||||
// interesting assertions about it are the ones that encode a DECISION rather
|
||||
// than a spelling. Three of these came across from core's `apiClient.test.js`
|
||||
// in slice 4: they had stayed behind when the bindings moved, still asserting
|
||||
// UO URLs from inside core's suite, which is the boundary this phase removes.
|
||||
//
|
||||
// What is NOT re-tested here is the fetch wrapper itself — status mapping, empty
|
||||
// bodies, FormData, cookie inclusion. That is `req`, core's primitive, and core
|
||||
// tests it. A module asserting core's contract back at it is a second copy that
|
||||
// drifts.
|
||||
//
|
||||
// The chunk reads its shared bindings off `window.__rg` at module scope
|
||||
// (src/core.js), so the fake global has to be in place before `src/api.js` is
|
||||
// imported — hence the dynamic import below rather than a static one.
|
||||
|
||||
import { test, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import * as react from 'react'
|
||||
import * as reactDom from 'react-dom/client'
|
||||
import * as router from 'react-router-dom'
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
|
||||
const BASE = '/api/v1'
|
||||
|
||||
let calls = []
|
||||
|
||||
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText,
|
||||
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
}
|
||||
}
|
||||
|
||||
// Core's `req`, close enough for a path assertion: the only property this file
|
||||
// cares about is the URL it was handed. Recording it here rather than mocking
|
||||
// global.fetch keeps the test honest about the boundary — a module never sees
|
||||
// fetch, it sees the primitive.
|
||||
function request(path, opts = {}) {
|
||||
calls.push({ url: BASE + path, opts })
|
||||
return Promise.resolve(reply({ body: {} }).text().then(() => ({})))
|
||||
}
|
||||
|
||||
// The REAL react/react-dom/router go in, not stubs: `src/core.js` compares the
|
||||
// bindings it imported against the ones here and logs a "bundled its own copy"
|
||||
// error when they differ. With stubs that error fires on every run of this file
|
||||
// — a false alarm in the exact words of a real defect, which is how a check
|
||||
// gets ignored.
|
||||
globalThis.window = globalThis.window || {}
|
||||
globalThis.window.__rg = {
|
||||
react, reactDom, router, jsxRuntime,
|
||||
api: { request, BASE },
|
||||
ui: {},
|
||||
registry: { registerRoutes() {}, registerNav() {}, registerFeatureProvider() {}, registerExtension() {} },
|
||||
}
|
||||
|
||||
const { shard, atlas, admin } = await import('../src/api.js')
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
})
|
||||
afterEach(() => {
|
||||
calls = []
|
||||
})
|
||||
|
||||
// ── 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 because the split is a design decision, not an accident of spelling.
|
||||
test('atlas reads hit /public/atlas, not /public/shard', async () => {
|
||||
await atlas.creatures()
|
||||
assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
|
||||
})
|
||||
|
||||
test('atlas.creatures() sends only the filters that are set', async () => {
|
||||
await 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 () => {
|
||||
await 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 () => {
|
||||
await admin.atlas.import(true)
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.deepEqual(calls[0].opts.body, { force: true })
|
||||
|
||||
await admin.atlas.setPath('/srv/servuo')
|
||||
assert.equal(calls[1].opts.method, 'PUT')
|
||||
assert.deepEqual(calls[1].opts.body, { path: '/srv/servuo' })
|
||||
})
|
||||
|
||||
// ── path encoding ───────────────────────────────────────────────────────────
|
||||
// A city name with an apostrophe and a space is the real case: "Serpent's Hold"
|
||||
// is a governor city, and an unencoded one would break the route match rather
|
||||
// than 404 cleanly.
|
||||
test('path params are URL-encoded', async () => {
|
||||
await shard.governorHistory('Serpent’s Hold', 5)
|
||||
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
|
||||
})
|
||||
|
||||
// ── the API surface §1.2 freezes ────────────────────────────────────────────
|
||||
// The shipped Android app calls these seven by name (data/api/AdminApi.kt), which
|
||||
// is why the extraction moved which repo declares them and not what they are. A
|
||||
// rename here is a client break, not a refactor.
|
||||
test('the seven admin URLs the Android app calls are unchanged', async () => {
|
||||
const expected = [
|
||||
['kick', '/api/v1/admin/shard/kick'],
|
||||
['ban', '/api/v1/admin/shard/ban'],
|
||||
['unban', '/api/v1/admin/shard/unban'],
|
||||
['broadcast', '/api/v1/admin/shard/broadcast'],
|
||||
]
|
||||
for (const [fn, url] of expected) {
|
||||
calls = []
|
||||
await admin.shardOps[fn]({})
|
||||
assert.equal(calls[0].url, url, fn)
|
||||
}
|
||||
calls = []
|
||||
await admin.shardOps.pages()
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/pages')
|
||||
calls = []
|
||||
await admin.shardOps.respondPage('7', {})
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/pages/7/respond')
|
||||
calls = []
|
||||
await admin.shardOps.closePage('7')
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/pages/7/close')
|
||||
})
|
||||
@@ -20,6 +20,7 @@ import { fileURLToPath } from 'node:url'
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const CLIENT = path.resolve(HERE, '..')
|
||||
|
||||
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
|
||||
const configModule = await import('../vite.config.js')
|
||||
const config = configModule.default
|
||||
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
|
||||
@@ -92,15 +93,63 @@ test('modulePreload polyfilling stays off — an inline bootstrap is refused und
|
||||
assert.strictEqual(config.build.modulePreload.polyfill, false)
|
||||
})
|
||||
|
||||
test('every shim reads from window.__rg and imports nothing', () => {
|
||||
test('exactly one file reads window.__rg, and every shim goes through it', () => {
|
||||
// `shim/rg.js` is the single reader, and that is not tidiness: it is what
|
||||
// makes the "core did not publish its dependencies" message reachable. The
|
||||
// shims touch the global before anything else in the chunk does, so a check
|
||||
// placed in the first-imported file is a guarantee that lasts until someone
|
||||
// sorts the imports.
|
||||
const dir = path.join(CLIENT, 'src', 'shim')
|
||||
const shims = fs.readdirSync(dir)
|
||||
assert.ok(shims.length >= 4)
|
||||
assert.ok(shims.length >= 5)
|
||||
for (const file of shims) {
|
||||
const source = fs.readFileSync(path.join(dir, file), 'utf8')
|
||||
assert.match(source, /window\.__rg/, `${file} does not read the global`)
|
||||
// A shim that imported anything would be a shim with a dependency to
|
||||
// resolve, which is the problem it exists to remove.
|
||||
assert.doesNotMatch(source, /^\s*import\s/m, `${file} imports something`)
|
||||
const code = source.replace(/^\s*\/\/.*$/gm, '') // the comments discuss the global
|
||||
if (file === 'rg.js') {
|
||||
assert.match(code, /window\.__rg/, 'rg.js must be the one that reads the global')
|
||||
assert.doesNotMatch(code, /^\s*import\s/m, 'rg.js imports something')
|
||||
continue
|
||||
}
|
||||
assert.doesNotMatch(code, /window\.__rg/, `${file} reads the global directly instead of via rg()`)
|
||||
assert.match(code, /rg\(\)/, `${file} does not resolve through rg()`)
|
||||
// A shim may import its sibling helper and nothing else — anything further
|
||||
// would be a shim with a dependency to resolve, the problem it exists to remove.
|
||||
for (const [, spec] of code.matchAll(/^\s*import\s[^'"]*['"]([^'"]+)['"]/gm)) {
|
||||
assert.strictEqual(spec, './rg.js', `${file} imports ${spec}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('the built chunk has no bare imports and bundles no shared dependency', () => {
|
||||
// The artifact check itself, over the artifact that ships. Skipped rather than
|
||||
// failed when there is no build: `npm test` must be runnable before `npm run
|
||||
// build`, and CI runs them in order.
|
||||
const chunk = path.join(CLIENT, 'dist', 'entry.js')
|
||||
if (!fs.existsSync(chunk)) return
|
||||
assert.deepStrictEqual(problemsWith(fs.readFileSync(chunk, 'utf8')), [])
|
||||
})
|
||||
|
||||
test('an import inside a string is not an import — the check reads code, not text', () => {
|
||||
// The regression that made this necessary: slice 3's chunk was the first with
|
||||
// any content in it, and a button labelled "Approve and import" put the token
|
||||
// immediately before a quote. The check rejected the whole build, naming a
|
||||
// fragment of minified JSX as the offending specifier.
|
||||
const uiCopy = 'const a=n("button",{children:"Approve and import"}),b=1;'
|
||||
assert.deepStrictEqual(bareImports(uiCopy), [])
|
||||
|
||||
// Neither is one in a comment, or in a template literal.
|
||||
assert.deepStrictEqual(bareImports('// import "react" would be wrong here\nconst a=1'), [])
|
||||
assert.deepStrictEqual(bareImports('/* import "react" */ const a=1'), [])
|
||||
assert.deepStrictEqual(bareImports('const s=`import "react"`'), [])
|
||||
|
||||
// And a real one still is, in each form the build could emit.
|
||||
assert.deepStrictEqual(bareImports('import"react";'), ['react'])
|
||||
assert.deepStrictEqual(bareImports('import{useState}from"react";'), ['react'])
|
||||
assert.deepStrictEqual(bareImports('const m=await import("react-dom/client")'), ['react-dom/client'])
|
||||
// A relative specifier is a split chunk, not a shared dependency: not our concern.
|
||||
assert.deepStrictEqual(bareImports('import"./other.js";'), [])
|
||||
|
||||
// The case that proves the mask tracks escapes: a quote escaped INSIDE a
|
||||
// string must not end it early and leave the tail looking like code.
|
||||
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
|
||||
})
|
||||
|
||||
60
client/test/regionBuckets.test.js
Normal file
60
client/test/regionBuckets.test.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { bucketize, BUCKETS } from '../src/data/regionBuckets.js'
|
||||
|
||||
// Unit-test the presence.online region roll-up for the "Players Online" widget.
|
||||
// The load-bearing invariant: the bucket counts ALWAYS reconcile to the true
|
||||
// total — anything unmatched lands in Wilderness — so the widget can never show
|
||||
// a sum that disagrees with the headline online count.
|
||||
|
||||
test('bucketize groups named regions into their buckets', () => {
|
||||
const { rows, total } = bucketize({
|
||||
'Britain': 4,
|
||||
'Moonglow': 2,
|
||||
'Despise': 3,
|
||||
'Green Acres House 12': 1, // not a town/dungeon name → Housing
|
||||
})
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
|
||||
assert.equal(byId.britain, 4)
|
||||
assert.equal(byId.towns, 2)
|
||||
assert.equal(byId.dungeons, 3)
|
||||
assert.equal(byId.housing, 1)
|
||||
assert.equal(total, 10)
|
||||
})
|
||||
|
||||
test('first match wins by BUCKETS order: a town-named house region counts as Towns, not Housing', () => {
|
||||
// The towns regex is ^-anchored and towns is checked BEFORE housing, so a house
|
||||
// region whose name starts with a town name is bucketed as Towns. Pinning this
|
||||
// documents the ordering dependency for anyone retuning BUCKETS.
|
||||
const { rows } = bucketize({ 'Trinsic House 12': 1 })
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
|
||||
assert.equal(byId.towns, 1)
|
||||
assert.equal(byId.housing, undefined) // empty bucket dropped
|
||||
})
|
||||
|
||||
test('an unmatched region falls through to Wilderness so counts always reconcile', () => {
|
||||
const { rows, total } = bucketize({ 'Some Unnamed Field': 5, 'Wilderness': 2 })
|
||||
const wilderness = rows.find((r) => r.id === 'wilderness')
|
||||
assert.equal(wilderness.count, 7)
|
||||
assert.equal(total, 7)
|
||||
// The reconciliation guarantee: the buckets sum to the total, exactly.
|
||||
assert.equal(rows.reduce((s, r) => s + r.count, 0), total)
|
||||
})
|
||||
|
||||
test('bucketize returns rows in BUCKETS order and drops empty buckets', () => {
|
||||
const { rows } = bucketize({ 'Despise': 1, 'Britain': 1 })
|
||||
assert.deepEqual(rows.map((r) => r.id), ['britain', 'dungeons']) // BUCKETS order, no empty towns/housing/wilderness
|
||||
})
|
||||
|
||||
test('bucketize coerces non-numeric counts and tolerates empty/nullish input', () => {
|
||||
assert.deepEqual(bucketize({}), { rows: [], total: 0 })
|
||||
assert.deepEqual(bucketize(), { rows: [], total: 0 })
|
||||
const { total } = bucketize({ 'Britain': '3', 'Minoc': 'oops' })
|
||||
assert.equal(total, 3) // '3' → 3, 'oops' → 0
|
||||
})
|
||||
|
||||
test('the last bucket is the catch-all (its match accepts anything)', () => {
|
||||
const last = BUCKETS[BUCKETS.length - 1]
|
||||
assert.equal(last.id, 'wilderness')
|
||||
assert.equal(last.match('literally anything'), true)
|
||||
})
|
||||
200
client/test/registration.test.js
Normal file
200
client/test/registration.test.js
Normal file
@@ -0,0 +1,200 @@
|
||||
// ── What the chunk registers, checked without a browser ────────────────────
|
||||
//
|
||||
// `build.test.js` says the honest thing about this half: its real failures are
|
||||
// timing and resolution, and a DOM-less runner cannot see either. That is still
|
||||
// true, and MODULE_API.md §7.7's browser smoke is still what proves the module
|
||||
// works. But it left a gap worth closing, and slice 3 is when it started to
|
||||
// matter: nothing checked *what* the chunk registers.
|
||||
//
|
||||
// It can be checked, because registration is the one thing this chunk does at
|
||||
// evaluation time and it does it through an object core hands it. So: stand up a
|
||||
// fake `window.__rg` with a recording registry and the real React behind it,
|
||||
// import the BUILT artifact, and read back what it asked for. No DOM is needed
|
||||
// because nothing renders — `<Shard />` is `jsx(Shard)`, an object, and the
|
||||
// route table is full of them by design.
|
||||
//
|
||||
// What this catches that review does not: a page that silently stops being
|
||||
// routed, a nav row whose `to` drifts from its route's path, a slot fill that
|
||||
// was renamed on one side, and the whole registration surface disappearing
|
||||
// because an exception was thrown halfway down entry.jsx.
|
||||
//
|
||||
// What it deliberately does NOT do is re-assert the paths as a literal list.
|
||||
// The interesting property is that the nav and the routes AGREE, and a test that
|
||||
// restates both is a second copy of the thing it is checking.
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import * as react from 'react'
|
||||
import * as jsxRuntime from 'react/jsx-runtime'
|
||||
import * as router from 'react-router-dom'
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
|
||||
|
||||
// A component, as far as the registry cares. The kit's real members are core's;
|
||||
// nothing here renders, so a named stub is enough to be imported and passed on.
|
||||
const stub = (name) => Object.assign(() => null, { displayName: name })
|
||||
|
||||
function fakeRg() {
|
||||
const routes = { public: [], admin: [], player: [] }
|
||||
const nav = { public: [], admin: [], player: [] }
|
||||
const providers = new Map()
|
||||
const extensions = new Map()
|
||||
return {
|
||||
version: '1.3.0',
|
||||
react,
|
||||
jsxRuntime,
|
||||
router,
|
||||
// `react-dom/client` is imported for the identity check in core.js and never
|
||||
// called — createRoot in a DOM-less process would throw. The shim reads this
|
||||
// object, so the check compares against whatever is here.
|
||||
reactDom: { createRoot: () => { throw new Error('not in a browser') } },
|
||||
ui: Object.fromEntries(
|
||||
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite']
|
||||
.map((n) => [n, stub(n)]),
|
||||
),
|
||||
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||
registry: {
|
||||
registerRoutes(id, byArea) {
|
||||
for (const [area, list] of Object.entries(byArea || {})) {
|
||||
for (const r of list || []) routes[area].push({ ...r, path: `${id}/${r.path}`, moduleId: id })
|
||||
}
|
||||
},
|
||||
registerNav(id, { area, items }) {
|
||||
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
|
||||
},
|
||||
registerFeatureProvider(id, namespace, hook) { providers.set(namespace, { id, hook }) },
|
||||
registerExtension(id, slot, Component) {
|
||||
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
|
||||
extensions.set(slot, { id, Component })
|
||||
},
|
||||
routesFor: (area) => routes[area],
|
||||
navFor: (area) => nav[area],
|
||||
},
|
||||
_read: () => ({ routes, nav, providers, extensions }),
|
||||
}
|
||||
}
|
||||
|
||||
// Loaded once: an ES module is evaluated a single time per process however many
|
||||
// times it is imported, so every test below reads the same registration pass —
|
||||
// which is also how it behaves in a browser.
|
||||
let registered = null
|
||||
let skip = false
|
||||
|
||||
if (!fs.existsSync(CHUNK)) {
|
||||
skip = true
|
||||
} else {
|
||||
const rg = fakeRg()
|
||||
globalThis.window = { __rg: rg }
|
||||
await import(`${new URL(`file://${CHUNK.split(path.sep).join('/')}`)}`)
|
||||
registered = rg._read()
|
||||
}
|
||||
|
||||
const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run npm run build' }, fn)
|
||||
|
||||
it('registers routes in all three areas, namespaced under the module id', () => {
|
||||
const { routes } = registered
|
||||
assert.equal(routes.public.length, 12)
|
||||
assert.equal(routes.admin.length, 7)
|
||||
assert.equal(routes.player.length, 2)
|
||||
for (const area of ['public', 'admin', 'player']) {
|
||||
for (const r of routes[area]) {
|
||||
assert.match(r.path, /^uo\//, `${area} route "${r.path}" is not under the module namespace`)
|
||||
assert.ok(r.element, `${area} route "${r.path}" has no element`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('every route path is distinct within its area', () => {
|
||||
// Two routes on one path is a page that can never be reached, and React
|
||||
// renders the first without complaint.
|
||||
for (const [area, list] of Object.entries(registered.routes)) {
|
||||
const paths = list.map((r) => r.path)
|
||||
assert.equal(new Set(paths).size, paths.length, `duplicate path in ${area}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('every nav row points at a route this module actually registered', () => {
|
||||
// The agreement that matters, and the one that rots quietly: a row survives a
|
||||
// route rename and becomes a link to core's catch-all redirect. Nav rows carry
|
||||
// the FULL rendered path (`/uo/shard`), routes carry the namespaced one
|
||||
// (`uo/shard`), and reconciling them is the whole test.
|
||||
const rendered = {
|
||||
public: (p) => `/${p}`,
|
||||
admin: (p) => `/admin/${p}`,
|
||||
player: (p) => `/player/${p}`,
|
||||
}
|
||||
for (const [area, rows] of Object.entries(registered.nav)) {
|
||||
const reachable = new Set(registered.routes[area].map((r) => rendered[area](r.path)))
|
||||
for (const row of rows) {
|
||||
assert.ok(
|
||||
reachable.has(row.to),
|
||||
`${area} nav row "${row.label}" links to ${row.to}, which no route serves`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('every admin and player nav row carries an icon', () => {
|
||||
// Both of those navs render a glyph on every core row, so a row without one
|
||||
// reads as breakage rather than as a design. The PUBLIC header is text
|
||||
// buttons and is deliberately excluded.
|
||||
//
|
||||
// The player half of this assertion is not symmetry for its own sake. Core's
|
||||
// PlayerPortalLayout rendered `<n.icon />` UNGUARDED — fine for as long as
|
||||
// every row in it was core's own and had one, and React error #130 with a
|
||||
// blank portal the moment a module registered one without. Core is guarded
|
||||
// now, but a missing icon there is still a visible defect and this is the
|
||||
// cheap place to catch it.
|
||||
for (const area of ['admin', 'player']) {
|
||||
for (const row of registered.nav[area]) {
|
||||
assert.equal(typeof row.icon, 'function', `${area} nav row "${row.label}" has no icon`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('a nav row that gates on a feature is gated by a namespace this module provides', () => {
|
||||
// Resolution is by the REGISTERING module (§3.3), so a `feature` on a row from
|
||||
// a module that registered no provider resolves against nothing — and
|
||||
// everything fails open, which would re-advertise surfaces an operator hid.
|
||||
const gated = Object.values(registered.nav).flat().filter((r) => r.feature)
|
||||
assert.ok(gated.length > 0)
|
||||
assert.ok(registered.providers.has('uo'), 'rows carry feature gates but no provider was registered')
|
||||
})
|
||||
|
||||
it('fills the three extension slots, each with a component', () => {
|
||||
const { extensions } = registered
|
||||
assert.deepEqual(
|
||||
[...extensions.keys()].sort(),
|
||||
['admin.users.detail', 'player.invite.accepted', 'site.footer.status'],
|
||||
)
|
||||
for (const [slot, { id, Component }] of extensions) {
|
||||
assert.equal(id, 'uo', `${slot} was filled under the wrong owner id`)
|
||||
assert.equal(typeof Component, 'function', `${slot} was not filled with a component`)
|
||||
}
|
||||
})
|
||||
|
||||
it('the manifest\'s declared server slot is one this module fills', () => {
|
||||
// module.json declares SERVER slots and the loader validates them before the
|
||||
// chunk is ever served. Client slots cannot be declared there — the server has
|
||||
// no knowledge of them — so this is the one place the two halves are compared.
|
||||
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
|
||||
for (const slot of manifest.extensions || []) {
|
||||
assert.ok(registered.extensions.has(slot), `module.json declares "${slot}" and the chunk does not fill it`)
|
||||
}
|
||||
})
|
||||
|
||||
it('registers under exactly one module id, matching the manifest', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.resolve(HERE, '..', '..', 'module.json'), 'utf8'))
|
||||
const owners = new Set([
|
||||
...Object.values(registered.routes).flat().map((r) => r.moduleId),
|
||||
...Object.values(registered.nav).flat().map((r) => r.moduleId),
|
||||
...[...registered.extensions.values()].map((e) => e.id),
|
||||
...[...registered.providers.values()].map((p) => p.id),
|
||||
])
|
||||
assert.deepEqual([...owners], [manifest.id])
|
||||
})
|
||||
74
client/test/shardEvents.test.js
Normal file
74
client/test/shardEvents.test.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, categoryOf, kindLabel, CATEGORIES } from '../src/lib/shardEvents.js'
|
||||
|
||||
// Unit-test the shared shard-event formatter — the single place that decides how
|
||||
// each event kind reads and which filter category it belongs to. These strings
|
||||
// are user-facing on the public Shard page, the Activity feed, and the admin
|
||||
// live feed, so a regression here is visible everywhere at once.
|
||||
|
||||
// ── describe(): works on both stored (.payload) and live (top-level) frames ──
|
||||
test('describe reads fields from .payload when present, else the top level', () => {
|
||||
const stored = { kind: 'quest.complete', payload: { who: { name: 'Ada' }, quest: 'The Cavern' } }
|
||||
const live = { kind: 'quest.complete', who: { name: 'Ada' }, quest: 'The Cavern' }
|
||||
assert.equal(describe(stored), 'Ada completed “The Cavern”')
|
||||
assert.equal(describe(live), 'Ada completed “The Cavern”')
|
||||
})
|
||||
|
||||
test('describe resolves an actor from name → acct → "Someone"', () => {
|
||||
assert.equal(describe({ kind: 'mob.login', who: { name: 'Bob' } }), 'Bob entered the world')
|
||||
assert.equal(describe({ kind: 'mob.login', who: { acct: 'acct7' } }), 'acct7 entered the world')
|
||||
assert.equal(describe({ kind: 'mob.login', who: null }), 'Someone entered the world')
|
||||
assert.equal(describe({ kind: 'mob.login', who: 'RawString' }), 'RawString entered the world')
|
||||
})
|
||||
|
||||
test('describe pluralizes a vendor sale only when amount > 1 and formats the price', () => {
|
||||
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Katana', amount: 1, price: 1200 }), 'Katana sold for 1,200gp')
|
||||
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Arrow', amount: 40, price: 80 }), 'Arrow ×40 sold for 80gp')
|
||||
})
|
||||
|
||||
test('describe includes the killer only when present (optional clause)', () => {
|
||||
assert.equal(describe({ kind: 'player.death', who: { name: 'Ada' } }), 'Ada was slain')
|
||||
assert.equal(
|
||||
describe({ kind: 'player.death', who: { name: 'Ada' }, killer: { name: 'Orc' } }),
|
||||
'Ada was slain by Orc',
|
||||
)
|
||||
})
|
||||
|
||||
test('describe champ.update branches on status and boss state', () => {
|
||||
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', bossUp: true }), 'Rikktor: boss is up')
|
||||
assert.equal(
|
||||
describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', level: 3 }),
|
||||
'Rikktor is active — level 3',
|
||||
)
|
||||
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'cooldown' }), 'Rikktor is on cooldown')
|
||||
})
|
||||
|
||||
test('describe falls back to the raw kind for an unknown event', () => {
|
||||
assert.equal(describe({ kind: 'some.future.kind' }), 'some.future.kind')
|
||||
})
|
||||
|
||||
// ── categoryOf(): membership + catch-all ────────────────────────────────
|
||||
test('categoryOf groups kinds per the CATEGORIES table, and unknowns are "other"', () => {
|
||||
assert.equal(categoryOf('player.death'), 'pvp')
|
||||
assert.equal(categoryOf('skill.gain'), 'progress')
|
||||
assert.equal(categoryOf('house.decay'), 'world')
|
||||
assert.equal(categoryOf('vendor.sale'), 'other') // deliberately not a public category
|
||||
assert.equal(categoryOf('totally.unknown'), 'other')
|
||||
})
|
||||
|
||||
test('every kind listed in CATEGORIES maps back to that category (table stays consistent)', () => {
|
||||
for (const cat of CATEGORIES) {
|
||||
if (!cat.kinds) continue
|
||||
for (const kind of cat.kinds) {
|
||||
assert.equal(categoryOf(kind), cat.id, `${kind} should be in ${cat.id}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── kindLabel(): badge text ─────────────────────────────────────────────
|
||||
test('kindLabel turns dots/underscores into spaces and tolerates empty input', () => {
|
||||
assert.equal(kindLabel('player.death'), 'player death')
|
||||
assert.equal(kindLabel('account.login.attempt'), 'account login attempt')
|
||||
assert.equal(kindLabel(null), '')
|
||||
})
|
||||
15
module.json
15
module.json
@@ -1,8 +1,17 @@
|
||||
{
|
||||
"id": "uo",
|
||||
"name": "Ultima Online",
|
||||
"version": "0.1.0",
|
||||
"coreApi": "^1.0.0",
|
||||
"version": "0.3.0",
|
||||
"coreApi": "^1.3.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" }
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
"purge": "server/db/purge.sql",
|
||||
"mounts": {
|
||||
"public": ["/shard", "/atlas"],
|
||||
"admin": ["/shard", "/uo-link"],
|
||||
"player": ["/shard"]
|
||||
},
|
||||
"extensions": ["admin.users.detail"],
|
||||
"capabilities": ["shard", "atlas", "market", "governors", "guilds", "houses", "champs", "cliloc"]
|
||||
}
|
||||
|
||||
365
routes.manifest.json
Normal file
365
routes.manifest.json
Normal file
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"$comment": "Generated inventory of the URLs module-uo serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest workflow; see server/scripts/frozenManifest.js.",
|
||||
"routes": [
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/uo-link/towncrier/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/shard/link/:account",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/accounts",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/atlas",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/audit",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/char/:serial",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/clilocs",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/houses",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/pages",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/roster/:account",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/sales",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/vendors/:account",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/shard/visibility",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/uo-link/config",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/uo-link/signup-mode",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/uo-link/stream",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/accounts",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/houses",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/online",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/sales",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/shard/standing",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/accounts",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/char/:serial",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/houses",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/roster/:account",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/sales",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/player/shard/vendors/:account",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/champions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/creatures/:slug",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/landmarks",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/meta",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/atlas/regions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/champs",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/economy",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/features",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/feed",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/governors",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/governors/:city/history",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/guilds",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/houses",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/idoc",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/meta",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/market/vendors/:serial",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/online",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/points/:system",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/presence",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/ruleset",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/status",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/shard/stream",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/account",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/approve",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/import",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/atlas/reject",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/ban",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/broadcast",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/clilocs/import",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/kick",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/link",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/pages/:id/close",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/pages/:id/respond",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/unban",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/uo-link/towncrier",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/shard/account",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/player/shard/link",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/atlas/path",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/clilocs/path",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/shard/visibility",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/uo-link/config",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/uo-link/signup-mode",
|
||||
"tier": "public"
|
||||
}
|
||||
]
|
||||
}
|
||||
121
server/boot.js
Normal file
121
server/boot.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// ── onBoot / onShutdown ────────────────────────────────────────────────────
|
||||
//
|
||||
// The eight UO call sites that used to sit in core's `server.js`. `register()`
|
||||
// runs with no database (MODULE_API.md §2.2); everything here runs with one.
|
||||
//
|
||||
// Core dispatches `onBoot` after `ensureSchema` and the schema-fragment replay,
|
||||
// and **before the HTTP listener binds** — so the tables these functions touch
|
||||
// exist, and nothing is served until the warm-up finishes. That ordering is the
|
||||
// contract's promise rather than an accident, and it is why `onBoot` has no
|
||||
// timeout: a module that must not serve traffic until a cache is warm only gets
|
||||
// that guarantee if the listener is still closed.
|
||||
//
|
||||
// **One behavioural change, and it is deliberate.** In core, `uoLinkSocket.start()`
|
||||
// and the sidecar health probe ran AFTER the listener bound; here they run before
|
||||
// it. `start()` returns as soon as the reconnecting client is armed, so that part
|
||||
// is free — but the probe is a real HTTP call to the sidecar, and an unreachable
|
||||
// sidecar must not hold the site closed. It is therefore fired and NOT awaited,
|
||||
// with its own catch. Reporting whether the bridge is up is diagnostics; being up
|
||||
// is not a precondition for serving a page, and the site is required to degrade
|
||||
// gracefully when the shard is down.
|
||||
//
|
||||
// Everything here is best-effort by the same rule. A module whose `onBoot`
|
||||
// throws is marked `startup_failed` and its routes answer 503 (§4.4), which is
|
||||
// the right outcome for a broken module — but "the operator has not configured a
|
||||
// ServUO path" is not a broken module, and neither is "the shard is offline".
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
const uoLinkSocket = require('./utils/uoLinkSocket')
|
||||
const uoLinkClient = require('./utils/uoLinkClient')
|
||||
const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model')
|
||||
const shardBroadcast = require('./utils/shardBroadcast')
|
||||
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
|
||||
const shardClilocs = require('./model/shardClilocs/shardClilocs.model')
|
||||
const shardMarket = require('./model/shardMarket/shardMarket.model')
|
||||
|
||||
/**
|
||||
* Best-effort startup probe of the uo-link sidecar.
|
||||
*
|
||||
* Logs whether it is reachable and warns loudly on a protocol mismatch —
|
||||
* fail-fast visibility rather than silently mis-parsing a newer wire format.
|
||||
* Never throws, and is never awaited by `onBoot`.
|
||||
*/
|
||||
async function checkUoLink() {
|
||||
const log = core.logger('boot')
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
if (!config.enabled) return
|
||||
const health = await uoLinkClient.health()
|
||||
if (!health.ok) {
|
||||
log.warn('uo-link is enabled but the sidecar is unreachable at startup', {
|
||||
baseUrl: config.baseUrl,
|
||||
error: health.error || `status ${health.status}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (health.data && health.data.protocol && health.data.protocol !== config.protocol) {
|
||||
log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', {
|
||||
pinned: config.protocol,
|
||||
sidecar: health.data.protocol,
|
||||
})
|
||||
} else {
|
||||
log.info('uo-link sidecar reachable', {
|
||||
pluginConnected: health.data && health.data.plugin_connected,
|
||||
protocol: health.data && health.data.protocol,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function onBoot() {
|
||||
const log = core.logger('boot')
|
||||
|
||||
// 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()
|
||||
|
||||
// Start the uo-link WebSocket ingest client. Self-guards: it only actually
|
||||
// connects when the admin has enabled the integration and saved a token, so
|
||||
// this is a no-op on shards that haven't configured the sidecar.
|
||||
try {
|
||||
await uoLinkSocket.start()
|
||||
} catch (err) {
|
||||
log.warn('uo-link socket failed to start (continuing)', { error: err.message })
|
||||
}
|
||||
|
||||
// Deliberately not awaited — see the header. An unreachable sidecar would
|
||||
// otherwise hold the listener closed for the length of an HTTP timeout.
|
||||
checkUoLink().catch((err) => log.warn('uo-link startup probe failed', { error: err.message }))
|
||||
}
|
||||
|
||||
async function onShutdown() {
|
||||
// Core runs this FIRST in its signal handler, while everything it handed over
|
||||
// still works — the pool is open, the push dispatcher is up, the SSE fan-out
|
||||
// is live. It is the only chance to close cleanly, and it is budgeted, so a
|
||||
// hook that will not let go costs five seconds rather than the whole shutdown.
|
||||
uoLinkSocket.stop() // close the uo-link WS ingest client
|
||||
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
|
||||
}
|
||||
|
||||
module.exports = { onBoot, onShutdown, checkUoLink }
|
||||
172
server/config/shardStreams.js
Normal file
172
server/config/shardStreams.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// ── Shard-derived push streams + event → stream mapping ────────────────────
|
||||
//
|
||||
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.8 named
|
||||
// config/notificationStreams.js as one of the three genuinely entangled files:
|
||||
// most of its catalog and all of `mapShardEvent` are shard-derived, and it reads
|
||||
// `PUBLIC_KINDS` out of utils/shardBroadcast. PR 4 split it — core's one stream
|
||||
// is config/coreStreams.js, and everything shard-shaped is here, in a file that
|
||||
// moves to module-uo whole in Phase 3. Nothing in core imports it except
|
||||
// modules/registries.js's registerCore(), which is the one line Phase 3 deletes.
|
||||
//
|
||||
// Two families:
|
||||
// • public / opt-in — no linked game account required; delivered to every
|
||||
// subscriber. Drawn ONLY from the SSE public allowlist
|
||||
// (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
|
||||
// can never produce a public push.
|
||||
// • personal / owner-keyed — require a linked game account; delivered ONLY to
|
||||
// the owning user's devices (resolved from the event's
|
||||
// game account via shardLinks), never fanned out publicly.
|
||||
//
|
||||
// The payload the relay ever carries is a CONTENT-FREE tickle ({ stream, ref });
|
||||
// `ref` is an opaque hint (serial / city / timestamp) the app uses to pull the
|
||||
// real, ownership-checked content over the authenticated API. So even a leaked
|
||||
// ntfy topic reveals nothing (docs/android/PLAN.md §11).
|
||||
|
||||
const { PUBLIC_KINDS } = require('../utils/shardBroadcast')
|
||||
|
||||
const STREAMS = [
|
||||
{
|
||||
id: 'server.status',
|
||||
label: 'Server up / down',
|
||||
description: 'The shard comes online or goes offline.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'idoc.warning',
|
||||
label: 'IDOC warnings',
|
||||
description: 'A house falls into its final (IDOC) decay stage.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'champ.start',
|
||||
label: 'Champion spawn starts',
|
||||
description: 'A champion spawn becomes active.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'governor.election',
|
||||
label: 'Governor elections',
|
||||
description: 'A town elects a new governor.',
|
||||
personal: false,
|
||||
requiresLinkedAccount: false,
|
||||
},
|
||||
{
|
||||
id: 'vendor.sale',
|
||||
label: 'Your vendor sold an item',
|
||||
description: 'One of your player vendors made a sale.',
|
||||
personal: true,
|
||||
requiresLinkedAccount: true,
|
||||
},
|
||||
{
|
||||
id: 'house.idoc',
|
||||
label: 'Your house entered IDOC',
|
||||
description: 'One of your houses fell into its final decay stage.',
|
||||
personal: true,
|
||||
requiresLinkedAccount: true,
|
||||
},
|
||||
{
|
||||
id: 'account.login',
|
||||
label: 'A login to your account',
|
||||
description: 'An authentication attempt against your game account.',
|
||||
personal: true,
|
||||
requiresLinkedAccount: true,
|
||||
},
|
||||
]
|
||||
|
||||
// The owner-keyed subset, needed by mapShardEvent's public-safety filter below.
|
||||
// Derived from this file's own catalog rather than read back out of the registry:
|
||||
// the filter is about THESE streams, and a module must not be able to weaken it
|
||||
// by registering something that happens to share an id.
|
||||
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))
|
||||
|
||||
// Per-process transition state so full-state upserts (champ.update / city.update
|
||||
// are upserts, not discrete "started"/"elected" events — see docs/link
|
||||
// PROTOCOL_2 §383) only fire once, on an actual transition. Injectable so tests
|
||||
// pass a fresh tracker; a module-level default backs the live dispatcher.
|
||||
function createTracker() {
|
||||
return { champActive: new Map(), cityGovernor: new Map() }
|
||||
}
|
||||
const defaultTracker = createTracker()
|
||||
|
||||
// Per-kind mappers, each pushing 0+ targets onto `out` (and updating `tracker`
|
||||
// for the upsert-transition kinds). Split out of mapShardEvent so that function
|
||||
// stays a trivial dispatch + the public-safety filter.
|
||||
const serverStatusUp = (event, tracker, out) =>
|
||||
out.push({ streamId: 'server.status', ref: `up:${event.bootId || ''}` })
|
||||
const serverStatusDown = (event, tracker, out) => out.push({ streamId: 'server.status', ref: 'down' })
|
||||
|
||||
const EVENT_MAPPERS = {
|
||||
'server.hello': serverStatusUp,
|
||||
'server.shutdown': serverStatusDown,
|
||||
'server.crashed': serverStatusDown,
|
||||
'house.decay': (event, tracker, out) => {
|
||||
if (String(event.to).toUpperCase() !== 'IDOC') return
|
||||
const ref = String(event.serial ?? '')
|
||||
out.push({ streamId: 'idoc.warning', ref }) // public — location only
|
||||
if (event.ownerAcct) {
|
||||
out.push({ streamId: 'house.idoc', ref, ownerAccount: event.ownerAcct }) // personal
|
||||
}
|
||||
},
|
||||
'champ.update': (event, tracker, out) => {
|
||||
const { serial } = event
|
||||
if (serial == null) return
|
||||
const wasActive = tracker.champActive.get(serial) === true
|
||||
const isActive = event.active === true
|
||||
tracker.champActive.set(serial, isActive)
|
||||
if (isActive && !wasActive) out.push({ streamId: 'champ.start', ref: String(serial) })
|
||||
},
|
||||
'champ.remove': (event, tracker) => {
|
||||
if (event.serial != null) tracker.champActive.delete(event.serial)
|
||||
},
|
||||
'city.update': (event, tracker, out) => {
|
||||
const { city } = event
|
||||
if (!city) return
|
||||
const gov = event.governor && event.governor.serial != null ? String(event.governor.serial) : null
|
||||
const prev = tracker.cityGovernor.get(city)
|
||||
tracker.cityGovernor.set(city, gov)
|
||||
// Only a real transition to a new governor, and never on first sight
|
||||
// (prev === undefined) so a reconnect snapshot isn't read as an election.
|
||||
if (prev !== undefined && gov && gov !== prev) {
|
||||
out.push({ streamId: 'governor.election', ref: String(city) })
|
||||
}
|
||||
},
|
||||
'vendor.sale': (event, tracker, out) => {
|
||||
if (event.ownerAcct) {
|
||||
out.push({ streamId: 'vendor.sale', ref: String(event.t ?? ''), ownerAccount: event.ownerAcct })
|
||||
}
|
||||
},
|
||||
'account.login.attempt': (event, tracker, out) => {
|
||||
if (event.acct) {
|
||||
out.push({ streamId: 'account.login', ref: String(event.t ?? ''), ownerAccount: event.acct })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Map one shard event → an array of targets ({ streamId, ref, ownerAccount? }).
|
||||
// May yield 0, 1, or 2 targets (an owner house.decay produces both the public
|
||||
// idoc.warning and the personal house.idoc). Pure given `tracker`.
|
||||
function mapShardEvent(event, tracker = defaultTracker) {
|
||||
if (!event || typeof event.kind !== 'string') return []
|
||||
const kind = event.kind
|
||||
const out = []
|
||||
|
||||
const mapper = EVENT_MAPPERS[kind]
|
||||
if (mapper) mapper(event, tracker, out)
|
||||
|
||||
// Defense in depth: a PUBLIC (non-personal) target may only ride a public-safe
|
||||
// kind. Personal targets are owner-keyed and delivered solely to the owner, so
|
||||
// they are exempt from the public allowlist (that is the whole point of the
|
||||
// owner-keyed split). This guarantees a sensitive kind can never leak publicly
|
||||
// even if a future mapping case is added carelessly.
|
||||
//
|
||||
// This filter, the kinds it reads and the streams it protects now all live in
|
||||
// one file and move together — the reason PR 4 dropped the contract's
|
||||
// `mapEvent` half rather than leaving the mapping in core and the catalog in a
|
||||
// module (MODULE_API.md §2.4).
|
||||
return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
|
||||
}
|
||||
|
||||
module.exports = { STREAMS, mapShardEvent, createTracker, PERSONAL_STREAMS }
|
||||
112
server/core.js
Normal file
112
server/core.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// ── Everything this module reaches in core ─────────────────────────────────
|
||||
//
|
||||
// `ctx` arrives once, as an argument to `register()` (MODULE_API.md §2.3). The
|
||||
// code below it — models, utils, controllers — is ordinary Node that requires
|
||||
// its dependencies at file scope, the way it did when it lived in core. This
|
||||
// file is what lets both be true.
|
||||
//
|
||||
// **The shape is a lazy accessor, not a stored reference, and that is the whole
|
||||
// point.** A ported file writes
|
||||
//
|
||||
// const { query } = require('../../core')
|
||||
//
|
||||
// at require time, which is before `register()` has been called and therefore
|
||||
// before any `ctx` exists. Handing out `ctx.db.query` there would hand out
|
||||
// `undefined`, permanently, and the failure would surface much later as a
|
||||
// TypeError inside a model. So every export here is a stable function that
|
||||
// resolves `ctx` when it is CALLED. Require order stops mattering, and the port
|
||||
// stays a one-line import change per file rather than a signature change per
|
||||
// function.
|
||||
//
|
||||
// The other half of the same rule: nothing here may be destructured off `ctx`
|
||||
// at init time either, for the same reason in the other direction — core is
|
||||
// free to hand over a getter (`ctx.site.baseUrl` is one), and a value captured
|
||||
// once is a value that cannot change.
|
||||
//
|
||||
// If `ctx` is missing, every accessor throws with the same message. That is
|
||||
// deliberate: the only way to reach one before `register()` is a require cycle
|
||||
// or a test that forgot to call `init`, and both want naming, not `undefined`.
|
||||
|
||||
let ctx = null
|
||||
|
||||
function need() {
|
||||
if (!ctx) {
|
||||
throw new Error('module-uo: core accessed before register() — see server/core.js')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Called once, first thing in `register()`. */
|
||||
function init(value) {
|
||||
ctx = value
|
||||
}
|
||||
|
||||
/** Test seam. Nothing in the module calls this; there is no de-registration. */
|
||||
function _reset() {
|
||||
ctx = null
|
||||
}
|
||||
|
||||
// A logger that can be taken at require time and used after `register()`.
|
||||
//
|
||||
// Ported files write `const log = require('../core').logger('shard-ingest')` at
|
||||
// file scope — the same shape as core's `require('./logger')('…')` — so the
|
||||
// object returned has to exist before `ctx` does. It is a façade whose four
|
||||
// methods each resolve the real logger on call. Core namespaces it with the
|
||||
// module id, so these come out as `[uo:shard-ingest]`.
|
||||
function logger(namespace) {
|
||||
const call = (level) => (message, meta) => need().log(namespace)[level](message, meta)
|
||||
return { error: call('error'), warn: call('warn'), info: call('info'), debug: call('debug') }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
_reset,
|
||||
logger,
|
||||
|
||||
// Shared server dependencies. Core owns exactly one express, as it owns
|
||||
// exactly one React on the client, and for the same reason: a second copy in
|
||||
// the process is a second Router prototype and a second set of instanceof
|
||||
// checks. A module lives outside core's `server/`, so it could not resolve
|
||||
// these for itself even if it were allowed to (§7.2).
|
||||
get express() { return need().express },
|
||||
get validator() { return need().validator },
|
||||
|
||||
// Database. `query` is the one every `*.db.js` uses; `pool` is for the
|
||||
// streamed atlas import, which needs a connection it can hold.
|
||||
query: (...args) => need().db.query(...args),
|
||||
get pool() { return need().db.pool },
|
||||
|
||||
// Core state a module may read or append to, each narrowed to what is
|
||||
// actually used (§2.3).
|
||||
settings: {
|
||||
get: (...args) => need().settings.get(...args),
|
||||
set: (...args) => need().settings.set(...args),
|
||||
getInstanceName: (...args) => need().settings.getInstanceName(...args),
|
||||
},
|
||||
activity: { log: (...args) => need().activity.log(...args) },
|
||||
users: { getById: (...args) => need().users.getById(...args) },
|
||||
posts: {
|
||||
listAll: (...args) => need().posts.listAll(...args),
|
||||
getById: (...args) => need().posts.getById(...args),
|
||||
linkAnnounceJob: (...args) => need().posts.linkAnnounceJob(...args),
|
||||
markAnnounced: (...args) => need().posts.markAnnounced(...args),
|
||||
},
|
||||
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
||||
push: { publish: (...args) => need().push.publish(...args) },
|
||||
secretBox: {
|
||||
encrypt: (...args) => need().secretBox.encrypt(...args),
|
||||
decrypt: (...args) => need().secretBox.decrypt(...args),
|
||||
},
|
||||
get uploads() { return need().uploads },
|
||||
|
||||
// Middleware. Taken as values rather than wrapped, because express stores the
|
||||
// function reference at mount time — a wrapper would be what ends up in the
|
||||
// stack, and `requireRole('admin')` returns a middleware rather than being
|
||||
// one. Routers are built inside `register()`, so `ctx` is set by then.
|
||||
get middleware() { return need().middleware },
|
||||
|
||||
// Deployment facts.
|
||||
get baseUrl() { return need().site.baseUrl },
|
||||
get moduleRoot() { return need().paths.moduleRoot },
|
||||
get moduleId() { return need().moduleId },
|
||||
}
|
||||
24
server/data/spawnAtlas.art.example.json
Normal file
24
server/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"
|
||||
}
|
||||
56
server/db/purge.sql
Normal file
56
server/db/purge.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- ── module-uo's teardown ──────────────────────────────────────────────────
|
||||
--
|
||||
-- Destructive, and run ONLY by an explicit admin purge (MODULE_API.md §2.6).
|
||||
-- Nothing on the boot path ever executes this file — uninstalling a module
|
||||
-- leaves its data alone, and removing the data is a separate decision an
|
||||
-- operator has to make on purpose.
|
||||
--
|
||||
-- It exists because `schema.sql` does. A module that can create tables and
|
||||
-- cannot drop them leaves an operator with orphaned data and no supported way
|
||||
-- to remove it, which is why core refuses to load a module that declares one
|
||||
-- without the other.
|
||||
--
|
||||
-- **The order is the reverse of creation, and that is load-bearing**: two of
|
||||
-- these tables carry a foreign key into core's `users`, and several reference
|
||||
-- each other. Dropping a parent before its children fails on the constraint,
|
||||
-- and a purge that fails halfway is worse than one that does not run — it
|
||||
-- leaves exactly the orphaned data this file exists to remove. `IF EXISTS` on
|
||||
-- every line so a partially-installed module still tears down cleanly.
|
||||
--
|
||||
-- What is NOT here, deliberately: rows this module wrote into core's tables.
|
||||
-- `notification_subs` rows for `shard.*` streams and `announce_job_legs` rows
|
||||
-- with leg `towncrier` belong to core's tables, and a module does not delete
|
||||
-- from those — core prunes them when it drops the registrations, which it can
|
||||
-- do because it knows which registrant owned what. The two `settings` rows
|
||||
-- schema.sql seeds (`game_account_signup`, `uo_link_protocol_3_migrated`) are
|
||||
-- the same case with an extra reason: the second is a one-shot MIGRATION
|
||||
-- marker, and deleting it would re-arm a protocol bump against tables this
|
||||
-- file has just dropped.
|
||||
|
||||
DROP TABLE IF EXISTS `shard_atlas_pending`;
|
||||
DROP TABLE IF EXISTS `shard_atlas_meta`;
|
||||
DROP TABLE IF EXISTS `shard_cliloc_meta`;
|
||||
DROP TABLE IF EXISTS `shard_clilocs`;
|
||||
DROP TABLE IF EXISTS `shard_champion_spawns`;
|
||||
DROP TABLE IF EXISTS `shard_landmarks`;
|
||||
DROP TABLE IF EXISTS `shard_regions`;
|
||||
DROP TABLE IF EXISTS `shard_spawn_point_types`;
|
||||
DROP TABLE IF EXISTS `shard_spawn_points`;
|
||||
DROP TABLE IF EXISTS `shard_spawn_creatures`;
|
||||
DROP TABLE IF EXISTS `shard_feature_visibility`;
|
||||
DROP TABLE IF EXISTS `shard_vendor_items`;
|
||||
DROP TABLE IF EXISTS `shard_vendors`;
|
||||
DROP TABLE IF EXISTS `shard_points_boards`;
|
||||
DROP TABLE IF EXISTS `shard_ruleset`;
|
||||
DROP TABLE IF EXISTS `shard_presence`;
|
||||
DROP TABLE IF EXISTS `shard_governor_terms`;
|
||||
DROP TABLE IF EXISTS `shard_governors`;
|
||||
DROP TABLE IF EXISTS `shard_guilds`;
|
||||
DROP TABLE IF EXISTS `shard_pages`;
|
||||
DROP TABLE IF EXISTS `shard_champs`;
|
||||
DROP TABLE IF EXISTS `shard_account_links`;
|
||||
DROP TABLE IF EXISTS `shard_houses`;
|
||||
DROP TABLE IF EXISTS `shard_economy`;
|
||||
DROP TABLE IF EXISTS `shard_online`;
|
||||
DROP TABLE IF EXISTS `shard_events`;
|
||||
DROP TABLE IF EXISTS `uo_link_config`;
|
||||
644
server/db/schema.sql
Normal file
644
server/db/schema.sql
Normal file
@@ -0,0 +1,644 @@
|
||||
-- ── module-uo's schema fragment ───────────────────────────────────────────
|
||||
--
|
||||
-- Replayed by core on EVERY boot, after core's own schema.sql and before
|
||||
-- seedDefaults (MODULE_API.md §2.6). Everything here is therefore idempotent:
|
||||
-- every CREATE TABLE carries IF NOT EXISTS and every ALTER carries
|
||||
-- IF NOT EXISTS, because a statement that succeeds once and fails afterwards
|
||||
-- presents as a module that worked until the first restart.
|
||||
--
|
||||
-- Core validates this file at LOAD time, before anything mounts — statement by
|
||||
-- statement, split by the same code that splits core's schema. The rules it
|
||||
-- enforces and the reason each exists:
|
||||
--
|
||||
-- • Leading verbs are an allowlist: CREATE, ALTER, INSERT, UPDATE. Not a
|
||||
-- DROP denylist — this file replays every boot, so TRUNCATE or DELETE would
|
||||
-- empty a table on each restart.
|
||||
-- • Every table is prefixed. `shard_*` and `uo_link_*` are grandfathered to
|
||||
-- this module by name (loader.js LEGACY_TABLE_PREFIXES): they predate the
|
||||
-- module system by two years, they hold live data, and renaming them would
|
||||
-- be a migration this workstream deliberately does not do. Every module
|
||||
-- written after this one prefixes with its own id.
|
||||
-- • No table core declares may appear here, and no table another module has
|
||||
-- claimed.
|
||||
--
|
||||
-- Two tables carry a foreign key INTO core (`users`), which is allowed and is
|
||||
-- why the replay order matters: core's schema is already in place when this
|
||||
-- runs, so `users` exists. The reverse — a core table referencing one of these
|
||||
-- — does not occur and must not: it would make core's schema depend on a module
|
||||
-- being installed.
|
||||
--
|
||||
-- Teardown is `purge.sql`, which is never run by a boot. See it for the drop
|
||||
-- order, which is the reverse of the dependency order here.
|
||||
|
||||
|
||||
-- ── uo-link sidecar ────────────────────────────────────────────────────────
|
||||
-- Connection config for the uo-link sidecar (the HTTP + WebSocket bridge to the
|
||||
-- ServUO shard). Singleton row (id = 1), mirroring bot_config/email_config: the
|
||||
-- DB only ever holds the AES-256-GCM-encrypted shared-secret auth token, never
|
||||
-- plaintext, and it is only decrypted server-side (to call the sidecar). It is
|
||||
-- never returned to the admin UI — responses expose only `hasToken`. base_url is
|
||||
-- the REST endpoint, ws_url the live-feed endpoint; both are configurable because
|
||||
-- in production the sidecar runs on a different host from the website. `status`/
|
||||
-- `plugin_connected`/`last_event_at`/`boot_id` mirror the sidecar's last-known
|
||||
-- state for the admin panel between polls; `boot_id` tracks server.hello.bootId
|
||||
-- so a shard restart can be detected (and caches dropped).
|
||||
CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
base_url VARCHAR(255) NULL,
|
||||
ws_url VARCHAR(255) NULL,
|
||||
auth_token_enc TEXT NULL,
|
||||
protocol INT NOT NULL DEFAULT 3,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
plugin_connected TINYINT(1) NOT NULL DEFAULT 0,
|
||||
last_event_at DATETIME NULL,
|
||||
boot_id VARCHAR(64) NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_uo_link_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_uo_link_config_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Append-only log of notable shard events ingested from the uo-link WebSocket
|
||||
-- feed. The site OWNS this data (it does not query the sidecar's SQLite): the WS
|
||||
-- client writes here, and the public/admin read endpoints + live feeds read from
|
||||
-- here. Only "notable" kinds are logged (sales, deaths, murders, mob.killed,
|
||||
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
|
||||
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
|
||||
-- here — they update shard_online / shard_economy instead, keeping the log lean.
|
||||
-- dedupe_key = sha256(kind + t + stable-json(payload)) truncated to 40 hex chars
|
||||
-- (fits CHAR(40)); with the UNIQUE index it makes INSERT IGNORE idempotent so
|
||||
-- WS-reconnect backfill never double-inserts.
|
||||
CREATE TABLE IF NOT EXISTS shard_events (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
kind VARCHAR(48) NOT NULL,
|
||||
t BIGINT NOT NULL, -- event time, epoch ms (from the sidecar)
|
||||
boot_id VARCHAR(64) NULL, -- shard boot id at ingest (server.hello.bootId)
|
||||
payload JSON NOT NULL, -- the full event object
|
||||
dedupe_key CHAR(40) NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_events_kind_t (kind, t),
|
||||
INDEX idx_shard_events_t (t)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current online players. Upserted on mob.login, refreshed on char.vitals, and
|
||||
-- removed on mob.logout. Cleared wholesale when the shard restarts (a new
|
||||
-- server.hello.bootId). web_id is the linked website user id (present when the
|
||||
-- account is linked), so the roster can be correlated to site accounts.
|
||||
CREATE TABLE IF NOT EXISTS shard_online (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- mobile serial (opaque hex key)
|
||||
name VARCHAR(120) NULL,
|
||||
acct VARCHAR(120) NULL,
|
||||
web_id INT NULL,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
hits INT NULL,
|
||||
hits_max INT NULL,
|
||||
mana INT NULL,
|
||||
mana_max INT NULL,
|
||||
stam INT NULL,
|
||||
stam_max INT NULL,
|
||||
str INT NULL,
|
||||
dex INT NULL,
|
||||
`int` INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_online_acct (acct)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Total-gold-supply time series (from the periodic economy.supply event). Kept
|
||||
-- append-only so the public status page can render a supply-over-time sparkline.
|
||||
CREATE TABLE IF NOT EXISTS shard_economy (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
accounts INT NULL, -- number of accounts included in the total
|
||||
gold BIGINT NULL, -- total gold supply across all accounts
|
||||
t BIGINT NOT NULL, -- sample time, epoch ms
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_economy_t (t)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current decay stage per house, upserted on house.decay. is_idoc is a derived
|
||||
-- flag (stage == 'IDOC') so the public "houses in danger" list is a cheap
|
||||
-- indexed lookup rather than a scan.
|
||||
CREATE TABLE IF NOT EXISTS shard_houses (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY,
|
||||
stage VARCHAR(24) NULL, -- Somewhat | Fairly | Greatly | IDOC | Collapsed | ...
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
region VARCHAR(120) NULL,
|
||||
name VARCHAR(160) NULL,
|
||||
owner_serial VARCHAR(20) NULL,
|
||||
owner_acct VARCHAR(120) NULL,
|
||||
built_on DATETIME NULL,
|
||||
last_refreshed DATETIME NULL,
|
||||
is_idoc TINYINT(1) NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_houses_idoc (is_idoc)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
-- a single user may link several game accounts.
|
||||
CREATE TABLE IF NOT EXISTS shard_account_links (
|
||||
account VARCHAR(120) NOT NULL PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
char_name VARCHAR(120) NULL,
|
||||
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_shard_links_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_shard_links_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current champion-spawn board, upserted on champ.update and removed on
|
||||
-- champ.remove. Mirrors the sidecar's /champs projection into our own store so
|
||||
-- the public Champions page (and its live deltas) survive a shard outage, the
|
||||
-- same way shard_online / shard_houses do. Three families share one table, told
|
||||
-- apart by `category` (champion | mini | sea); category-specific fields (level,
|
||||
-- kills, boss, restartAt, hits, …) live in the JSON `payload` so the schema does
|
||||
-- not have to model every variant.
|
||||
CREATE TABLE IF NOT EXISTS shard_champs (
|
||||
serial VARCHAR(20) NOT NULL PRIMARY KEY, -- controller/mobile serial (opaque hex)
|
||||
category VARCHAR(16) NULL, -- champion | mini | sea
|
||||
type VARCHAR(80) NULL,
|
||||
name VARCHAR(120) NULL,
|
||||
status VARCHAR(16) NULL, -- active | cooldown | dormant
|
||||
active TINYINT(1) NOT NULL DEFAULT 0,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
boss_up TINYINT(1) NOT NULL DEFAULT 0,
|
||||
payload JSON NOT NULL, -- the full champ.update object
|
||||
t BIGINT NULL, -- event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_champs_category (category)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Current open help-page (support ticket) queue, upserted on page.new/page.updated
|
||||
-- and removed on page.closed. Snapshotted authoritatively from the sidecar's
|
||||
-- GET /pages on every (re)connect. page_id is the sender's serial (one page per
|
||||
-- player). Staff-only data — served on the admin channel, never public.
|
||||
CREATE TABLE IF NOT EXISTS shard_pages (
|
||||
page_id VARCHAR(20) NOT NULL PRIMARY KEY, -- sender serial (one page per player)
|
||||
type VARCHAR(40) NULL, -- Bug | Stuck | Account | Question | ...
|
||||
sender_name VARCHAR(120) NULL,
|
||||
sender_acct VARCHAR(120) NULL,
|
||||
web_id INT NULL, -- linked website user id, if any
|
||||
message TEXT NULL,
|
||||
map VARCHAR(40) NULL,
|
||||
x INT NULL,
|
||||
y INT NULL,
|
||||
z INT NULL,
|
||||
sent_ms BIGINT NULL, -- when the page was opened, epoch ms
|
||||
handled TINYINT(1) NOT NULL DEFAULT 0, -- a staffer claimed it in game
|
||||
handler VARCHAR(120) NULL,
|
||||
payload JSON NOT NULL, -- the full page.new/updated object
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_pages_handled (handled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state
|
||||
-- snapshot emitted only on change) and removed on guild.remove. The leader is an
|
||||
-- actor object flattened into leader_* columns; the full event is kept in
|
||||
-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds
|
||||
-- projection into our store so the public Guilds page survives a shard outage.
|
||||
CREATE TABLE IF NOT EXISTS shard_guilds (
|
||||
id INT NOT NULL PRIMARY KEY, -- in-game guild id
|
||||
name VARCHAR(120) NULL,
|
||||
abbr VARCHAR(24) NULL,
|
||||
members INT NULL,
|
||||
online INT NULL,
|
||||
alliance VARCHAR(120) NULL,
|
||||
leader_serial VARCHAR(20) NULL,
|
||||
leader_name VARCHAR(120) NULL,
|
||||
leader_acct VARCHAR(120) NULL,
|
||||
leader_web_id INT NULL,
|
||||
payload JSON NOT NULL, -- the full guild.update object
|
||||
t BIGINT NULL, -- event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_guilds_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
|
||||
-- city.update (full-state, emitted only on change; there is no remove event since
|
||||
-- the set of cities is fixed). governor / governorElect are actor objects
|
||||
-- flattened into columns; the full event is kept in `payload`. Empty on shards
|
||||
-- that do not run the City Loyalty system.
|
||||
CREATE TABLE IF NOT EXISTS shard_governors (
|
||||
city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ...
|
||||
governor_serial VARCHAR(20) NULL,
|
||||
governor_name VARCHAR(120) NULL,
|
||||
governor_acct VARCHAR(120) NULL,
|
||||
governor_web_id INT NULL,
|
||||
elect_serial VARCHAR(20) NULL,
|
||||
elect_name VARCHAR(120) NULL,
|
||||
elect_acct VARCHAR(120) NULL,
|
||||
election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending
|
||||
candidates INT NULL,
|
||||
auto_pick_at DATETIME NULL,
|
||||
payload JSON NOT NULL, -- the full city.update object
|
||||
t BIGINT NULL, -- event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Governor term history — the "who governed when" ledger behind the Governors
|
||||
-- board. Captured from day one (history cannot be backfilled) on every observed
|
||||
-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one
|
||||
-- opened. `votes` stays NULL — the city.update feed exposes only the candidate
|
||||
-- COUNT and election phase, not per-candidate tallies, so we record who governed
|
||||
-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who
|
||||
-- were all the governors of Britain?") reads this table.
|
||||
CREATE TABLE IF NOT EXISTS shard_governor_terms (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
city VARCHAR(40) NOT NULL,
|
||||
governor_serial VARCHAR(20) NULL,
|
||||
governor_name VARCHAR(120) NULL,
|
||||
governor_acct VARCHAR(120) NULL,
|
||||
governor_web_id INT NULL,
|
||||
started_at BIGINT NOT NULL, -- term start, epoch ms
|
||||
ended_at BIGINT NULL, -- term end epoch ms (NULL = current)
|
||||
votes INT NULL, -- not in the feed (reserved)
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_gov_terms_city (city, started_at),
|
||||
INDEX idx_shard_gov_terms_open (city, ended_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the
|
||||
-- latest presence.online aggregate: total count plus per-facet and per-region
|
||||
-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this
|
||||
-- is the rolled-up headcount the public "Players Online" widget renders. The
|
||||
-- time series, if ever needed, is available from GET /history?kind=presence.online.
|
||||
CREATE TABLE IF NOT EXISTS shard_presence (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
count INT NOT NULL DEFAULT 0,
|
||||
by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 }
|
||||
by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 }
|
||||
t BIGINT NULL, -- snapshot time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_presence_singleton CHECK (id = 1)
|
||||
) 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;
|
||||
-- `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;
|
||||
|
||||
-- House registry (Protocol 2.0). The house.update full-state feed carries richer
|
||||
-- fields than the house.decay transition feed shard_houses was built for. Rather
|
||||
-- than a second table for one entity, extend shard_houses: house.update writes the
|
||||
-- registry columns below (owner display name, co-owner/friend counts, placement
|
||||
-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each
|
||||
-- upsert only touches its own columns, so the two feeds never clobber each other.
|
||||
-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none).
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL;
|
||||
-- Distinguishes a full registry row (seen via house.update) from a decay-only row,
|
||||
-- so the public Houses browser can list registered houses without pulling in rows
|
||||
-- we only ever saw an IDOC transition for.
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry 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');
|
||||
-- **The marker must be written HERE, not in core.** These two statements were
|
||||
-- adjacent in core's schema.sql before the extraction; slice 1 moved the UPDATE
|
||||
-- and left the INSERT behind, and the two files do not run at the same time —
|
||||
-- core's schema is replayed in full BEFORE any module fragment (MODULE_API.md
|
||||
-- §2.6). So the marker existed before the UPDATE ever read it, the NOT EXISTS
|
||||
-- was true on the first boot of a fresh install and false on every boot of an
|
||||
-- upgraded one, and the one-shot could never fire. An install carrying a
|
||||
-- protocol-2 row would have stayed pinned at 2 against a v3 sidecar — every
|
||||
-- REST call 409, which is precisely the failure this migration exists to
|
||||
-- prevent. Latent rather than live: it only bites an install that first boots a
|
||||
-- post-slice-1 build while already holding a uo_link_config row, and `edge` has
|
||||
-- not cut over yet.
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
|
||||
|
||||
-- ── Settings rows this module owns ─────────────────────────────────────────
|
||||
--
|
||||
-- Both keys predate the module system and both name a game concept, so core
|
||||
-- seeding them made core's schema declare a module's settings — the structural
|
||||
-- half of what Phase 3 removes (MODULE_SYSTEM.md §2.7.1, slice 4). The KEYS are
|
||||
-- deliberately unchanged: they are live rows on every existing install, and
|
||||
-- renaming one would silently reset an operator's choice to the default.
|
||||
--
|
||||
-- INSERT IGNORE, so an install that already carries the row keeps its value and
|
||||
-- only a database that has never seen the key gets the default. Nothing in core
|
||||
-- reads either one; `game_account_signup` is read through ctx.settings by
|
||||
-- server/utils/gameSignup.js, which owns the policy.
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
|
||||
@@ -8,44 +8,90 @@
|
||||
// 1. **No `await`, and no database.** `scripts/routeManifest.js` and
|
||||
// `swagger/swagger.js` both require core's `app.js` with the pool pointed
|
||||
// at a dead port, so a module that queried at registration time would hang
|
||||
// both. Anything needing a live database belongs in `onBoot`.
|
||||
// both. Everything needing a live database is in `onBoot`.
|
||||
// 2. **Never resolve what core owns.** This module lives at
|
||||
// `<website>/modules/uo/`, outside `server/`, so Node's resolver never
|
||||
// reaches core's `node_modules` and `require('express')` fails outright.
|
||||
// express and express-validator arrive on `ctx`; so do the database, the
|
||||
// logger, the middleware and the rest of §2.3.
|
||||
// express, express-validator, the database, the logger, the middleware and
|
||||
// the rest of §2.3 arrive on `ctx` and are re-exported by `./core`.
|
||||
// 3. **Never reach into core's tree.** No relative path may escape this
|
||||
// module's root. `scripts/checkImports.js` enforces that in CI (§5.1)
|
||||
// rather than leaving it to review.
|
||||
// module's root; `scripts/checkImports.js` enforces that in CI (§5.1).
|
||||
//
|
||||
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) deliberately
|
||||
// registers NOTHING. The bundle exists, core discovers it, validates it, mounts
|
||||
// its zero routes, serves its client chunk and reports it `started` — which is
|
||||
// the whole delivery path proved end to end before a single UO file moves into
|
||||
// it. Slice 1 brings the atlas; every slice after that adds registrations here
|
||||
// and deletes the matching files from core.
|
||||
// **Require order is load-bearing, and it is why the requires below are inside
|
||||
// the function.** Every ported file reaches core through `./core`, whose members
|
||||
// resolve `ctx` when called — but a router does `const express = core.express` at
|
||||
// its own file scope, which runs the moment it is required. So `core.init(ctx)`
|
||||
// has to happen before the first `require` of anything under `router/`. Hoisting
|
||||
// these to the top of the file would break the module with an error about `ctx`
|
||||
// being missing, from a file that never mentions it. Node caches modules, so
|
||||
// requiring here costs nothing after the first call.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
/**
|
||||
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
|
||||
* @param {object} api what the module registers (§2.4)
|
||||
*/
|
||||
module.exports = function register(ctx, api) {
|
||||
const log = ctx.log()
|
||||
core.init(ctx)
|
||||
|
||||
// Registrations land here, slice by slice:
|
||||
/* eslint-disable global-require */
|
||||
const publicShard = require('./router/public/shard.router')
|
||||
const publicAtlas = require('./router/public/atlas.router')
|
||||
const adminShard = require('./router/admin/shard.router')
|
||||
const adminUoLink = require('./router/admin/uoLink.router')
|
||||
const playerShard = require('./router/player/shard.router')
|
||||
const usersShardExtension = require('./router/admin/usersShard.router')
|
||||
|
||||
const shardStreams = require('./config/shardStreams')
|
||||
const townCrierLeg = require('./utils/shardAnnounce')
|
||||
const boot = require('./boot')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const log = core.logger()
|
||||
|
||||
// The five prefixes, exactly the ones `module.json` declares — the loader
|
||||
// compares the two and rejects a mismatch in either direction. Each router
|
||||
// mounts INSIDE its tier, so it structurally cannot reach above its prefix,
|
||||
// and the tier's own gate is already applied: `/admin` sits behind
|
||||
// `noindex, isLoggedIn, requireRole(...)`, `/player` behind
|
||||
// `noindex, requireAuth`, `/public` behind nothing by design.
|
||||
//
|
||||
// api.registerRoutes({ public: {...}, admin: {...}, player: {...} })
|
||||
// api.registerExtension('admin.users.detail', usersShardRouter)
|
||||
// api.registerNotificationStreams(streams)
|
||||
// api.registerAnnounceLeg({ leg: 'towncrier', ... })
|
||||
// api.onBoot(async (ctx) => { ... })
|
||||
// api.onShutdown(async () => { ... })
|
||||
// The URLs these produce are byte-identical to the ones core served before the
|
||||
// extraction (§1.2). That is the whole point of moving the code and not the
|
||||
// paths: the shipped Android app calls `POST /api/v1/admin/shard/kick`, and the
|
||||
// Discord bot reads `/api/v1/public/shard/*`, and neither knows or needs to
|
||||
// know that a module answers now.
|
||||
api.registerRoutes({
|
||||
public: { '/shard': publicShard, '/atlas': publicAtlas },
|
||||
admin: { '/shard': adminShard, '/uo-link': adminUoLink },
|
||||
player: { '/shard': playerShard },
|
||||
})
|
||||
|
||||
// The six `/admin/users/:id/shard/*` URLs, which hang off a CORE resource and
|
||||
// therefore cannot be a mount of our own (§1.9). Core declares the slot in
|
||||
// `users.router.js` and we fill it; the router gets `req.params.id` from the
|
||||
// parent via `mergeParams`. Core's own routes on the resource win any path
|
||||
// conflict, which is correct — it owns the user.
|
||||
api.registerExtension('admin.users.detail', usersShardExtension)
|
||||
|
||||
// The push catalog and the news leg. Core kept the push infrastructure and the
|
||||
// announce worker; what it never had was an opinion about *shard* streams or
|
||||
// about talking to a town crier, and those are content (MODULE_SYSTEM.md §1.8).
|
||||
//
|
||||
// `api` is referenced by this log line and nothing else yet, on purpose: an
|
||||
// entry point that took `api` and never named it would read like an oversight
|
||||
// rather than a stage of the extraction.
|
||||
// Seven of these stream ids and the leg id `towncrier` are grandfathered
|
||||
// (§6.5) — they are stored in `notification_subs` and `announce_job_legs.leg`
|
||||
// and read by the shipped Android app, so a rename here is a data migration
|
||||
// plus a client break rather than a tidy-up.
|
||||
api.registerNotificationStreams(shardStreams.STREAMS)
|
||||
api.registerAnnounceLeg(townCrierLeg.leg)
|
||||
|
||||
api.onBoot(boot.onBoot)
|
||||
api.onShutdown(boot.onShutdown)
|
||||
|
||||
log.info('registered', {
|
||||
version: require('../module.json').version,
|
||||
registers: Object.keys(api).length,
|
||||
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
|
||||
streams: shardStreams.STREAMS.length,
|
||||
})
|
||||
}
|
||||
|
||||
392
server/model/shardAtlas/shardAtlas.db.js
Normal file
392
server/model/shardAtlas/shardAtlas.db.js
Normal file
@@ -0,0 +1,392 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const { query } = core
|
||||
|
||||
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
|
||||
// empties and refills all six inside one transaction, and nothing else in the
|
||||
// codebase writes to them. There are no foreign keys, consistent with every
|
||||
// other shard_* table.
|
||||
|
||||
const BATCH = 500
|
||||
|
||||
const ATLAS_TABLES = [
|
||||
'shard_spawn_point_types',
|
||||
'shard_spawn_points',
|
||||
'shard_spawn_creatures',
|
||||
'shard_regions',
|
||||
'shard_landmarks',
|
||||
'shard_champion_spawns',
|
||||
]
|
||||
|
||||
async function insertBatched(conn, sql, rows) {
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(sql, rows.slice(i, i + BATCH))
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire atlas in one transaction.
|
||||
*
|
||||
* All-or-nothing on purpose: a failed reload must leave the previous atlas
|
||||
* intact rather than a half-loaded world, since a partially-imported atlas is
|
||||
* indistinguishable from a real one to anyone reading it.
|
||||
*
|
||||
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
|
||||
* commits, which would defeat exactly that guarantee. At ~7k rows the cost of
|
||||
* `DELETE` is irrelevant.
|
||||
*/
|
||||
async function replaceAtlas(atlas, art = {}) {
|
||||
const conn = await core.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,
|
||||
}
|
||||
491
server/model/shardAtlas/shardAtlas.model.js
Normal file
491
server/model/shardAtlas/shardAtlas.model.js
Normal file
@@ -0,0 +1,491 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const db = require('./shardAtlas.db')
|
||||
const core = require('../../core')
|
||||
const { settings } = core
|
||||
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||
const {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
buildAtlas,
|
||||
hashSources,
|
||||
sameSources,
|
||||
} = require('../../utils/spawnAtlasSource')
|
||||
const log = require('../../core').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.
|
||||
*/
|
||||
// Resolved from ctx.paths.moduleRoot rather than by walking up from __dirname.
|
||||
// The ported default was `../../../db/data`, which pointed at core's tree when
|
||||
// this file lived there and points OUTSIDE server/ now — a path that happens to
|
||||
// resolve is exactly the kind of port bug that survives a green test suite,
|
||||
// because the absent-file branch returns {} and looks like the normal case.
|
||||
function loadArtMap(dir = path.join(core.moduleRoot, 'server', '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,
|
||||
}
|
||||
110
server/model/shardClilocs/shardClilocs.db.js
Normal file
110
server/model/shardClilocs/shardClilocs.db.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const { query } = core
|
||||
|
||||
// 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 core.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/model/shardClilocs/shardClilocs.model.js
Normal file
368
server/model/shardClilocs/shardClilocs.model.js
Normal file
@@ -0,0 +1,368 @@
|
||||
const db = require('./shardClilocs.db')
|
||||
const { settings } = require('../../core')
|
||||
const { displayText } = require('../../utils/clilocParse')
|
||||
const {
|
||||
ClilocFormatError,
|
||||
ClilocSourceError,
|
||||
PARSER_VERSION,
|
||||
hashSources,
|
||||
sameSources,
|
||||
missingSources,
|
||||
readCliloc,
|
||||
} = require('../../utils/clilocSource')
|
||||
const log = require('../../core').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,
|
||||
}
|
||||
46
server/model/shardEvents/shardEvents.db.js
Normal file
46
server/model/shardEvents/shardEvents.db.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
// INSERT IGNORE on the UNIQUE dedupe_key — a re-ingested event (WS-reconnect
|
||||
// backfill overlap) is silently skipped rather than duplicated. Returns true if
|
||||
// a new row was actually inserted.
|
||||
async function insertIgnore({ kind, t, bootId, payload, dedupeKey }) {
|
||||
const res = await query(
|
||||
`INSERT IGNORE INTO shard_events (kind, t, boot_id, payload, dedupe_key)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[kind, t, bootId || null, JSON.stringify(payload), dedupeKey],
|
||||
)
|
||||
return res.affectedRows > 0
|
||||
}
|
||||
|
||||
// Recent events, newest first. Filter by a single `kind`, or an allowlist of
|
||||
// `kinds` (IN clause) — the public feed uses the allowlist so it can never leak
|
||||
// staff/sensitive kinds. limit is clamped by the model.
|
||||
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) {
|
||||
const placeholders = kinds.map(() => '?').join(', ')
|
||||
return query(
|
||||
`SELECT id, kind, t, boot_id, payload, created_at
|
||||
FROM shard_events WHERE kind IN (${placeholders}) ORDER BY t DESC LIMIT ?`,
|
||||
[...kinds, limit],
|
||||
)
|
||||
}
|
||||
if (kind) {
|
||||
return query(
|
||||
`SELECT id, kind, t, boot_id, payload, created_at
|
||||
FROM shard_events WHERE kind = ? ORDER BY t DESC LIMIT ?`,
|
||||
[kind, limit],
|
||||
)
|
||||
}
|
||||
return query(
|
||||
`SELECT id, kind, t, boot_id, payload, created_at
|
||||
FROM shard_events ORDER BY t DESC LIMIT ?`,
|
||||
[limit],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { insertIgnore, list }
|
||||
61
server/model/shardEvents/shardEvents.model.js
Normal file
61
server/model/shardEvents/shardEvents.model.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// Append-only shard event log. The WS ingest dispatcher calls append() for the
|
||||
// notable kinds; the public/admin read endpoints call list(). The DB layer only
|
||||
// sees an already-computed dedupe_key so INSERT IGNORE is idempotent across
|
||||
// WS-reconnect backfill.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const db = require('./shardEvents.db')
|
||||
|
||||
const MAX_LIMIT = 1000
|
||||
const DEFAULT_LIMIT = 100
|
||||
|
||||
// Stable stringify — keys sorted — so the dedupe hash is independent of the
|
||||
// property order the sidecar happened to serialize with.
|
||||
function stableStringify(value) {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
||||
const keys = Object.keys(value).sort()
|
||||
const entries = keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
||||
return `{${entries.join(',')}}`
|
||||
}
|
||||
|
||||
// dedupe_key = sha256(kind + t + stable-json(payload)), truncated to 40 hex chars.
|
||||
// This is a content fingerprint for idempotent INSERT IGNORE, not a security value,
|
||||
// but we use SHA-256 rather than SHA-1 anyway; the truncation keeps it inside the
|
||||
// CHAR(40) column (160 bits is ample collision resistance for dedupe). Two identical
|
||||
// events (same kind, same timestamp, same body) collapse to one row.
|
||||
function dedupeKey(kind, t, payload) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(`${kind}|${t}|${stableStringify(payload)}`)
|
||||
.digest('hex')
|
||||
.slice(0, 40)
|
||||
}
|
||||
|
||||
// Append one event. Returns true if a new row was inserted (false = deduped).
|
||||
async function append({ kind, t, bootId, payload }) {
|
||||
return db.insertIgnore({ kind, t, bootId, payload, dedupeKey: dedupeKey(kind, t, payload) })
|
||||
}
|
||||
|
||||
function normalizeLimit(limit) {
|
||||
const n = Number(limit)
|
||||
if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT
|
||||
return Math.min(Math.floor(n), MAX_LIMIT)
|
||||
}
|
||||
|
||||
// Recent events, newest first. Each row's JSON payload is parsed back to an
|
||||
// object. `kinds` (array) restricts to an allowlist; `kind` filters a single kind.
|
||||
async function list({ kind, kinds, limit } = {}) {
|
||||
const rows = await db.list({ kind, kinds, limit: normalizeLimit(limit) })
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
t: row.t,
|
||||
bootId: row.boot_id || null,
|
||||
// mariadb returns JSON columns as strings on some versions; parse defensively.
|
||||
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
|
||||
createdAt: row.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
module.exports = { append, list, dedupeKey }
|
||||
42
server/model/shardLinks/shardLinks.db.js
Normal file
42
server/model/shardLinks/shardLinks.db.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
const COLS = 'account, user_id, char_name, linked_at'
|
||||
|
||||
// Upsert a link. account is the PK, so a re-link moves the account to the new
|
||||
// user (the sidecar already treats /link/confirm as authoritative).
|
||||
async function upsert({ account, userId, charName }) {
|
||||
await query(
|
||||
`INSERT INTO shard_account_links (account, user_id, char_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
|
||||
[account, userId, charName || null],
|
||||
)
|
||||
return getByAccount(account)
|
||||
}
|
||||
|
||||
async function getByAccount(account) {
|
||||
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const listByUser = (userId) =>
|
||||
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
|
||||
|
||||
async function isOwnedBy(account, userId) {
|
||||
const rows = await query(
|
||||
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
|
||||
[account, userId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const remove = (account, userId) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||
|
||||
// Drop the mirror for an account regardless of which user held it — used to
|
||||
// reconcile when the tie is severed at the source (an in-game [unlink →
|
||||
// account.unlinked event, or a site-side DELETE /link/{account}).
|
||||
const removeByAccount = (account) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||
37
server/model/shardLinks/shardLinks.model.js
Normal file
37
server/model/shardLinks/shardLinks.model.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
|
||||
// authoritative link (it tags the game account on /link/confirm); this model
|
||||
// records it locally so the player portal can list links and enforce ownership.
|
||||
|
||||
const db = require('./shardLinks.db')
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
account: row.account,
|
||||
userId: row.user_id,
|
||||
charName: row.char_name || null,
|
||||
linkedAt: row.linked_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function link({ account, userId, charName }) {
|
||||
return toSafe(await db.upsert({ account, userId, charName }))
|
||||
}
|
||||
|
||||
async function listForUser(userId) {
|
||||
const rows = await db.listByUser(userId)
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
|
||||
|
||||
async function getByAccount(account) {
|
||||
return toSafe(await db.getByAccount(account))
|
||||
}
|
||||
|
||||
const unlink = (account, userId) => db.remove(account, userId)
|
||||
|
||||
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||
const removeByAccount = (account) => db.removeByAccount(account)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
|
||||
301
server/model/shardMarket/shardMarket.db.js
Normal file
301
server/model/shardMarket/shardMarket.db.js
Normal file
@@ -0,0 +1,301 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const { query } = core
|
||||
|
||||
// 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 core.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 core.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 core.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/model/shardMarket/shardMarket.model.js
Normal file
329
server/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('../../core').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,
|
||||
}
|
||||
369
server/model/shardState/shardState.db.js
Normal file
369
server/model/shardState/shardState.db.js
Normal file
@@ -0,0 +1,369 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
// Shared upsert builder for the shard-state tables. Each is keyed on a single
|
||||
// primary column (`pkCol` = pk); `fields` carries only the columns the model
|
||||
// wants to write, so a partial refresh touches nothing else. `coalesce` keeps
|
||||
// the prior column value when the incoming one is NULL (used by shard_online so a
|
||||
// vitals frame that omits acct/name doesn't blank what mob.login set); otherwise
|
||||
// the incoming value wins (VALUES()).
|
||||
function upsertRow(table, pkCol, pk, fields, { coalesce = false } = {}) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = [pkCol, ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const rhs = coalesce
|
||||
? (c) => `\`${c}\` = COALESCE(VALUES(\`${c}\`), \`${c}\`)`
|
||||
: (c) => `\`${c}\` = VALUES(\`${c}\`)`
|
||||
const updates = cols.map(rhs).join(', ')
|
||||
return query(
|
||||
`INSERT INTO ${table} (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[pk, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Online players ─────────────────────────────────────────────────────────
|
||||
const ONLINE_COLS =
|
||||
'serial, name, acct, web_id, map, x, y, z, hits, hits_max, mana, mana_max, stam, stam_max, str, dex, `int`, updated_at'
|
||||
|
||||
// Upsert one online player. `fields` already prepared by the model (only the
|
||||
// columns it wants to write); serial is required and is the primary key.
|
||||
// COALESCE variant: a char.vitals frame that omits acct/name must not blank what
|
||||
// mob.login set, so an incoming NULL keeps the prior column value.
|
||||
const upsertOnline = (serial, fields) =>
|
||||
upsertRow('shard_online', 'serial', serial, fields, { coalesce: true })
|
||||
|
||||
const removeOnline = (serial) => query('DELETE FROM shard_online WHERE serial = ?', [serial])
|
||||
const clearOnline = () => query('DELETE FROM shard_online')
|
||||
|
||||
async function countOnline() {
|
||||
const rows = await query('SELECT COUNT(*) AS n FROM shard_online')
|
||||
return rows[0] ? Number(rows[0].n) : 0
|
||||
}
|
||||
|
||||
const listOnline = () =>
|
||||
query(`SELECT ${ONLINE_COLS} FROM shard_online ORDER BY name ASC`)
|
||||
|
||||
// Online players on any of the given game accounts (admin: a user's linked
|
||||
// accounts). Empty list short-circuits so we never emit `IN ()`.
|
||||
const listOnlineByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT ${ONLINE_COLS} FROM shard_online
|
||||
WHERE acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY name ASC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// Staff roles whose online presence is shown on the public Shard page. Players
|
||||
// who link an account are NOT surfaced publicly — only staff opt into visibility
|
||||
// by virtue of being staff.
|
||||
const PUBLIC_ONLINE_ROLES = ['admin', 'editor', 'moderator']
|
||||
|
||||
// Online players whose game account is linked to a STAFF website user. Joined
|
||||
// against shard_account_links (not the sidecar-supplied web_id) so a link takes
|
||||
// effect immediately, regardless of whether the player has re-logged since
|
||||
// linking, then through to users so only staff roles are surfaced publicly.
|
||||
const listOnlineLinked = () => {
|
||||
const cols = ONLINE_COLS.split(', ')
|
||||
.map((c) => `o.${c}`)
|
||||
.join(', ')
|
||||
return query(
|
||||
`SELECT ${cols}
|
||||
FROM shard_online o
|
||||
JOIN shard_account_links l ON l.account = o.acct
|
||||
JOIN users u ON u.id = l.user_id
|
||||
WHERE u.role IN (${PUBLIC_ONLINE_ROLES.map(() => '?').join(', ')})
|
||||
ORDER BY o.name ASC`,
|
||||
PUBLIC_ONLINE_ROLES,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Economy supply series ────────────────────────────────────────────────
|
||||
const insertEconomy = ({ accounts, gold, t }) =>
|
||||
query('INSERT INTO shard_economy (accounts, gold, t) VALUES (?, ?, ?)', [
|
||||
accounts ?? null,
|
||||
gold ?? null,
|
||||
t,
|
||||
])
|
||||
|
||||
const listEconomy = (limit) =>
|
||||
query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT ?', [limit])
|
||||
|
||||
async function latestEconomy() {
|
||||
const rows = await query('SELECT accounts, gold, t FROM shard_economy ORDER BY t DESC LIMIT 1')
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// ── Houses / IDOC ────────────────────────────────────────────────────────
|
||||
const HOUSE_COLS =
|
||||
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at'
|
||||
|
||||
const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields)
|
||||
|
||||
const listIdocHouses = () =>
|
||||
query(`SELECT ${HOUSE_COLS} FROM shard_houses WHERE is_idoc = 1 ORDER BY updated_at DESC`)
|
||||
|
||||
// Houses owned by any of the given game accounts (admin: a user's linked
|
||||
// accounts). IDOC houses first, then newest-refreshed. Empty list short-circuits.
|
||||
const listHousesByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT ${HOUSE_REG_COLS} FROM shard_houses
|
||||
WHERE owner_acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY is_idoc DESC, updated_at DESC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
|
||||
// The registry columns extend HOUSE_COLS; a registry row is one we've seen via
|
||||
// house.update (in_registry = 1), as opposed to a decay-only transition row.
|
||||
const HOUSE_REG_COLS = `${HOUSE_COLS}, owner_name, co_owners, friends, price, decay, in_registry`
|
||||
|
||||
const removeHouse = (serial) => query('DELETE FROM shard_houses WHERE serial = ?', [serial])
|
||||
|
||||
// The full registered-house browser: every row we've seen via house.update.
|
||||
const listRegistryHouses = () =>
|
||||
query(`SELECT ${HOUSE_REG_COLS} FROM shard_houses WHERE in_registry = 1 ORDER BY name ASC`)
|
||||
|
||||
// ── Champion spawns ────────────────────────────────────────────────────────
|
||||
const CHAMP_COLS =
|
||||
'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at'
|
||||
|
||||
const upsertChamp = (serial, fields) => upsertRow('shard_champs', 'serial', serial, fields)
|
||||
|
||||
const removeChamp = (serial) => query('DELETE FROM shard_champs WHERE serial = ?', [serial])
|
||||
const clearChamps = () => query('DELETE FROM shard_champs')
|
||||
// Ordered by name (matches the sidecar's /champs ordering).
|
||||
const listChamps = () => query(`SELECT ${CHAMP_COLS} FROM shard_champs ORDER BY name ASC`)
|
||||
|
||||
// ── Help-page (support) queue ──────────────────────────────────────────────
|
||||
const PAGE_COLS =
|
||||
'page_id, type, sender_name, sender_acct, web_id, message, map, x, y, z, sent_ms, handled, handler, payload, updated_at'
|
||||
|
||||
async function upsertPage(pageId, fields) {
|
||||
const cols = Object.keys(fields)
|
||||
const allCols = ['page_id', ...cols]
|
||||
const insertCols = allCols.map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = allCols.map(() => '?').join(', ')
|
||||
const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO shard_pages (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
[pageId, ...cols.map((c) => fields[c])],
|
||||
)
|
||||
}
|
||||
|
||||
const removePage = (pageId) => query('DELETE FROM shard_pages WHERE page_id = ?', [pageId])
|
||||
const clearPages = () => query('DELETE FROM shard_pages')
|
||||
// Oldest-open first so the queue reads like a work list.
|
||||
const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sent_ms ASC`)
|
||||
|
||||
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
|
||||
const GUILD_COLS =
|
||||
'id, name, abbr, members, online, alliance, leader_serial, leader_name, leader_acct, leader_web_id, payload, t, updated_at'
|
||||
|
||||
const upsertGuild = (id, fields) => upsertRow('shard_guilds', 'id', id, fields)
|
||||
|
||||
const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
|
||||
const clearGuilds = () => query('DELETE FROM shard_guilds')
|
||||
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
|
||||
|
||||
// The guild an actor LEADS — matched on the current board (leader_serial or the
|
||||
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
|
||||
// is not modelled (the board carries only counts + leader), so we don't guess it.
|
||||
const findGuildLedByActor = (serial, acct) =>
|
||||
query(
|
||||
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
|
||||
WHERE leader_serial = ? OR (leader_acct IS NOT NULL AND leader_acct = ?)
|
||||
LIMIT 1`,
|
||||
[serial ?? null, acct ?? null],
|
||||
)
|
||||
|
||||
// Guilds led by any of the given game accounts (admin: a user's linked accounts).
|
||||
const listGuildsLedByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
|
||||
WHERE leader_acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY name ASC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// ── Governor board + term history (Protocol 2.0) ───────────────────────────
|
||||
const GOV_COLS =
|
||||
'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at'
|
||||
|
||||
const upsertGovernor = (city, fields) => upsertRow('shard_governors', 'city', city, fields)
|
||||
|
||||
const listGovernors = () => query(`SELECT ${GOV_COLS} FROM shard_governors ORDER BY city ASC`)
|
||||
|
||||
// Cities whose current governor is one of the given game accounts (cross-link:
|
||||
// does this user hold a governorship?). Empty list short-circuits.
|
||||
const listGovernorshipsByAccounts = (accounts) =>
|
||||
accounts.length === 0
|
||||
? Promise.resolve([])
|
||||
: query(
|
||||
`SELECT ${GOV_COLS} FROM shard_governors
|
||||
WHERE governor_acct IN (${accounts.map(() => '?').join(', ')})
|
||||
ORDER BY city ASC`,
|
||||
accounts,
|
||||
)
|
||||
|
||||
// The single open term (ended_at IS NULL) for a city, if any.
|
||||
async function currentGovernorTerm(city) {
|
||||
const rows = await query(
|
||||
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1',
|
||||
[city],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const closeGovernorTerm = (id, endedAt) =>
|
||||
query('UPDATE shard_governor_terms SET ended_at = ? WHERE id = ?', [endedAt, id])
|
||||
|
||||
const openGovernorTerm = ({ city, serial, name, acct, webId, startedAt }) =>
|
||||
query(
|
||||
`INSERT INTO shard_governor_terms
|
||||
(city, governor_serial, governor_name, governor_acct, governor_web_id, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[city, serial ?? null, name ?? null, acct ?? null, webId ?? null, startedAt],
|
||||
)
|
||||
|
||||
const listGovernorTerms = (city, limit) =>
|
||||
query(
|
||||
'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? ORDER BY started_at DESC LIMIT ?',
|
||||
[city, limit],
|
||||
)
|
||||
|
||||
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
|
||||
async function setPresence({ count, byFacet, byRegion, t }) {
|
||||
await query(
|
||||
`INSERT INTO shard_presence (id, count, by_facet, by_region, t) VALUES (1, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE count = VALUES(count), by_facet = VALUES(by_facet),
|
||||
by_region = VALUES(by_region), t = VALUES(t)`,
|
||||
[
|
||||
Number.isFinite(count) ? count : 0,
|
||||
byFacet ? JSON.stringify(byFacet) : null,
|
||||
byRegion ? JSON.stringify(byRegion) : null,
|
||||
Number.isFinite(t) ? t : null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
async function latestPresence() {
|
||||
const rows = await query('SELECT count, by_facet, by_region, t FROM shard_presence WHERE id = 1')
|
||||
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 = {
|
||||
upsertOnline,
|
||||
removeOnline,
|
||||
clearOnline,
|
||||
countOnline,
|
||||
listOnline,
|
||||
listOnlineLinked,
|
||||
listOnlineByAccounts,
|
||||
insertEconomy,
|
||||
listEconomy,
|
||||
latestEconomy,
|
||||
upsertHouse,
|
||||
listIdocHouses,
|
||||
listHousesByAccounts,
|
||||
removeHouse,
|
||||
listRegistryHouses,
|
||||
upsertGuild,
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
findGuildLedByActor,
|
||||
listGuildsLedByAccounts,
|
||||
upsertGovernor,
|
||||
listGovernors,
|
||||
listGovernorshipsByAccounts,
|
||||
currentGovernorTerm,
|
||||
closeGovernorTerm,
|
||||
openGovernorTerm,
|
||||
listGovernorTerms,
|
||||
setPresence,
|
||||
latestPresence,
|
||||
setRuleset,
|
||||
getRuleset,
|
||||
upsertPointsBoard,
|
||||
listPointsBoards,
|
||||
getPointsBoard,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
listChamps,
|
||||
upsertPage,
|
||||
removePage,
|
||||
clearPages,
|
||||
listPages,
|
||||
}
|
||||
638
server/model/shardState/shardState.model.js
Normal file
638
server/model/shardState/shardState.model.js
Normal file
@@ -0,0 +1,638 @@
|
||||
// Live shard state derived from the WS feed: who is online, the gold-supply
|
||||
// series, and per-house decay stage. The ingest dispatcher calls the write
|
||||
// methods; the public read endpoints call the list/count methods. Writes take
|
||||
// camelCase semantic objects and map to the snake_case columns; only the keys
|
||||
// present are written (so a char.vitals refresh doesn't clobber login fields).
|
||||
|
||||
const db = require('./shardState.db')
|
||||
|
||||
const MAX_ECONOMY = 1000
|
||||
|
||||
// Small coercion helpers, kept out of the upsert builders below so those stay
|
||||
// flat (each inline `?? null` / ternary otherwise adds to cognitive complexity).
|
||||
const orNull = (v) => v ?? null
|
||||
const toDate = (v) => (v ? new Date(v) : null)
|
||||
// Owner is an actor object (or null for an abandoned house); flatten to columns.
|
||||
const ownerFields = (owner) => ({
|
||||
owner_serial: orNull(owner?.serial),
|
||||
owner_acct: orNull(owner?.acct),
|
||||
owner_name: orNull(owner?.name),
|
||||
})
|
||||
|
||||
// Map a camelCase online descriptor to DB columns, dropping undefined keys so a
|
||||
// partial refresh only touches the fields it carries.
|
||||
function onlineFields(data) {
|
||||
const map = {
|
||||
name: data.name,
|
||||
acct: data.acct,
|
||||
web_id: data.webId,
|
||||
map: data.map,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
z: data.z,
|
||||
hits: data.hits,
|
||||
hits_max: data.hitsMax,
|
||||
mana: data.mana,
|
||||
mana_max: data.manaMax,
|
||||
stam: data.stam,
|
||||
stam_max: data.stamMax,
|
||||
str: data.str,
|
||||
dex: data.dex,
|
||||
int: data.int,
|
||||
}
|
||||
const fields = {}
|
||||
for (const [k, v] of Object.entries(map)) if (v !== undefined) fields[k] = v
|
||||
return fields
|
||||
}
|
||||
|
||||
// Upsert an online player (mob.login) or refresh their vitals (char.vitals).
|
||||
async function upsertOnline(data) {
|
||||
if (!data || !data.serial) return
|
||||
await db.upsertOnline(data.serial, onlineFields(data))
|
||||
}
|
||||
|
||||
const setOffline = (serial) => db.removeOnline(serial)
|
||||
const clearOnline = () => db.clearOnline()
|
||||
const onlineCount = () => db.countOnline()
|
||||
|
||||
function shapeOnline(r) {
|
||||
return {
|
||||
serial: r.serial,
|
||||
name: r.name,
|
||||
acct: r.acct,
|
||||
webId: r.web_id,
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
hits: r.hits,
|
||||
hitsMax: r.hits_max,
|
||||
mana: r.mana,
|
||||
manaMax: r.mana_max,
|
||||
stam: r.stam,
|
||||
stamMax: r.stam_max,
|
||||
str: r.str,
|
||||
dex: r.dex,
|
||||
int: r.int,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
// Only players whose account is linked to a website user (opt-in visibility).
|
||||
async function listOnlineLinked() {
|
||||
const rows = await db.listOnlineLinked()
|
||||
return rows.map(shapeOnline)
|
||||
}
|
||||
|
||||
async function listOnline() {
|
||||
const rows = await db.listOnline()
|
||||
return rows.map(shapeOnline)
|
||||
}
|
||||
|
||||
// Append a gold-supply sample (economy.supply).
|
||||
async function addEconomySample({ accounts, gold, t }) {
|
||||
await db.insertEconomy({ accounts, gold, t })
|
||||
}
|
||||
|
||||
async function listEconomy(limit = 100) {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), MAX_ECONOMY)
|
||||
const rows = await db.listEconomy(n)
|
||||
// Return oldest → newest for charting.
|
||||
return rows
|
||||
.map((r) => ({ accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t }))
|
||||
.reverse()
|
||||
}
|
||||
|
||||
async function latestEconomy() {
|
||||
const r = await db.latestEconomy()
|
||||
return r ? { accounts: r.accounts, gold: r.gold == null ? null : Number(r.gold), t: r.t } : null
|
||||
}
|
||||
|
||||
// Upsert a house's decay stage (house.decay). is_idoc is derived from the stage.
|
||||
async function upsertHouse(data) {
|
||||
if (!data || !data.serial) return
|
||||
const fields = {
|
||||
stage: data.stage ?? null,
|
||||
map: data.map ?? null,
|
||||
x: data.x ?? null,
|
||||
y: data.y ?? null,
|
||||
z: data.z ?? null,
|
||||
region: data.region ?? null,
|
||||
name: data.name ?? null,
|
||||
owner_serial: data.ownerSerial ?? null,
|
||||
owner_acct: data.ownerAcct ?? null,
|
||||
built_on: data.builtOn ? new Date(data.builtOn) : null,
|
||||
last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null,
|
||||
is_idoc: String(data.stage).toUpperCase() === 'IDOC' ? 1 : 0,
|
||||
}
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
}
|
||||
|
||||
function shapeHouse(r) {
|
||||
return {
|
||||
serial: r.serial,
|
||||
stage: r.stage,
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
region: r.region,
|
||||
name: r.name,
|
||||
ownerSerial: r.owner_serial,
|
||||
ownerAcct: r.owner_acct,
|
||||
// Registry fields (Protocol 2.0 house.update); undefined on decay-only rows.
|
||||
ownerName: r.owner_name,
|
||||
coOwners: r.co_owners,
|
||||
friends: r.friends,
|
||||
price: r.price == null ? null : Number(r.price),
|
||||
decay: r.decay,
|
||||
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
|
||||
builtOn: r.built_on,
|
||||
lastRefreshed: r.last_refreshed,
|
||||
isIdoc: Boolean(r.is_idoc),
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function listIdoc() {
|
||||
const rows = await db.listIdocHouses()
|
||||
return rows.map(shapeHouse)
|
||||
}
|
||||
|
||||
// Houses owned by the given game accounts (admin: a user's linked accounts).
|
||||
async function listHousesForAccounts(accounts) {
|
||||
const rows = await db.listHousesByAccounts(accounts)
|
||||
return rows.map(shapeHouse)
|
||||
}
|
||||
|
||||
// ── House registry (Protocol 2.0 house.update / house.remove) ──────────────
|
||||
// Richer per-house snapshot than the decay-transition feed. Writes only the
|
||||
// registry columns (+ shared location/owner fields); is_idoc/stage stay owned by
|
||||
// the house.decay path, so the two feeds never clobber each other. owner is an
|
||||
// actor object (or null for an abandoned house).
|
||||
async function upsertHouseRegistry(data) {
|
||||
if (!data || !data.serial) return
|
||||
const fields = {
|
||||
name: orNull(data.name),
|
||||
...ownerFields(data.owner || null),
|
||||
co_owners: orNull(data.coOwners),
|
||||
friends: orNull(data.friends),
|
||||
price: orNull(data.price),
|
||||
decay: orNull(data.decay),
|
||||
region: orNull(data.region),
|
||||
map: orNull(data.map),
|
||||
x: orNull(data.x),
|
||||
y: orNull(data.y),
|
||||
z: orNull(data.z),
|
||||
built_on: toDate(data.builtOn),
|
||||
last_refreshed: toDate(data.lastRefreshed),
|
||||
in_registry: 1,
|
||||
}
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
}
|
||||
|
||||
const removeHouse = (serial) => (serial ? db.removeHouse(serial) : Promise.resolve())
|
||||
|
||||
async function listHouses() {
|
||||
const rows = await db.listRegistryHouses()
|
||||
return rows.map(shapeHouse)
|
||||
}
|
||||
|
||||
// Online players on the given game accounts (admin: a user's linked accounts).
|
||||
async function listOnlineForAccounts(accounts) {
|
||||
const rows = await db.listOnlineByAccounts(accounts)
|
||||
return rows.map(shapeOnline)
|
||||
}
|
||||
|
||||
// ── Champion spawns ────────────────────────────────────────────────────────
|
||||
// Upsert a champ spawn's state (champ.update). The full event is stored in
|
||||
// `payload` for the category-specific fields; a few columns are hoisted out for
|
||||
// querying/ordering. is-boss-up is derived from bossUp (sea bosses are always up).
|
||||
async function upsertChamp(ev) {
|
||||
if (!ev || !ev.serial) return
|
||||
await db.upsertChamp(ev.serial, {
|
||||
category: orNull(ev.category),
|
||||
type: orNull(ev.type),
|
||||
name: orNull(ev.name),
|
||||
status: orNull(ev.status),
|
||||
active: ev.active ? 1 : 0,
|
||||
map: orNull(ev.map),
|
||||
x: orNull(ev.x),
|
||||
y: orNull(ev.y),
|
||||
z: orNull(ev.z),
|
||||
boss_up: ev.bossUp ? 1 : 0,
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
})
|
||||
}
|
||||
|
||||
const removeChamp = (serial) => (serial ? db.removeChamp(serial) : Promise.resolve())
|
||||
const clearChamps = () => db.clearChamps()
|
||||
|
||||
// Return the stored champ.update payload (the shape the sidecar/UI expect),
|
||||
// falling back to the hoisted columns if an older row lacks a payload.
|
||||
function shapeChamp(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return payload || {
|
||||
kind: 'champ.update',
|
||||
serial: r.serial,
|
||||
category: r.category,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
active: Boolean(r.active),
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
bossUp: Boolean(r.boss_up),
|
||||
t: r.t,
|
||||
}
|
||||
}
|
||||
|
||||
async function listChamps() {
|
||||
const rows = await db.listChamps()
|
||||
return rows.map(shapeChamp)
|
||||
}
|
||||
|
||||
// Replace the whole board with a fresh snapshot (sidecar GET /champs on connect).
|
||||
async function replaceChamps(spawns) {
|
||||
await db.clearChamps()
|
||||
for (const ev of spawns || []) await upsertChamp(ev)
|
||||
}
|
||||
|
||||
// ── Help-page (support) queue ──────────────────────────────────────────────
|
||||
// Upsert a page (page.new / page.updated). The `sender` actor object carries the
|
||||
// name/acct/webId; the rest are top-level fields.
|
||||
async function upsertPage(ev) {
|
||||
const pageId = ev && (ev.pageId || (ev.sender && ev.sender.serial))
|
||||
if (!pageId) return
|
||||
const sender = ev.sender || {}
|
||||
await db.upsertPage(pageId, {
|
||||
type: orNull(ev.type),
|
||||
sender_name: orNull(sender.name),
|
||||
sender_acct: orNull(sender.acct),
|
||||
web_id: orNull(sender.webId),
|
||||
message: orNull(ev.message),
|
||||
map: orNull(ev.map),
|
||||
x: orNull(ev.x),
|
||||
y: orNull(ev.y),
|
||||
z: orNull(ev.z),
|
||||
sent_ms: Number.isFinite(ev.sentMs) ? ev.sentMs : null,
|
||||
handled: ev.handled ? 1 : 0,
|
||||
handler: orNull(ev.handler),
|
||||
payload: JSON.stringify(ev),
|
||||
})
|
||||
}
|
||||
|
||||
const removePage = (pageId) => (pageId ? db.removePage(pageId) : Promise.resolve())
|
||||
const clearPages = () => db.clearPages()
|
||||
|
||||
function shapePage(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return {
|
||||
pageId: r.page_id,
|
||||
type: r.type,
|
||||
sender: { serial: r.page_id, name: r.sender_name, acct: r.sender_acct, webId: r.web_id },
|
||||
message: r.message,
|
||||
map: r.map,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
sentMs: r.sent_ms == null ? null : Number(r.sent_ms),
|
||||
handled: Boolean(r.handled),
|
||||
handler: r.handler,
|
||||
updatedAt: r.updated_at,
|
||||
// Keep the raw payload available for any field not hoisted above.
|
||||
payload: payload || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function listPages() {
|
||||
const rows = await db.listPages()
|
||||
return rows.map(shapePage)
|
||||
}
|
||||
|
||||
// Replace the whole queue with a fresh snapshot (sidecar GET /pages on connect).
|
||||
async function replacePages(pages) {
|
||||
await db.clearPages()
|
||||
for (const ev of pages || []) await upsertPage(ev)
|
||||
}
|
||||
|
||||
// ── Guild board (Protocol 2.0) ─────────────────────────────────────────────
|
||||
// Upsert a guild's roster snapshot (guild.update). The leader is an actor object
|
||||
// flattened into leader_* columns; the full event lives in `payload`.
|
||||
async function upsertGuild(ev) {
|
||||
if (!ev || ev.id == null) return
|
||||
const leader = ev.leader || {}
|
||||
await db.upsertGuild(ev.id, {
|
||||
name: ev.name ?? null,
|
||||
abbr: ev.abbr ?? null,
|
||||
members: ev.members ?? null,
|
||||
online: ev.online ?? null,
|
||||
alliance: ev.alliance ?? null,
|
||||
leader_serial: leader.serial ?? null,
|
||||
leader_name: leader.name ?? null,
|
||||
leader_acct: leader.acct ?? null,
|
||||
leader_web_id: leader.webId ?? null,
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
})
|
||||
}
|
||||
|
||||
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
|
||||
const clearGuilds = () => db.clearGuilds()
|
||||
|
||||
function shapeGuild(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return payload || {
|
||||
kind: 'guild.update',
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
abbr: r.abbr,
|
||||
members: r.members,
|
||||
online: r.online,
|
||||
alliance: r.alliance,
|
||||
leader: r.leader_serial
|
||||
? { serial: r.leader_serial, name: r.leader_name, acct: r.leader_acct, webId: r.leader_web_id }
|
||||
: null,
|
||||
t: r.t,
|
||||
}
|
||||
}
|
||||
|
||||
async function listGuilds() {
|
||||
const rows = await db.listGuilds()
|
||||
return rows.map(shapeGuild)
|
||||
}
|
||||
|
||||
// Replace the board with a fresh snapshot (sidecar GET /guilds on connect).
|
||||
async function replaceGuilds(guilds) {
|
||||
await db.clearGuilds()
|
||||
for (const ev of guilds || []) await upsertGuild(ev)
|
||||
}
|
||||
|
||||
// The guild an actor leads (cross-link on the character sheet). Leadership only —
|
||||
// see the db note; membership for rank-and-file isn't in the feed, so we return
|
||||
// null rather than show a possibly-stale guess.
|
||||
async function findGuildForActor({ serial, acct }) {
|
||||
const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null)
|
||||
const g = rows[0]
|
||||
if (!g) return null
|
||||
return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' }
|
||||
}
|
||||
|
||||
// Guilds led by any of a user's linked accounts (admin user-detail cross-link).
|
||||
async function listGuildsLedForAccounts(accounts) {
|
||||
const rows = await db.listGuildsLedByAccounts(accounts)
|
||||
return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name }))
|
||||
}
|
||||
|
||||
// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
|
||||
// Upsert a city's governance snapshot (city.update) AND capture term history.
|
||||
// Term capture runs first (it reads the CURRENT open term to decide whether the
|
||||
// governor changed) and is idempotent: a repeat/backfill of the same governor is a
|
||||
// no-op, so it's safe to call on the live feed and on reconnect snapshots alike.
|
||||
async function upsertGovernor(ev) {
|
||||
if (!ev || !ev.city) return
|
||||
await recordGovernorTransition(ev)
|
||||
const gov = ev.governor
|
||||
const elect = ev.governorElect
|
||||
await db.upsertGovernor(ev.city, {
|
||||
governor_serial: orNull(gov?.serial),
|
||||
governor_name: orNull(gov?.name),
|
||||
governor_acct: orNull(gov?.acct),
|
||||
governor_web_id: orNull(gov?.webId),
|
||||
elect_serial: orNull(elect?.serial),
|
||||
elect_name: orNull(elect?.name),
|
||||
elect_acct: orNull(elect?.acct),
|
||||
election_phase: orNull(ev.electionPhase),
|
||||
candidates: orNull(ev.candidates),
|
||||
auto_pick_at: toDate(ev.autoPickAt),
|
||||
payload: JSON.stringify(ev),
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
})
|
||||
}
|
||||
|
||||
// Close the open term and open a new one when the governor CHANGES. Idempotent:
|
||||
// same governor as the open term ⇒ nothing happens (so backfill/duplicate
|
||||
// city.update events never spawn spurious terms).
|
||||
async function recordGovernorTransition(ev) {
|
||||
const gov = ev.governor || null
|
||||
const newSerial = gov ? gov.serial ?? null : null
|
||||
const t = Number.isFinite(ev.t) ? ev.t : Date.now()
|
||||
const open = await db.currentGovernorTerm(ev.city)
|
||||
const openSerial = open ? open.governor_serial : null
|
||||
if (open && openSerial === newSerial) return // unchanged — nothing to record
|
||||
if (open) await db.closeGovernorTerm(open.id, t) // governor changed or seat vacated
|
||||
if (newSerial) {
|
||||
await db.openGovernorTerm({
|
||||
city: ev.city,
|
||||
serial: newSerial,
|
||||
name: gov.name ?? null,
|
||||
acct: gov.acct ?? null,
|
||||
webId: gov.webId ?? null,
|
||||
startedAt: t,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function shapeGovernor(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
return payload || {
|
||||
kind: 'city.update',
|
||||
city: r.city,
|
||||
governor: r.governor_serial
|
||||
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
||||
: null,
|
||||
governorElect: r.elect_serial
|
||||
? { serial: r.elect_serial, name: r.elect_name, acct: r.elect_acct }
|
||||
: null,
|
||||
electionPhase: r.election_phase,
|
||||
candidates: r.candidates,
|
||||
t: r.t,
|
||||
}
|
||||
}
|
||||
|
||||
async function listGovernors() {
|
||||
const rows = await db.listGovernors()
|
||||
return rows.map(shapeGovernor)
|
||||
}
|
||||
|
||||
// Cities the given game accounts currently govern (cross-link badge).
|
||||
async function listGovernorshipsForAccounts(accounts) {
|
||||
const rows = await db.listGovernorshipsByAccounts(accounts)
|
||||
return rows.map(shapeGovernor)
|
||||
}
|
||||
|
||||
// Term history for a city (look-back), newest first.
|
||||
async function listGovernorHistory(city, limit = 100) {
|
||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 500)
|
||||
const rows = await db.listGovernorTerms(city, n)
|
||||
return rows.map((r) => ({
|
||||
city: r.city,
|
||||
governor: r.governor_serial
|
||||
? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id }
|
||||
: null,
|
||||
startedAt: r.started_at == null ? null : Number(r.started_at),
|
||||
endedAt: r.ended_at == null ? null : Number(r.ended_at),
|
||||
votes: r.votes,
|
||||
}))
|
||||
}
|
||||
|
||||
// Upsert governors without clearing (cities are fixed, no remove event); term
|
||||
// capture inside upsertGovernor stays idempotent across reconnect snapshots.
|
||||
async function replaceGovernors(cities) {
|
||||
for (const ev of cities || []) await upsertGovernor(ev)
|
||||
}
|
||||
|
||||
// ── Online-population snapshot (Protocol 2.0 presence.online) ───────────────
|
||||
async function setPresence(ev) {
|
||||
if (!ev) return
|
||||
await db.setPresence({
|
||||
count: ev.count,
|
||||
byFacet: ev.byFacet || null,
|
||||
byRegion: ev.byRegion || null,
|
||||
t: ev.t,
|
||||
})
|
||||
}
|
||||
|
||||
async function latestPresence() {
|
||||
const r = await db.latestPresence()
|
||||
if (!r) return { count: 0, byFacet: {}, byRegion: {}, t: null }
|
||||
const parse = (v) => (typeof v === 'string' ? safeJson(v) || {} : v || {})
|
||||
return {
|
||||
count: Number(r.count) || 0,
|
||||
byFacet: parse(r.by_facet),
|
||||
byRegion: parse(r.by_region),
|
||||
t: r.t == null ? null : Number(r.t),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsertOnline,
|
||||
setOffline,
|
||||
clearOnline,
|
||||
onlineCount,
|
||||
listOnline,
|
||||
listOnlineLinked,
|
||||
listOnlineForAccounts,
|
||||
addEconomySample,
|
||||
listEconomy,
|
||||
latestEconomy,
|
||||
upsertHouse,
|
||||
listIdoc,
|
||||
listHousesForAccounts,
|
||||
upsertHouseRegistry,
|
||||
removeHouse,
|
||||
listHouses,
|
||||
upsertChamp,
|
||||
removeChamp,
|
||||
clearChamps,
|
||||
listChamps,
|
||||
replaceChamps,
|
||||
upsertPage,
|
||||
removePage,
|
||||
clearPages,
|
||||
listPages,
|
||||
replacePages,
|
||||
upsertGuild,
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
replaceGuilds,
|
||||
findGuildForActor,
|
||||
listGuildsLedForAccounts,
|
||||
upsertGovernor,
|
||||
listGovernors,
|
||||
listGovernorshipsForAccounts,
|
||||
listGovernorHistory,
|
||||
replaceGovernors,
|
||||
setPresence,
|
||||
latestPresence,
|
||||
setRuleset,
|
||||
getRuleset,
|
||||
upsertPointsBoard,
|
||||
listPointsBoards,
|
||||
getPointsBoard,
|
||||
}
|
||||
37
server/model/shardVisibility/shardVisibility.db.js
Normal file
37
server/model/shardVisibility/shardVisibility.db.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
|
||||
// compiles a default for every known feature and merges stored rows over it, so
|
||||
// a fresh install with an empty table behaves exactly as the site did pre-v3.
|
||||
|
||||
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
|
||||
|
||||
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
|
||||
|
||||
const getOne = (feature) =>
|
||||
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
|
||||
|
||||
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
|
||||
// {field: rung}; the caller has already stripped locked fields and validated
|
||||
// every rung against the ladder.
|
||||
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
|
||||
query(
|
||||
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
audience = VALUES(audience),
|
||||
stream = VALUES(stream),
|
||||
field_rules = VALUES(field_rules),
|
||||
updated_by = VALUES(updated_by)`,
|
||||
[
|
||||
feature,
|
||||
enabled ? 1 : 0,
|
||||
audience,
|
||||
stream ? 1 : 0,
|
||||
fieldRules == null ? null : JSON.stringify(fieldRules),
|
||||
updatedBy ?? null,
|
||||
],
|
||||
)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
44
server/model/shardVisibility/shardVisibility.model.js
Normal file
44
server/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 }
|
||||
35
server/model/singletonConfigDb.js
Normal file
35
server/model/singletonConfigDb.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const { query } = require('../core')
|
||||
|
||||
// Factory for the singleton config tables (bot_config, email_config,
|
||||
// uo_link_config). Each is a one-row table keyed on id = 1: `get()` returns the
|
||||
// row (or null before the admin first saves it), and `upsert()` writes only the
|
||||
// columns the model prepared, leaving the rest untouched. The three tables share
|
||||
// this shape exactly, so the DB layer is generated rather than copy-pasted —
|
||||
// only the table name and column list differ.
|
||||
//
|
||||
// `fields` are already prepared by the model (secrets pre-encrypted); ordering
|
||||
// and encryption stay a model-layer concern.
|
||||
function singletonConfigDb(table, cols) {
|
||||
async function get() {
|
||||
const rows = await query(`SELECT ${cols} FROM ${table} WHERE id = 1 LIMIT 1`)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function upsert(fields) {
|
||||
const columns = Object.keys(fields)
|
||||
const vals = columns.map((c) => fields[c])
|
||||
const insertCols = ['id', ...columns].map((c) => `\`${c}\``).join(', ')
|
||||
const placeholders = ['1', ...columns.map(() => '?')].join(', ')
|
||||
const updates = columns.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ')
|
||||
await query(
|
||||
`INSERT INTO ${table} (${insertCols}) VALUES (${placeholders})
|
||||
ON DUPLICATE KEY UPDATE ${updates}`,
|
||||
vals,
|
||||
)
|
||||
return get()
|
||||
}
|
||||
|
||||
return { get, upsert }
|
||||
}
|
||||
|
||||
module.exports = singletonConfigDb
|
||||
7
server/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
7
server/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const singletonConfigDb = require('../singletonConfigDb')
|
||||
|
||||
const COLS =
|
||||
'id, base_url, ws_url, auth_token_enc, protocol, enabled, status, status_detail, plugin_connected, last_event_at, boot_id, updated_by, created_at, updated_at'
|
||||
|
||||
// Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract.
|
||||
module.exports = singletonConfigDb('uo_link_config', COLS)
|
||||
88
server/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
88
server/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// uo-link sidecar connection config store. Mirrors botConfig/emailConfig: the DB
|
||||
// layer only ever sees ciphertext, and only getWithToken() (used server-side to
|
||||
// call the sidecar over REST/WS) decrypts it. The admin-facing getSafe() never
|
||||
// includes the token — it exposes only `hasToken`. A blank `token` on save means
|
||||
// "leave the existing token unchanged" (same convention as the other configs).
|
||||
|
||||
const db = require('./uoLinkConfig.db')
|
||||
const { secretBox } = require('../../core')
|
||||
|
||||
// The wire protocol this build speaks (link/sidecar/src/main.rs PROTOCOL_VERSION).
|
||||
// Only used before an admin has saved anything — the stored row wins once it exists,
|
||||
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 3
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) {
|
||||
return {
|
||||
baseUrl: process.env.UOLINK_BASE_URL || null,
|
||||
wsUrl: process.env.UOLINK_WS_URL || null,
|
||||
protocol: DEFAULT_PROTOCOL,
|
||||
enabled: false,
|
||||
hasToken: false,
|
||||
status: 'disconnected',
|
||||
statusDetail: null,
|
||||
pluginConnected: false,
|
||||
lastEventAt: null,
|
||||
bootId: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
baseUrl: row.base_url || null,
|
||||
wsUrl: row.ws_url || null,
|
||||
protocol: row.protocol || DEFAULT_PROTOCOL,
|
||||
enabled: Boolean(row.enabled),
|
||||
hasToken: Boolean(row.auth_token_enc),
|
||||
status: row.status || 'disconnected',
|
||||
statusDetail: row.status_detail || null,
|
||||
pluginConnected: Boolean(row.plugin_connected),
|
||||
lastEventAt: row.last_event_at || null,
|
||||
bootId: row.boot_id || null,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSafe() {
|
||||
return toSafe(await db.get())
|
||||
}
|
||||
|
||||
// Decrypted token included — server-side only (calling the sidecar's REST/WS
|
||||
// API). Returns null when nothing has been saved yet.
|
||||
async function getWithToken() {
|
||||
const row = await db.get()
|
||||
if (!row) return null
|
||||
return { ...toSafe(row), token: row.auth_token_enc ? secretBox.decrypt(row.auth_token_enc) : null }
|
||||
}
|
||||
|
||||
// Save admin-supplied config. `token` undefined or '' means "leave the existing
|
||||
// token unchanged" (same convention as botConfig.save).
|
||||
async function save({ baseUrl, wsUrl, token, protocol, enabled, updatedBy }) {
|
||||
const fields = {}
|
||||
if (baseUrl !== undefined) fields.base_url = baseUrl
|
||||
if (wsUrl !== undefined) fields.ws_url = wsUrl
|
||||
if (token) fields.auth_token_enc = secretBox.encrypt(token)
|
||||
if (protocol !== undefined) fields.protocol = protocol
|
||||
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
|
||||
if (updatedBy !== undefined) fields.updated_by = updatedBy
|
||||
const row = await db.upsert(fields)
|
||||
return toSafe(row)
|
||||
}
|
||||
|
||||
// Mirror the sidecar's last-reported connection state into the DB so the admin
|
||||
// panel has something to show between polls and the public status endpoint can
|
||||
// read it without a live round-trip.
|
||||
async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt, bootId }) {
|
||||
const fields = {}
|
||||
if (status !== undefined) fields.status = status
|
||||
if (statusDetail !== undefined) fields.status_detail = statusDetail
|
||||
if (pluginConnected !== undefined) fields.plugin_connected = pluginConnected ? 1 : 0
|
||||
// lastEventAt may arrive as an ISO string (e.g. "2026-07-10T22:08:27Z"); the
|
||||
// mariadb DATETIME parser rejects the "T"/"Z", so hand it a real Date (same
|
||||
// fix as botConfig.recordStatus's last_connected_at).
|
||||
if (lastEventAt !== undefined) fields.last_event_at = lastEventAt ? new Date(lastEventAt) : null
|
||||
if (bootId !== undefined) fields.boot_id = bootId
|
||||
if (Object.keys(fields).length === 0) return getSafe()
|
||||
const row = await db.upsert(fields)
|
||||
return toSafe(row)
|
||||
}
|
||||
|
||||
module.exports = { getSafe, getWithToken, save, recordStatus }
|
||||
182
server/package-lock.json
generated
182
server/package-lock.json
generated
@@ -8,9 +8,13 @@
|
||||
"name": "module-uo-server",
|
||||
"version": "0.1.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"express": "^4.19.2",
|
||||
"express-validator": "^7.2.0"
|
||||
"express-validator": "^7.2.0",
|
||||
"swagger-autogen": "^2.23.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -30,6 +34,19 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "7.4.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
|
||||
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -37,6 +54,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
@@ -62,6 +86,17 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -103,6 +138,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -153,6 +195,16 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -356,6 +408,13 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -405,6 +464,28 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -478,6 +559,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -495,6 +588,19 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -578,6 +684,19 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -621,6 +740,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -631,6 +760,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
@@ -864,6 +1003,19 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-autogen": {
|
||||
"version": "2.23.7",
|
||||
"resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.7.tgz",
|
||||
"integrity": "sha512-vr7uRmuV0DCxWc0wokLJAwX3GwQFJ0jwN+AWk0hKxre2EZwusnkGSGdVFd82u7fQLgwSTnbWkxUL7HXuz5LTZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^7.4.1",
|
||||
"deepmerge": "^4.2.2",
|
||||
"glob": "^7.1.7",
|
||||
"json5": "^2.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -927,6 +1079,34 @@
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,22 @@
|
||||
"license": "GPL-3.0-or-later",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"check:imports": "node scripts/checkImports.js"
|
||||
"test": "node --test --require ./test/_setup.js",
|
||||
"check:imports": "node scripts/checkImports.js",
|
||||
"swagger": "node scripts/swaggerFragment.js",
|
||||
"check:swagger": "node scripts/swaggerFragment.js --check"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"//dependencies": "Deliberately none. Everything the shipped server half needs arrives on ctx (MODULE_API.md §2.3) — a module lives outside core's server/ and cannot resolve core's node_modules. The two below are devDependencies because test/_fakes.js builds a REAL express router: a fake Router would test the fake.",
|
||||
"//dependencies": "The ONE runtime dependency, and it ships inside the release tarball: CI runs npm ci --omit=dev and packs server/node_modules, because an operator never builds (MODULE_SYSTEM.md 1.14). Node resolves it by walking up from modules/uo/server/. Everything else the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger, the middleware and the rate-limit factory are all core-owned and handed over.",
|
||||
"devDependencies": {
|
||||
"express": "^4.19.2",
|
||||
"express-validator": "^7.2.0"
|
||||
}
|
||||
"express-validator": "^7.2.0",
|
||||
"swagger-autogen": "^2.23.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"//devDependencies": "Test-only and build-only, never shipped. test/_fakes.js builds a REAL express router - a fake Router would test the fake. swagger-autogen is the same generator core uses, pinned to the same major so the fragment and the spec it merges into come out of one tool (MODULE_API.md 2.8)."
|
||||
}
|
||||
|
||||
387
server/router/admin/shard.router.js
Normal file
387
server/router/admin/shard.router.js
Normal file
@@ -0,0 +1,387 @@
|
||||
// Admin · Shard — everything under /api/v1/admin/shard, in two tiers.
|
||||
//
|
||||
// Mounted at /api/v1/admin/shard by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. Two capabilities share this prefix, and
|
||||
// prefix ownership is the invariant the split preserves — so they share a file:
|
||||
//
|
||||
// 1. Self-service game-account linking (no extra gate). A staff member links
|
||||
// and inspects their OWN in-game account exactly as a player does under
|
||||
// /player/shard; the handlers are the very same `player/shard.controller`
|
||||
// ones, keyed off req.user.id. These keep their `Admin · Account` swagger
|
||||
// tag, which is why the tag disagrees with this filename.
|
||||
// 2. Privileged live-shard operations and the help-page queue (`modAccess` —
|
||||
// admin or moderator). `actor` is stamped server-side from the session in
|
||||
// shardOps.controller.js; the request body never carries it.
|
||||
//
|
||||
// `modAccess` stays a per-route gate rather than a router-level `use`: it was
|
||||
// per-route in admin.routes.js, and half the routes here must NOT have it.
|
||||
//
|
||||
// NOTE: /admin/shard/pages is the in-game help-page (support) queue. It is
|
||||
// unrelated to /admin/pages, the CMS page builder.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const { body, param } = core.validator
|
||||
|
||||
const shardOps = require('./shardOps.controller')
|
||||
const shardVisibility = require('./shardVisibility.controller')
|
||||
const shardAtlas = require('./shardAtlas.controller')
|
||||
const shardClilocs = require('./shardClilocs.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const { requireRole, validate } = core.middleware
|
||||
|
||||
const shardRouter = express.Router()
|
||||
|
||||
// Moderator gate. Admins can do everything a moderator can.
|
||||
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) ───────────────
|
||||
// Staff link their OWN in-game account here, exactly like players do under
|
||||
// /player/shard. The controller keys off req.user.id, so the same handlers work.
|
||||
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||
shardRouter.post(
|
||||
'/link',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||
validate,
|
||||
selfShard.link,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/accounts',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'List the caller’s linked game accounts (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
|
||||
selfShard.listAccounts,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/roster/:account',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Character roster for an account (self; admins: any account)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
selfShard.roster,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/vendors/:account',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Player vendors for an account (self; admins: any account)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
selfShard.vendors,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/char/:serial',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Character sheet (self-linked characters; admins: any character)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
selfShard.getChar,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/sales',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
|
||||
selfShard.getSales,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/account',
|
||||
// #swagger.tags = ['Admin · Account']
|
||||
// #swagger.summary = 'Create a game account and link it to the caller (staff self-service)'
|
||||
// #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
validate,
|
||||
selfShard.createGameAccount,
|
||||
)
|
||||
|
||||
// ── In-game staff operations (uo-link write plane + support queue) ─────
|
||||
// Privileged live-shard actions and the help-page queue, open to moderators as
|
||||
// well as admins (modAccess). `actor` is stamped server-side from the session in
|
||||
// the controller — the body never carries it. See shardOps.controller.js.
|
||||
shardRouter.post(
|
||||
'/kick',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Kick every live session of an account (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Kicked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||||
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
shardOps.kick,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/ban',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Ban an account, timed or indefinite (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, serial: { type: "string" }, durationSec: { type: "integer" }, reason: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Banned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected target or write plane disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
body('account').optional({ values: 'falsy' }).matches(SHARD_ACCOUNT_RE),
|
||||
body('serial').optional({ values: 'falsy' }).matches(/^0x[0-9a-fA-F]+$/),
|
||||
body('durationSec').optional().isInt({ min: 0, max: 315360000 }),
|
||||
body('reason').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
|
||||
validate,
|
||||
shardOps.ban,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/unban',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Clear an account ban (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" } }, required: ["account"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Unbanned', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
body('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
shardOps.unban,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/broadcast',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Broadcast a system message to everyone online (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { text: { type: "string" }, hue: { type: "integer" } }, required: ["text"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Broadcast', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
body('text').isString().trim().isLength({ min: 1, max: 300 }),
|
||||
body('hue').optional().isInt({ min: 0, max: 3000 }),
|
||||
validate,
|
||||
shardOps.broadcast,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/pages',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Open help-page (support) queue (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Open pages', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
modAccess,
|
||||
shardOps.listPages,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/pages/:id/respond',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Reply to a help page, optionally closing it (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { message: { type: "string" }, close: { type: "boolean" } }, required: ["message"] } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Responded', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Unknown page', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
modAccess,
|
||||
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||||
body('message').isString().trim().isLength({ min: 1, max: 500 }),
|
||||
body('close').optional().isBoolean(),
|
||||
validate,
|
||||
shardOps.respondPage,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/pages/:id/close',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Resolve a help page without a reply (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Page id (sender serial).' }
|
||||
/* #swagger.responses[200] = { description: 'Closed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
modAccess,
|
||||
param('id').matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
shardOps.closePage,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/audit',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEvent" } } } } } */
|
||||
modAccess,
|
||||
shardOps.listAudit,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/houses',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)'
|
||||
// #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
|
||||
modAccess,
|
||||
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/UoAtlasStatus" } } } } */
|
||||
/* #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/UoAtlasRefreshResult" } } } } */
|
||||
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/UoAtlasRefreshResult" } } } } */
|
||||
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/UoAtlasRefreshResult" } } } } */
|
||||
/* #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/UoAtlasStatus" } } } } */
|
||||
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/UoClilocStatus" } } } } */
|
||||
/* #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/UoClilocRefreshResult" } } } } */
|
||||
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/UoClilocStatus" } } } } */
|
||||
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/UoShardVisibilityConfig" } } } } */
|
||||
/* #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/UoShardVisibilityUpdate" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityConfig" } } } } */
|
||||
/* #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
|
||||
117
server/router/admin/shardAtlas.controller.js
Normal file
117
server/router/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('../../core')
|
||||
|
||||
const log = require('../../core').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/router/admin/shardClilocs.controller.js
Normal file
106
server/router/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('../../core')
|
||||
|
||||
const log = require('../../core').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 }
|
||||
171
server/router/admin/shardOps.controller.js
Normal file
171
server/router/admin/shardOps.controller.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// ── Admin: in-game staff operations (uo-link write plane + support queue) ────
|
||||
//
|
||||
// The privileged "write plane" (§6 of the sidecar guide): kick / ban / unban /
|
||||
// broadcast against the live shard, plus the help-page (support ticket) queue.
|
||||
// Gated admin+moderator at the route (modAccess) — the sidecar trusts the
|
||||
// loopback socket, so authorization is entirely the site's responsibility.
|
||||
//
|
||||
// SECURITY: `actor` (who is taking the action) is ALWAYS set here from the
|
||||
// authenticated session (req.user.username), never from the request body, so an
|
||||
// action can't be attributed to someone else. The shard records it in its console
|
||||
// log, the ban's BanDealer tag, and the admin.audit event it echoes back.
|
||||
|
||||
const uoLinkClient = require('../../utils/uoLinkClient')
|
||||
const shardState = require('../../model/shardState/shardState.model')
|
||||
const shardEvents = require('../../model/shardEvents/shardEvents.model')
|
||||
const { activity } = require('../../core')
|
||||
|
||||
const log = require('../../core').logger('admin-shard-ops')
|
||||
|
||||
// Map a never-throw uoLinkClient result onto an HTTP response. `okData` shapes the
|
||||
// success body. Mirrors the sidecar's documented status codes so the UI can tell a
|
||||
// transient outage (503/504 — retry) from a real rejection (403/404).
|
||||
function relay(res, result, okData) {
|
||||
if (result.ok) return res.json(okData(result.data))
|
||||
switch (result.status) {
|
||||
case 400:
|
||||
return res.status(400).json({ message: (result.data && result.data.error) || 'The shard rejected that request.' })
|
||||
case 403:
|
||||
return res.status(403).json({
|
||||
message:
|
||||
(result.data && result.data.error) ||
|
||||
'That action was refused — the target is protected, or the write plane is disabled on the shard.',
|
||||
})
|
||||
case 404:
|
||||
return res.status(404).json({ message: 'No such account or target on the shard.' })
|
||||
case 503:
|
||||
case 504:
|
||||
case 0:
|
||||
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||
default:
|
||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/kick — disconnect every live session of an account (or serial).
|
||||
async function kick(req, res) {
|
||||
const { account, serial } = req.body
|
||||
const actor = req.user.username
|
||||
try {
|
||||
const result = await uoLinkClient.adminKick({ actor, account, serial })
|
||||
if (result.ok) await activity.log({ req, action: 'shard.kick', detail: { account, serial } })
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.kick', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/ban — ban an account (works offline); durationSec 0/absent = indefinite.
|
||||
async function ban(req, res) {
|
||||
const { account, serial, durationSec, reason } = req.body
|
||||
const actor = req.user.username
|
||||
try {
|
||||
const result = await uoLinkClient.adminBan({ actor, account, serial, durationSec, reason })
|
||||
if (result.ok) await activity.log({ req, action: 'shard.ban', detail: { account, serial, durationSec, reason } })
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.ban', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/unban — clear an account's ban.
|
||||
async function unban(req, res) {
|
||||
const { account } = req.body
|
||||
const actor = req.user.username
|
||||
try {
|
||||
const result = await uoLinkClient.adminUnban({ actor, account })
|
||||
if (result.ok) await activity.log({ req, action: 'shard.unban', detail: { account } })
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.unban', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/broadcast — a system message to everyone online.
|
||||
async function broadcast(req, res) {
|
||||
const { text, hue } = req.body
|
||||
const actor = req.user.username
|
||||
try {
|
||||
const result = await uoLinkClient.adminBroadcast({ actor, text, hue })
|
||||
if (result.ok) await activity.log({ req, action: 'shard.broadcast', detail: { text } })
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.broadcast', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/shard/pages — the open help-page (support) queue, from our store.
|
||||
async function listPages(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listPages())
|
||||
} catch (err) {
|
||||
log.error('shardOps.listPages', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/pages/:id/respond — reply to a player (optionally close).
|
||||
async function respondPage(req, res) {
|
||||
const { id } = req.params
|
||||
const { message, close } = req.body
|
||||
try {
|
||||
const result = await uoLinkClient.respondPage(id, { message, close: Boolean(close) })
|
||||
if (result.ok) {
|
||||
await activity.log({ req, action: 'shard.page.respond', detail: { pageId: id, close: Boolean(close) } })
|
||||
// Close removes the page from the queue; reflect it locally at once (the
|
||||
// page.closed event will confirm it, but the UI shouldn't wait a poll cycle).
|
||||
if (close) await shardState.removePage(id).catch(() => {})
|
||||
}
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.respondPage', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/pages/:id/close — resolve a page without a reply.
|
||||
async function closePage(req, res) {
|
||||
const { id } = req.params
|
||||
try {
|
||||
const result = await uoLinkClient.closePage(id)
|
||||
if (result.ok) {
|
||||
await activity.log({ req, action: 'shard.page.close', detail: { pageId: id } })
|
||||
await shardState.removePage(id).catch(() => {})
|
||||
}
|
||||
return relay(res, result, (d) => d || { ok: true })
|
||||
} catch (err) {
|
||||
log.error('shardOps.closePage', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/shard/audit — recent moderation audit events (admin.audit), from the
|
||||
// ingested event log. Seeds the live audit log the panel keeps current over SSE.
|
||||
async function listAudit(req, res) {
|
||||
try {
|
||||
const limit = req.query.limit
|
||||
return res.json(await shardEvents.list({ kind: 'admin.audit', limit }))
|
||||
} catch (err) {
|
||||
log.error('shardOps.listAudit', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/shard/houses — the FULL house registry (owner, price, co-owners,
|
||||
// decay), staff-only (modAccess). The public /public/shard/houses shows only IDOC
|
||||
// houses with location; this is the complete board, kept live for staff on the
|
||||
// admin SSE channel (house.update / house.remove).
|
||||
async function listHouses(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listHouses())
|
||||
} catch (err) {
|
||||
log.error('shardOps.listHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { kick, ban, unban, broadcast, listPages, respondPage, closePage, listAudit, listHouses }
|
||||
98
server/router/admin/shardVisibility.controller.js
Normal file
98
server/router/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('../../core').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 }
|
||||
152
server/router/admin/uoLink.controller.js
Normal file
152
server/router/admin/uoLink.controller.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// ── Admin: uo-link sidecar control ─────────────────────────────────────────
|
||||
//
|
||||
// Configure the connection to the uo-link sidecar (base/ws URL, shared-secret
|
||||
// token, protocol pin, enabled) and drive the town crier. SECURITY: the token
|
||||
// is write-only over this API — stored encrypted, NEVER returned; responses
|
||||
// expose only `hasToken` (same convention as the Discord bot token). Saving
|
||||
// (re)starts the WS ingest client so a change takes effect with no redeploy.
|
||||
|
||||
const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const uoLinkClient = require('../../utils/uoLinkClient')
|
||||
const uoLinkSocket = require('../../utils/uoLinkSocket')
|
||||
const shardBroadcast = require('../../utils/shardBroadcast')
|
||||
const gameSignup = require('../../utils/gameSignup')
|
||||
const { activity } = require('../../core')
|
||||
|
||||
const log = require('../../core').logger('admin-uolink')
|
||||
|
||||
// Assemble the masked config + live health + ingestion stats for the panel.
|
||||
async function buildStatus() {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
const health = await uoLinkClient.health()
|
||||
return {
|
||||
...config,
|
||||
health: health.ok ? health.data : { ok: false, error: health.error || `status ${health.status}` },
|
||||
ingest: uoLinkSocket.getState(),
|
||||
sse: shardBroadcast.stats(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/uo-link/config — masked config + live status + ingestion stats.
|
||||
async function getConfig(req, res) {
|
||||
try {
|
||||
return res.json(await buildStatus())
|
||||
} catch (err) {
|
||||
log.error('uoLink.getConfig', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/uo-link/config — save connection settings + (re)start the socket.
|
||||
async function saveConfig(req, res) {
|
||||
const { baseUrl, wsUrl, token, protocol, enabled } = req.body
|
||||
try {
|
||||
const current = await uoLinkConfig.getSafe()
|
||||
const willHaveToken = Boolean(token) || current.hasToken
|
||||
if (enabled && !willHaveToken) {
|
||||
return res.status(400).json({ message: 'An auth token is required before enabling.' })
|
||||
}
|
||||
|
||||
await uoLinkConfig.save({
|
||||
baseUrl,
|
||||
wsUrl,
|
||||
token,
|
||||
protocol: protocol !== undefined ? Number(protocol) : undefined,
|
||||
enabled,
|
||||
updatedBy: req.user.id,
|
||||
})
|
||||
// Drop the client's cached config so the health check below uses the new values.
|
||||
uoLinkClient.invalidateConfig()
|
||||
|
||||
// (Re)start or stop the ingest socket to match the new enabled/URL/token.
|
||||
const saved = await uoLinkConfig.getSafe()
|
||||
if (saved.enabled && saved.hasToken) {
|
||||
await uoLinkSocket.start()
|
||||
} else {
|
||||
uoLinkSocket.stop()
|
||||
await uoLinkConfig.recordStatus({ status: 'disconnected', pluginConnected: false })
|
||||
}
|
||||
|
||||
await activity.log({ req, action: 'uoLink.config.update', detail: { baseUrl: saved.baseUrl, enabled: saved.enabled } })
|
||||
log.info('uo-link config updated', { by: req.user.username, enabled: saved.enabled })
|
||||
return res.json(await buildStatus())
|
||||
} catch (err) {
|
||||
log.error('uoLink.saveConfig', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/uo-link/signup-mode — whether this site creates game accounts.
|
||||
//
|
||||
// Core's Site Settings carried this field until slice 3, with help text naming
|
||||
// Bridge.cfg. It reads as UO policy because it is: the site's mode and the
|
||||
// shard's own SignupMode have to agree, and only one of those two is core's.
|
||||
async function getSignupMode(req, res) {
|
||||
try {
|
||||
return res.json({ mode: await gameSignup.getMode(), modes: gameSignup.MODES })
|
||||
} catch (err) {
|
||||
log.error('uoLink.getSignupMode', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/uo-link/signup-mode
|
||||
async function saveSignupMode(req, res) {
|
||||
const { mode } = req.body
|
||||
try {
|
||||
await gameSignup.setMode(mode, req.user.id)
|
||||
await activity.log({ req, action: 'uoLink.signupMode.update', detail: { mode } })
|
||||
log.info('game-signup mode updated', { by: req.user.username, mode })
|
||||
return res.json({ mode })
|
||||
} catch (err) {
|
||||
log.error('uoLink.saveSignupMode', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/uo-link/towncrier — publish/replace a town-crier message.
|
||||
async function postTownCrier(req, res) {
|
||||
const { id, lines, durationSec } = req.body
|
||||
try {
|
||||
const result = await uoLinkClient.postTownCrier({ id, lines, durationSec })
|
||||
if (result.ok) {
|
||||
await activity.log({ req, action: 'uoLink.towncrier.post', detail: { id } })
|
||||
return res.json(result.data || { ok: true, id })
|
||||
}
|
||||
if (result.status === 400) return res.status(400).json({ message: 'The shard rejected that message (over the line/duration caps?).' })
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The shard is unavailable right now.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||
} catch (err) {
|
||||
log.error('uoLink.postTownCrier', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /admin/uo-link/towncrier/:id — remove a town-crier message.
|
||||
async function deleteTownCrier(req, res) {
|
||||
const { id } = req.params
|
||||
try {
|
||||
const result = await uoLinkClient.deleteTownCrier(id)
|
||||
if (result.ok) {
|
||||
await activity.log({ req, action: 'uoLink.towncrier.delete', detail: { id } })
|
||||
return res.json(result.data || { ok: true, id })
|
||||
}
|
||||
if (result.status === 404) return res.status(404).json({ message: 'No town-crier message with that id.' })
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The shard is unavailable right now.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||
} catch (err) {
|
||||
log.error('uoLink.deleteTownCrier', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/uo-link/stream — the full live feed (incl. audit/cheat), staff only.
|
||||
function stream(req, res) {
|
||||
shardBroadcast.subscribe(req, res, 'admin')
|
||||
}
|
||||
|
||||
module.exports = { getConfig, saveConfig, getSignupMode, saveSignupMode, postTownCrier, deleteTownCrier, stream }
|
||||
137
server/router/admin/uoLink.router.js
Normal file
137
server/router/admin/uoLink.router.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// Admin · uo-link — the sidecar connection config, the town crier, and the
|
||||
// staff SSE stream.
|
||||
//
|
||||
// Mounted at /api/v1/admin/uo-link by admin/index.js, which already applied
|
||||
// `noindex, isLoggedIn, staffOnly`. This is where shard integration is
|
||||
// configured: base/ws URL, bearer token, protocol version and the enabled
|
||||
// toggle all live in the DB (uoLinkConfig), never in env. The token is
|
||||
// write-only over this API (SECURITY note in uoLink.controller.js).
|
||||
//
|
||||
// /stream is the ADMIN SSE channel — it carries staff audit, cheat detection
|
||||
// and login attempts on top of the public event kinds. The public/admin
|
||||
// allowlist split in utils/shardIngest.js is a security boundary; the adminOnly
|
||||
// gate below is its other half.
|
||||
//
|
||||
// The routes keep their `Admin · Shard` swagger tag: retagging is a real
|
||||
// OpenAPI diff and does not belong in a route-move PR.
|
||||
//
|
||||
// Admin-only, and kept as a per-route gate rather than a router-level `use` so
|
||||
// the middleware chain each route carries is unchanged by the move.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const { body, param } = core.validator
|
||||
|
||||
const uoLink = require('./uoLink.controller')
|
||||
const gameSignup = require('../../utils/gameSignup')
|
||||
const { requireRole, validate } = core.middleware
|
||||
|
||||
const uoLinkRouter = express.Router()
|
||||
const adminOnly = requireRole('admin')
|
||||
|
||||
uoLinkRouter.get(
|
||||
'/config',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Get uo-link config + live status + ingestion stats (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Masked config, health and ingestion stats', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
uoLink.getConfig,
|
||||
)
|
||||
uoLinkRouter.put(
|
||||
'/config',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Save uo-link connection config (admin only)'
|
||||
// #swagger.description = 'token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { baseUrl: { type: "string" }, wsUrl: { type: "string" }, token: { type: "string" }, protocol: { type: "integer" }, enabled: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated config + live status', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error, or missing token while enabling', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('baseUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['http', 'https'] }),
|
||||
body('wsUrl').optional({ values: 'falsy' }).isString().trim().isURL({ require_tld: false, protocols: ['ws', 'wss'] }),
|
||||
body('token').optional({ values: 'falsy' }).isString().trim(),
|
||||
body('protocol').optional().isInt({ min: 1, max: 99 }),
|
||||
body('enabled').optional().isBoolean(),
|
||||
validate,
|
||||
uoLink.saveConfig,
|
||||
)
|
||||
|
||||
// ── Game-account signup mode ───────────────────────────────────────────────
|
||||
//
|
||||
// New in slice 3, and new only in the sense that the field moved: core's Site
|
||||
// Settings has carried `game_account_signup` since long before the extraction,
|
||||
// and its help text has always been about a game server. The setting key and its
|
||||
// stored value are unchanged, so an existing instance keeps its configured mode.
|
||||
uoLinkRouter.get(
|
||||
'/signup-mode',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Get the game-account signup mode (admin only)'
|
||||
// #swagger.description = 'Whether the site offers game-account creation, and in which direction. The shard’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The configured mode and the legal values', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" }, modes: { type: "array", items: { type: "string" } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
uoLink.getSignupMode,
|
||||
)
|
||||
uoLinkRouter.put(
|
||||
'/signup-mode',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Set the game-account signup mode (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["mode"], properties: { mode: { type: "string", enum: ["disabled","website","hybrid","game"] } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The saved mode', content: { "application/json": { schema: { type: "object", properties: { mode: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Unknown mode', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
// Validated here as well as in gameSignup.setMode: the list is the same list,
|
||||
// and the difference is the answer. A rejected value must be a 400 naming the
|
||||
// field, not a 500 from a thrown Error the controller could only guess about.
|
||||
body('mode').isIn(gameSignup.MODES),
|
||||
validate,
|
||||
uoLink.saveSignupMode,
|
||||
)
|
||||
|
||||
uoLinkRouter.post(
|
||||
'/towncrier',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Publish / replace a town-crier message (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoTownCrierRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||||
body('lines').isArray({ min: 1, max: 8 }),
|
||||
body('lines.*').isString().isLength({ max: 200 }),
|
||||
body('durationSec').optional().isInt({ min: 1, max: 86400 }),
|
||||
validate,
|
||||
uoLink.postTownCrier,
|
||||
)
|
||||
uoLinkRouter.delete(
|
||||
'/towncrier/:id',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Remove a town-crier message (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Town-crier message id.' }
|
||||
/* #swagger.responses[200] = { description: 'Removed', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Unknown id', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
param('id').isString().trim().isLength({ min: 1, max: 64 }),
|
||||
validate,
|
||||
uoLink.deleteTownCrier,
|
||||
)
|
||||
uoLinkRouter.get(
|
||||
'/stream',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Full live shard event stream incl. audit/cheat (SSE, admin only)'
|
||||
/* #swagger.responses[200] = { description: 'An SSE stream (Content-Type: text/event-stream).' } */
|
||||
adminOnly,
|
||||
uoLink.stream,
|
||||
)
|
||||
|
||||
module.exports = uoLinkRouter
|
||||
129
server/router/admin/usersShard.controller.js
Normal file
129
server/router/admin/usersShard.controller.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// ── Admin: a single user's shard (uo-link) footprint ──────────────────────────
|
||||
//
|
||||
// Backs the /admin/users/:id detail page. Every read is scoped to the target
|
||||
// user's linked game accounts (from the local shard_account_links mirror): their
|
||||
// vendor sales, houses, and currently-online characters. The live character
|
||||
// rosters are fetched separately by the client through the existing admin-bypass
|
||||
// /admin/shard/* endpoints, so nothing here round-trips the sidecar — these are
|
||||
// fast, DB-backed reads. Admin-only (registered under adminOnly in the router).
|
||||
|
||||
const { users, activity } = require('../../core')
|
||||
const shardLinks = require('../../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../../model/shardState/shardState.model')
|
||||
const uoLinkClient = require('../../utils/uoLinkClient')
|
||||
const { salesForAccounts } = require('../../utils/shardSales')
|
||||
|
||||
const log = require('../../core').logger('admin-user-shard')
|
||||
|
||||
// Resolve the target user's linked game accounts, or null if the user id is
|
||||
// unknown (so the handler can 404 rather than silently returning an empty set).
|
||||
async function accountsForUser(id) {
|
||||
const user = await users.getById(id)
|
||||
if (!user) return null
|
||||
const links = await shardLinks.listForUser(id)
|
||||
return { user, links, accounts: links.map((l) => l.account) }
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/accounts — the user's linked game accounts.
|
||||
async function listAccounts(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(ctx.links)
|
||||
} catch (err) {
|
||||
log.error('listAccounts', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/sales — recent vendor sales on the user's accounts.
|
||||
async function getSales(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(await salesForAccounts(ctx.accounts))
|
||||
} catch (err) {
|
||||
log.error('getSales', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/houses — houses owned by the user's accounts.
|
||||
async function getHouses(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(await shardState.listHousesForAccounts(ctx.accounts))
|
||||
} catch (err) {
|
||||
log.error('getHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/online — the user's characters currently online.
|
||||
async function getOnline(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
return res.json(await shardState.listOnlineForAccounts(ctx.accounts))
|
||||
} catch (err) {
|
||||
log.error('getOnline', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links:
|
||||
// city governorships they currently hold and guilds they lead. Both are reliable
|
||||
// current-state lookups on the user's linked accounts.
|
||||
async function getStanding(req, res) {
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
const [governorOf, guildsLed] = await Promise.all([
|
||||
shardState.listGovernorshipsForAccounts(ctx.accounts),
|
||||
shardState.listGuildsLedForAccounts(ctx.accounts),
|
||||
])
|
||||
return res.json({ governorOf, guildsLed })
|
||||
} catch (err) {
|
||||
log.error('getStanding', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /admin/users/:id/shard/link/:account — unlink a game account from this
|
||||
// user, site-side. `actor` is stamped from the session (never the browser). On
|
||||
// success the sidecar clears the WebsiteUserId tag on the shard and we drop the
|
||||
// local mirror so attribution stops immediately.
|
||||
async function unlinkAccount(req, res) {
|
||||
const { account } = req.params
|
||||
try {
|
||||
const ctx = await accountsForUser(Number(req.params.id))
|
||||
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||
// Only unlink an account actually linked to THIS user (avoid cross-user unlink).
|
||||
if (!ctx.accounts.includes(account)) {
|
||||
return res.status(404).json({ message: 'That account is not linked to this user.' })
|
||||
}
|
||||
const result = await uoLinkClient.unlinkAccount({ actor: req.user.username, account })
|
||||
if (result.ok) {
|
||||
await shardLinks.removeByAccount(account)
|
||||
await activity.log({ req, userId: ctx.user.id, action: 'shard.account.unlink', detail: { account } })
|
||||
log.info('game account unlinked', { account, userId: ctx.user.id, actor: req.user.username })
|
||||
return res.json({ account, unlinked: true })
|
||||
}
|
||||
if (result.status === 403) return res.status(403).json({ message: 'That account is protected and cannot be unlinked.' })
|
||||
if (result.status === 404) {
|
||||
// Not linked on the shard — reconcile our mirror anyway so the two agree.
|
||||
await shardLinks.removeByAccount(account)
|
||||
return res.status(404).json({ message: 'That account is not linked.' })
|
||||
}
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard to unlink the account.' })
|
||||
} catch (err) {
|
||||
log.error('unlinkAccount', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount }
|
||||
113
server/router/admin/usersShard.router.js
Normal file
113
server/router/admin/usersShard.router.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// ── The `admin.users.detail` extension slot's contents ─────────────────────
|
||||
//
|
||||
// MODULE-UO CONTENT, still living in core. MODULE_SYSTEM.md §1.9 named the
|
||||
// fourth mount shape: module routes hanging off a CORE resource. These six paths
|
||||
// are shard reads on `/admin/users/:id`, a user-management URL core owns, so
|
||||
// they cannot move with a prefix and cannot stay where they are either.
|
||||
//
|
||||
// The resolution is an extension SLOT. `users.router.js` declares
|
||||
// `admin.users.detail` and mounts its router at `/:id`; this file is what fills
|
||||
// it, registered through modules/registries.js like a module would
|
||||
// (registerCore() → `api.registerExtension('admin.users.detail', …)`). Phase 3
|
||||
// moves this file to module-uo and changes nothing else — the six URLs are
|
||||
// identical either way, and core never learns what "shard" means.
|
||||
//
|
||||
// `mergeParams` comes from the slot's router, so `req.params.id` is the parent's
|
||||
// user id. Core's own routes on the resource are declared BEFORE the slot is
|
||||
// mounted, so core always wins a path conflict (MODULE_API.md §2.4).
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const { param } = core.validator
|
||||
|
||||
const usersShard = require('./usersShard.controller')
|
||||
const { validate } = core.middleware
|
||||
|
||||
// Same shape the shard routes validate account names with.
|
||||
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||
|
||||
const shardRouter = express.Router({ mergeParams: true })
|
||||
|
||||
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||||
// scoped to those accounts, their vendor sales / houses / online characters.
|
||||
// Live character rosters are fetched by the client through /admin/shard/* (which
|
||||
// already grants admins a bypass to any account), so no routes for them here.
|
||||
shardRouter.get(
|
||||
'/shard/accounts',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.listAccounts,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/sales',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getSales,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/houses',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getHouses,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/online',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getOnline,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/shard/standing',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersShard.getStanding,
|
||||
)
|
||||
shardRouter.delete(
|
||||
'/shard/link/:account',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
param('account').matches(SHARD_ACCOUNT_RE),
|
||||
validate,
|
||||
usersShard.unlinkAccount,
|
||||
)
|
||||
|
||||
module.exports = shardRouter
|
||||
274
server/router/player/shard.controller.js
Normal file
274
server/router/player/shard.controller.js
Normal file
@@ -0,0 +1,274 @@
|
||||
// ── Player: game-account linking + reads ───────────────────────────────────
|
||||
//
|
||||
// The player-facing surface for the uo-link integration. A logged-in player
|
||||
// runs [link in game, gets a one-time code, and enters it here — the server
|
||||
// confirms it with the sidecar (which permanently tags the game account with the
|
||||
// website user id) and mirrors the link locally. Roster/vendor reads are
|
||||
// ownership-checked against that mirror so a player can only see accounts they
|
||||
// have linked. The sidecar token stays server-side throughout.
|
||||
|
||||
const uoLinkClient = require('../../utils/uoLinkClient')
|
||||
const shardLinks = require('../../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../../model/shardState/shardState.model')
|
||||
const shardClilocs = require('../../model/shardClilocs/shardClilocs.model')
|
||||
const { activity } = require('../../core')
|
||||
const gameSignup = require('../../utils/gameSignup')
|
||||
const { salesForAccounts } = require('../../utils/shardSales')
|
||||
|
||||
const log = require('../../core').logger('player-shard')
|
||||
|
||||
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
|
||||
// character leads and any city governorship on its account, plus resolved cliloc
|
||||
// names. Best-effort — a failure here never fails the profile (it's a nicety,
|
||||
// not the sheet).
|
||||
async function enrichCharProfile(profile) {
|
||||
if (!profile) return profile
|
||||
try {
|
||||
const guild = await shardState.findGuildForActor({ serial: profile.serial, acct: profile.acct })
|
||||
if (guild) profile.guild = guild
|
||||
if (profile.acct) {
|
||||
const govs = await shardState.listGovernorshipsForAccounts([profile.acct])
|
||||
if (govs.length) profile.governorOf = govs.map((g) => g.city)
|
||||
}
|
||||
await resolveProfileClilocs(profile)
|
||||
} catch (err) {
|
||||
log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message })
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// POST /player/shard/link — confirm an in-game link code.
|
||||
async function link(req, res) {
|
||||
const { code } = req.body
|
||||
try {
|
||||
const result = await uoLinkClient.confirmLink(code, req.user.id)
|
||||
|
||||
if (result.ok && result.data && result.data.kind === 'link.ok') {
|
||||
const account = result.data.account
|
||||
await shardLinks.link({ account, userId: req.user.id, charName: result.data.char || null })
|
||||
await activity.log({ req, action: 'uoLink.account.link', detail: { account } })
|
||||
log.info('player linked game account', { user: req.user.username, account })
|
||||
return res.json({ linked: true, account })
|
||||
}
|
||||
|
||||
// Sidecar reports bad/expired codes as 400 link.error or 404.
|
||||
if (result.status === 400 || result.status === 404) {
|
||||
return res.status(400).json({ message: 'That code is unknown or has expired. Run [link in game for a new one.' })
|
||||
}
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not confirm the link with the shard.' })
|
||||
} catch (err) {
|
||||
log.error('player.shard.link', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /player/shard/accounts — the caller's linked game accounts.
|
||||
async function listAccounts(req, res) {
|
||||
try {
|
||||
return res.json(await shardLinks.listForUser(req.user.id))
|
||||
} catch (err) {
|
||||
log.error('player.shard.listAccounts', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Admins may view any character's data; everyone else is limited to accounts
|
||||
// they have personally linked. The same handlers back /player/shard (role
|
||||
// `player`, never admin) and /admin/shard (staff), so this bypass only ever
|
||||
// widens access for genuine admins.
|
||||
const isAdmin = (req) => req.user && req.user.role === 'admin'
|
||||
|
||||
// Shared ownership gate + live round-trip for roster/vendors. `fetcher` is the
|
||||
// uoLinkClient method to call with the account.
|
||||
async function ownedRoundTrip(req, res, fetcher, label) {
|
||||
const { account } = req.params
|
||||
try {
|
||||
const owns = isAdmin(req) || (await shardLinks.ownsAccount(account, req.user.id))
|
||||
if (!owns) return res.status(403).json({ message: 'That account is not linked to your profile.' })
|
||||
|
||||
const result = await fetcher(account)
|
||||
if (result.ok) return res.json(result.data)
|
||||
if (result.status === 404) return res.status(404).json({ message: 'Not found.' })
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The shard is unavailable right now — try again shortly.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||
} catch (err) {
|
||||
log.error(`player.shard.${label}`, err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /player/shard/roster/:account — characters on a linked account.
|
||||
const roster = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getRoster, 'roster')
|
||||
|
||||
// GET /player/shard/vendors/:account — player vendors on a linked account.
|
||||
const vendors = (req, res) => ownedRoundTrip(req, res, uoLinkClient.getVendors, 'vendors')
|
||||
|
||||
// GET /player/shard/char/:serial — a character sheet, but ONLY if the character's
|
||||
// account is linked to the caller. The sidecar returns the owning account in the
|
||||
// profile, which we check against the caller's links before returning anything.
|
||||
async function getChar(req, res) {
|
||||
const { serial } = req.params
|
||||
if (!SERIAL_RE.test(serial)) return res.status(400).json({ message: 'Invalid serial.' })
|
||||
try {
|
||||
const result = await uoLinkClient.getCharBySerial(serial)
|
||||
if (result.ok) {
|
||||
// Admins see any character; others only characters on an account they linked.
|
||||
if (!isAdmin(req)) {
|
||||
const acct = result.data && result.data.acct
|
||||
const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false
|
||||
if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' })
|
||||
}
|
||||
return res.json(await enrichCharProfile(result.data))
|
||||
}
|
||||
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
||||
if (result.status === 503 || result.status === 0) {
|
||||
return res.status(503).json({ message: 'The game server is restarting — try again shortly.' })
|
||||
}
|
||||
return res.status(502).json({ message: 'Could not reach the shard.' })
|
||||
} catch (err) {
|
||||
log.error('player.shard.getChar', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /player/shard/sales — recent player-vendor sales for the caller's linked
|
||||
// accounts only (as seller/owner). Read from the site's own event log.
|
||||
async function getSales(req, res) {
|
||||
try {
|
||||
const links = await shardLinks.listForUser(req.user.id)
|
||||
const accounts = links.map((l) => l.account)
|
||||
return res.json(await salesForAccounts(accounts))
|
||||
} catch (err) {
|
||||
log.error('player.shard.getSales', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /player/shard/houses — the caller's OWN houses (home status), scoped to
|
||||
// their linked accounts. A player sees their own decay/IDOC standing; never
|
||||
// anyone else's. Full detail is fine here — it's their property.
|
||||
async function getHouses(req, res) {
|
||||
try {
|
||||
const links = await shardLinks.listForUser(req.user.id)
|
||||
const accounts = links.map((l) => l.account)
|
||||
return res.json(await shardState.listHousesForAccounts(accounts))
|
||||
} catch (err) {
|
||||
log.error('player.shard.getHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response.
|
||||
// The password is never echoed anywhere; only the mapped reason is returned.
|
||||
function mapCreateAccountError(res, result) {
|
||||
const reason = (result.data && result.data.reason) || ''
|
||||
switch (result.status) {
|
||||
case 409:
|
||||
return res.status(409).json({ message: 'That account name is already taken.' })
|
||||
case 429:
|
||||
return res.status(429).json({ message: 'The account limit for your network has been reached.' })
|
||||
case 403:
|
||||
return res.status(403).json({ message: 'Game-account signups are not available on this shard right now.' })
|
||||
case 400:
|
||||
return res.status(400).json({ message: reason || 'The account name or password was not accepted.' })
|
||||
case 503:
|
||||
case 0:
|
||||
return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' })
|
||||
default:
|
||||
return res.status(502).json({ message: 'Could not reach the shard to create the account.' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /player/shard/account — provision a GAME account for the signed-in website
|
||||
// user and auto-link it (Protocol 2.0 hybrid). Used by self-serve signup and the
|
||||
// invite-accept "create game account" step alike (both act as the signed-in user).
|
||||
// actor + websiteUserId are stamped from the session; the browser IP (req.ip,
|
||||
// trust-proxy configured) is forwarded for the shard's per-IP cap; the password is
|
||||
// never logged. Gated by the game_account_signup setting AND the shard's own mode.
|
||||
async function createGameAccount(req, res) {
|
||||
const { account, password } = req.body
|
||||
try {
|
||||
if (!(await gameSignup.isEnabled())) {
|
||||
return res.status(403).json({ message: 'Game-account signup is not available right now.' })
|
||||
}
|
||||
const result = await uoLinkClient.createAccount({
|
||||
actor: req.user.username,
|
||||
account,
|
||||
password,
|
||||
websiteUserId: req.user.id,
|
||||
ip: req.ip,
|
||||
})
|
||||
if (result.ok) {
|
||||
// Mirror the link locally so the portal lists the account immediately.
|
||||
await shardLinks.link({ account, userId: req.user.id })
|
||||
await activity.log({ req, userId: req.user.id, action: 'shard.account.create', detail: { account } })
|
||||
log.info('game account created', { account, userId: req.user.id, ip: req.ip })
|
||||
return res.status(201).json({ account, linked: true })
|
||||
}
|
||||
return mapCreateAccountError(res, result)
|
||||
} catch (err) {
|
||||
log.error('player.shard.createGameAccount', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { link, listAccounts, roster, vendors, getChar, getSales, getHouses, createGameAccount }
|
||||
125
server/router/player/shard.router.js
Normal file
125
server/router/player/shard.router.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// Player · Shard — game-account linking and the caller's own roster / vendors /
|
||||
// characters / sales / houses, ownership-checked against the local link mirror.
|
||||
//
|
||||
// Mounted at /api/v1/player/shard by player/index.js, which already applied
|
||||
// `noindex, requireAuth`. No extra gate: every handler is self-scoped to
|
||||
// req.user.id.
|
||||
//
|
||||
// These are the *same* handlers (player/shard.controller) that admin/shard.router.js
|
||||
// serves under /admin/shard for the seven self-service routes — staff are a
|
||||
// superset of players, and the controller keys off req.user.id either way. Two
|
||||
// URL surfaces, one implementation.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const { body, param } = core.validator
|
||||
|
||||
const shard = require('./shard.controller')
|
||||
const { validate, accountChangeLimiter } = core.middleware
|
||||
|
||||
const shardRouter = express.Router()
|
||||
|
||||
// Link an in-game account with a one-time code from [link, then read the
|
||||
// account's roster / vendors (ownership-checked against the local link mirror).
|
||||
const ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||
|
||||
shardRouter.post(
|
||||
'/link',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Link an in-game account with a one-time code'
|
||||
// #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkResult" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||
validate,
|
||||
shard.link,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/account',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller'
|
||||
// #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Per-IP account cap reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
accountChangeLimiter,
|
||||
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
|
||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||
validate,
|
||||
shard.createGameAccount,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/accounts',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'List the caller’s linked game accounts'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
|
||||
shard.listAccounts,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/roster/:account',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Character roster for a linked account'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('account').matches(ACCOUNT_RE),
|
||||
validate,
|
||||
shard.roster,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/vendors/:account',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Player vendors for a linked account'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('account').matches(ACCOUNT_RE),
|
||||
validate,
|
||||
shard.vendors,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/char/:serial',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Character sheet — only for a character on the caller’s linked account'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['serial'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Mobile serial, e.g. 0x24C.' }
|
||||
/* #swagger.responses[200] = { description: 'Character profile', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Character not on an account linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('serial').matches(/^0x[0-9a-fA-F]+$/),
|
||||
validate,
|
||||
shard.getChar,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/sales',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
|
||||
shard.getSales,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/houses',
|
||||
// #swagger.tags = ['Player · Shard']
|
||||
// #swagger.summary = 'The caller’s own houses (home status)'
|
||||
// #swagger.description = 'Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
|
||||
shard.getHouses,
|
||||
)
|
||||
|
||||
module.exports = shardRouter
|
||||
134
server/router/public/atlas.controller.js
Normal file
134
server/router/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('../../core').logger('public-atlas')
|
||||
|
||||
const FEATURE = 'atlas'
|
||||
|
||||
// Query params arrive as strings; express-validator has already bounded them.
|
||||
const int = (value, fallback) => {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
const str = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
// GET /public/atlas/creatures?q=&facet=&limit=&offset=
|
||||
async function getCreatures(req, res) {
|
||||
try {
|
||||
const page = await atlas.searchCreatures({
|
||||
q: str(req.query.q),
|
||||
facet: str(req.query.facet),
|
||||
limit: int(req.query.limit, 50),
|
||||
offset: int(req.query.offset, 0),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, page, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getCreatures', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/creatures/:slug — one creature, with the places it spawns.
|
||||
//
|
||||
// 404 means "no such creature in this atlas", which also covers "the atlas has
|
||||
// never been imported" — an empty atlas has no slugs, and there is nothing more
|
||||
// specific to say to an anonymous caller.
|
||||
async function getCreature(req, res) {
|
||||
try {
|
||||
const creature = await atlas.getCreature(req.params.slug, {
|
||||
facet: str(req.query.facet),
|
||||
points: int(req.query.points, 200),
|
||||
})
|
||||
if (!creature) return res.status(404).json({ message: 'Not Found' })
|
||||
return res.json(await visibility.project(FEATURE, creature, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getCreature', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/regions?facet=&q=
|
||||
async function getRegions(req, res) {
|
||||
try {
|
||||
const regions = await atlas.listRegions({
|
||||
facet: str(req.query.facet),
|
||||
q: str(req.query.q),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, regions, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getRegions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/landmarks?facet=&q=
|
||||
async function getLandmarks(req, res) {
|
||||
try {
|
||||
const landmarks = await atlas.listLandmarks({
|
||||
facet: str(req.query.facet),
|
||||
q: str(req.query.q),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, landmarks, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getLandmarks', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/champions?facet= — the CONFIGURED altar roster.
|
||||
async function getChampions(req, res) {
|
||||
try {
|
||||
const champions = await atlas.listChampions({ facet: str(req.query.facet) })
|
||||
return res.json(await visibility.project(FEATURE, champions, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getChampions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/meta — what is loaded: facets, counts, when it was imported.
|
||||
//
|
||||
// Public-safe by construction: the model omits the ServUO path, the per-file
|
||||
// hashes and the pending-refresh state, all of which describe the operator's
|
||||
// filesystem rather than the game world. The admin status route carries those.
|
||||
async function getMeta(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project(FEATURE, await atlas.publicMeta(), req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getMeta', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCreatures,
|
||||
getCreature,
|
||||
getRegions,
|
||||
getLandmarks,
|
||||
getChampions,
|
||||
getMeta,
|
||||
}
|
||||
129
server/router/public/atlas.router.js
Normal file
129
server/router/public/atlas.router.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// 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 core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const { param, query } = core.validator
|
||||
|
||||
const atlas = require('./atlas.controller')
|
||||
const { siteMode, validate } = core.middleware
|
||||
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/UoAtlasCreaturePage" } } } } */
|
||||
/* #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/UoAtlasCreature" } } } } */
|
||||
/* #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/UoAtlasRegion" } } } } } */
|
||||
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/UoAtlasLandmark" } } } } } */
|
||||
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/UoAtlasChampion" } } } } } */
|
||||
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/UoAtlasMeta" } } } } */
|
||||
siteMode,
|
||||
atlas.getMeta,
|
||||
)
|
||||
|
||||
module.exports = atlasRouter
|
||||
436
server/router/public/shard.controller.js
Normal file
436
server/router/public/shard.controller.js
Normal file
@@ -0,0 +1,436 @@
|
||||
// ── Public: shard live data ────────────────────────────────────────────────
|
||||
//
|
||||
// Same-origin, token-free read endpoints backed by the data the WS ingest
|
||||
// pipeline persists (shard_online / shard_events / shard_economy / shard_houses)
|
||||
// plus a live character round-trip to the sidecar. The browser never sees the
|
||||
// sidecar URL or token — every sidecar call is server-side (uoLinkClient).
|
||||
//
|
||||
// The stored-data endpoints are cheap DB reads. The live /char endpoint hits the
|
||||
// running shard, so it is briefly cached and degrades gracefully: a 503 (shard
|
||||
// restarting) surfaces as a retry-able banner rather than an error.
|
||||
|
||||
const shardEvents = require('../../model/shardEvents/shardEvents.model')
|
||||
const shardState = require('../../model/shardState/shardState.model')
|
||||
const shardMarket = require('../../model/shardMarket/shardMarket.model')
|
||||
const uoLinkConfig = require('../../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const broadcast = require('../../utils/shardBroadcast')
|
||||
const visibility = require('../../utils/shardVisibility')
|
||||
const gameSignup = require('../../utils/gameSignup')
|
||||
|
||||
const log = require('../../core').logger('public-shard')
|
||||
|
||||
// GET /public/shard/status — connection state + online count + latest economy.
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
const [online, economy] = await Promise.all([
|
||||
shardState.onlineCount(),
|
||||
shardState.latestEconomy(),
|
||||
])
|
||||
return res.json({
|
||||
enabled: config.enabled,
|
||||
status: config.status,
|
||||
pluginConnected: config.pluginConnected,
|
||||
lastEventAt: config.lastEventAt,
|
||||
onlineCount: online,
|
||||
economy,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('shard.getStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/feed?kind=&limit= — recent notable events from the log.
|
||||
//
|
||||
// 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) {
|
||||
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
|
||||
// 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
|
||||
if (kind) {
|
||||
if (!allowed.has(kind)) return res.json([])
|
||||
events = await shardEvents.list({ kind, limit })
|
||||
} else {
|
||||
events = await shardEvents.list({ kinds: [...allowed], limit })
|
||||
}
|
||||
|
||||
return res.json(
|
||||
events.map((ev) => ({
|
||||
...ev,
|
||||
payload: visibility.projectFeature(
|
||||
visibility.KIND_FEATURE.get(ev.kind),
|
||||
ev.payload,
|
||||
level,
|
||||
config,
|
||||
),
|
||||
})),
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('shard.getFeed', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/economy — gold-supply series, oldest → newest.
|
||||
async function getEconomy(req, res) {
|
||||
try {
|
||||
return res.json(await shardState.listEconomy(req.query.limit))
|
||||
} catch (err) {
|
||||
log.error('shard.getEconomy', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// is online (name + serial); their in-game location (map + coordinates) is gated
|
||||
// on the `presence` feature's `location` field rule, which defaults to `staff`
|
||||
// — the same admin/moderator set this used to hardcode. Non-staff players are
|
||||
// never listed.
|
||||
async function canSeeStaffLocation(req) {
|
||||
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) {
|
||||
try {
|
||||
const rows = await shardState.listOnlineLinked()
|
||||
const showLocation = await canSeeStaffLocation(req)
|
||||
return res.json(
|
||||
rows.map((r) => {
|
||||
const entry = { serial: r.serial, name: r.name }
|
||||
if (showLocation) {
|
||||
entry.map = r.map
|
||||
entry.x = r.x
|
||||
entry.y = r.y
|
||||
entry.z = r.z
|
||||
}
|
||||
return entry
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('shard.getOnline', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
return res.json(await visibility.project('houses', await shardState.listIdoc(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getIdoc', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/champs — the current champion-spawn board (all categories).
|
||||
// Served from our own store; live deltas (champ.update / champ.remove) arrive on
|
||||
// the public SSE stream so the page can update in place.
|
||||
async function getChamps(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project('champs', await shardState.listChamps(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getChamps', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
try {
|
||||
return res.json(await visibility.project('guilds', await shardState.listGuilds(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getGuilds', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/governors — the current town-governor board (empty on shards
|
||||
// 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) {
|
||||
try {
|
||||
return res.json(await visibility.project('governors', await shardState.listGovernors(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getGovernors', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/governors/:city/history — the term ledger for one city
|
||||
// (look-back: "who were all the governors of Britain?"), newest first.
|
||||
async function getGovernorHistory(req, res) {
|
||||
try {
|
||||
const terms = await shardState.listGovernorHistory(req.params.city, req.query.limit)
|
||||
return res.json(await visibility.project('governors', terms, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getGovernorHistory', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/presence — the online-population aggregate (count + per-facet
|
||||
// + per-region). Live via presence.online on the public SSE stream.
|
||||
async function getPresence(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project('presence', await shardState.latestPresence(), req))
|
||||
} catch (err) {
|
||||
log.error('shard.getPresence', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/houses — PUBLIC view: only houses in danger (IDOC), and only
|
||||
// their location (name + region + map/coords). Owner, price, co-owners and decay
|
||||
// detail are staff-only (see admin GET /admin/shard/houses). Live via house.decay
|
||||
// on the public SSE stream. This is the "where are the falling houses" board.
|
||||
async function getHouses(req, res) {
|
||||
try {
|
||||
const idoc = await shardState.listIdoc()
|
||||
const publicHouses = idoc.map((h) => ({
|
||||
serial: h.serial,
|
||||
name: h.name,
|
||||
region: h.region,
|
||||
map: h.map,
|
||||
x: h.x,
|
||||
y: h.y,
|
||||
z: h.z,
|
||||
isIdoc: true,
|
||||
}))
|
||||
// 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) {
|
||||
log.error('shard.getHouses', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
// Whether this site offers game-account creation. Not a visibility flag
|
||||
// and deliberately carried here anyway: it is the same per-viewer,
|
||||
// once-a-session answer, and the alternative is a second endpoint and a
|
||||
// second round-trip for one boolean. It is NOT audience-gated — it says
|
||||
// what the site offers, not what this caller may see, and the portal's
|
||||
// create-account form is behind a session either way.
|
||||
//
|
||||
// Core's public settings carried this until slice 3. It is ours now
|
||||
// (utils/gameSignup.js), because the setting is about a game server.
|
||||
gameAccountSignup: await gameSignup.isEnabled(),
|
||||
})
|
||||
} 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) {
|
||||
return broadcast.subscribe(req, res, 'public')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStatus,
|
||||
getFeed,
|
||||
getEconomy,
|
||||
getOnline,
|
||||
getIdoc,
|
||||
getChamps,
|
||||
getGuilds,
|
||||
getGovernors,
|
||||
getGovernorHistory,
|
||||
getPresence,
|
||||
getHouses,
|
||||
getRuleset,
|
||||
getPointsBoards,
|
||||
getPointsBoard,
|
||||
getMarket,
|
||||
getMarketMeta,
|
||||
getMarketVendor,
|
||||
getFeatures,
|
||||
stream,
|
||||
}
|
||||
255
server/router/public/shard.router.js
Normal file
255
server/router/public/shard.router.js
Normal file
@@ -0,0 +1,255 @@
|
||||
// Public · Shard — token-free, same-origin reads of the live shard. The
|
||||
// status/feed/economy/idoc/champs/guilds/governors/presence/houses endpoints read
|
||||
// the site's own ingested data; nothing here round-trips the sidecar per request.
|
||||
//
|
||||
// Mounted at /api/v1/public/shard by public/index.js. Deliberately NOT site-mode
|
||||
// gated — shard status is useful (and wanted) while the site itself is in
|
||||
// maintenance.
|
||||
//
|
||||
// **GET /shard/stream stays anonymous.** It is consumed by logged-out browser
|
||||
// visitors *and* by the Android ShardStreamClient, neither of which sends an
|
||||
// Authorization header; adding requireAuth here blacks out the public live boards
|
||||
// on web and mobile. The sensitive kinds (staff audit, cheat detection, login
|
||||
// attempts, IPs) are withheld by utils/shardBroadcast.js, not by a route gate —
|
||||
// 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 core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const { param, query } = core.validator
|
||||
|
||||
const shard = require('./shard.controller')
|
||||
const { validate } = core.middleware
|
||||
const { marketLimiter } = require('../rateLimits')
|
||||
const { requireFeature } = require('../../utils/shardVisibility')
|
||||
|
||||
const shardRouter = express.Router()
|
||||
|
||||
shardRouter.get(
|
||||
'/status',
|
||||
requireFeature('status'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Shard connection state, online count and latest economy'
|
||||
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardStatus" } } } } */
|
||||
shard.getStatus,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/feed',
|
||||
requireFeature('activity'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Recent notable shard events (from the ingested log)'
|
||||
// #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.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEvent" } } } } } */
|
||||
query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||
validate,
|
||||
shard.getFeed,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/economy',
|
||||
requireFeature('status'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEconomyPoint" } } } } } */
|
||||
query('limit').optional().isInt({ min: 1, max: 1000 }),
|
||||
validate,
|
||||
shard.getEconomy,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/online',
|
||||
requireFeature('presence'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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/UoShardOnlinePlayer" } } } } } */
|
||||
shard.getOnline,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/idoc',
|
||||
requireFeature('houses'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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/UoShardHouse" } } } } } */
|
||||
shard.getIdoc,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/champs',
|
||||
requireFeature('champs'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getChamps,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/guilds',
|
||||
requireFeature('guilds'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getGuilds,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/governors',
|
||||
requireFeature('governors'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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.responses[200] = { description: 'Cities, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getGovernors,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/governors/:city/history',
|
||||
requireFeature('governors'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max terms (default 100, max 500).' }
|
||||
/* #swagger.responses[200] = { description: 'Terms, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
param('city').isString().isLength({ min: 1, max: 40 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 500 }),
|
||||
validate,
|
||||
shard.getGovernorHistory,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/presence',
|
||||
requireFeature('presence'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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.responses[200] = { description: 'Population snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
shard.getPresence,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/houses',
|
||||
requireFeature('houses'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #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.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
|
||||
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/UoShardPointsBoard" } } } } } */
|
||||
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/UoShardPointsBoard" } } } } */
|
||||
/* #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 — the more-like-this filter.' }
|
||||
// #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/UoShardMarketPage" } } } } */
|
||||
/* #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/UoShardMarketMeta" } } } } */
|
||||
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/UoShardMarketVendor" } } } } */
|
||||
/* #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/UoShardFeatures" } } } } */
|
||||
shard.getFeatures,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/stream',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Live shard event stream (Server-Sent Events, filtered by audience)'
|
||||
// #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).' } */
|
||||
shard.stream,
|
||||
)
|
||||
|
||||
module.exports = shardRouter
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user