Compare commits
85 Commits
e468bbd3b9
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6246383962 | |||
| 76c224fff1 | |||
| f9bbc7a90d | |||
| e7b3412b36 | |||
| 3c087a43cd | |||
| 5c35b4fe97 | |||
| 5705aa9c23 | |||
| 675e879b48 | |||
| 7dbdaa14ad | |||
| d4d5989926 | |||
| 679762b643 | |||
| c6c51b190d | |||
| 53b3aca0c1 | |||
| f335531538 | |||
| 6ece48f7d3 | |||
| a194ec68e0 | |||
| 55df03496d | |||
| 893a36618b | |||
| 50f84b5ea3 | |||
| d6346996d3 | |||
| bbaf08f67c | |||
| ea63ad019c | |||
| c73d62e93a | |||
| 8def6e19f4 | |||
| c289586a3d | |||
| 10fde87724 | |||
| 0b142adb81 | |||
| 89be9d6a4e | |||
| c11c130438 | |||
| 88bfe9310e | |||
| bf9a702cfa | |||
| dc13515927 | |||
| cf60932c85 | |||
| 021f191f65 | |||
| 57419111e6 | |||
| 144242fe8f | |||
| c679944181 | |||
| 1590b52bc8 | |||
| 3a81766526 | |||
| 3139cb4364 | |||
| 17a96ed4c4 | |||
| 849d4b10e8 | |||
| 52d9c3ddb8 | |||
| 50a89b48e2 | |||
| 1a866112e4 | |||
| 419dee3e49 | |||
| 75f9b27687 | |||
| 6a276a7ec3 | |||
| 1b6d92a5ba | |||
| 7f7d4578ce | |||
| 6fca1cebf4 | |||
| 9b0ae19855 | |||
| 956e3fb0b4 | |||
| 3c179e3338 | |||
| 16cfbe194d | |||
| 8ec21086b5 | |||
| 637121bce3 | |||
| fe176920c5 | |||
| d98f0c1a3d | |||
| 1a13f680f5 | |||
| 7d0378842b | |||
| 466842c6f2 | |||
| 2d1d91e372 | |||
| 990a50b491 | |||
| c57310c505 | |||
| 7ce78e303c | |||
| 46e3f5a127 | |||
| 9d0a197008 | |||
| eb30e4ae37 | |||
| dda0e32dd3 | |||
| d4aa5ade12 | |||
| 0d618599cf | |||
| 76b2321f25 | |||
| 99d1ca25a7 | |||
| c6929c6bae | |||
| 51e58104bf | |||
| 268449f2a6 | |||
| e93361aa48 | |||
| 2fa4d87a40 | |||
| 97e2fddfcd | |||
| 62c8ee68b4 | |||
| e81b61d044 | |||
| a0c24456c7 | |||
| 044211fd41 | |||
| 5cdcf0fbb6 |
@@ -16,6 +16,17 @@
|
||||
# tree works right up until core moves a file, and the whole boundary is
|
||||
# worth exactly as much as this check is (§5.1).
|
||||
#
|
||||
# • `server: check:bundle` — the release ships everything the entry point can
|
||||
# reach. Every other job here runs against the whole repo, but a release is a
|
||||
# SUBSET of it (release.yml assembles from the include list in
|
||||
# `ci/bundle.json`), and nothing compared the two. On 2026-08-19 they
|
||||
# disagreed: `server/commands/` arrived with the Teams cutover, the include
|
||||
# list did not learn about it, and v1.0.0 installed and then died at the
|
||||
# register stage on the operator's box with "Cannot find module
|
||||
# './commands/guild.command'". Green here, broken there — because the subset
|
||||
# only exists in the release. This asks, on the PR that adds the directory,
|
||||
# whether the list still covers what index.js reaches.
|
||||
#
|
||||
# • `client: check:externals` — the BUILT chunk has no bare imports left. That
|
||||
# failure is invisible in source: `import { useState } from 'react'` is
|
||||
# correct in every file, and whether it becomes core's React or a bare
|
||||
@@ -27,11 +38,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`)
|
||||
@@ -43,18 +72,34 @@
|
||||
#
|
||||
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
|
||||
# Node — no Docker socket, no database.
|
||||
#
|
||||
# Scope note: `edge` is gated as well as `main`. Multi-phase work lands there
|
||||
# first, so gating only the `main` hop would run these checks for the first time
|
||||
# at the cutover — the one moment a red build is most expensive to discover. This
|
||||
# is the same call `RunicGateway/installer` made for the same reason, and it was
|
||||
# taken here after a nine-PR Android workstream landed on an ungated `edge` with
|
||||
# no CI at all. Adding a branch to the `branches:` list is the whole change.
|
||||
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, edge]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
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 +124,12 @@ jobs:
|
||||
- name: Check the module boundary (MODULE_API.md §5.1)
|
||||
run: npm run check:imports --prefix server
|
||||
|
||||
- name: Check the release ships what the module requires
|
||||
run: npm run check:bundle --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
|
||||
@@ -107,3 +158,70 @@ jobs:
|
||||
|
||||
- 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
|
||||
|
||||
444
.gitea/workflows/release.yml
Normal file
444
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,444 @@
|
||||
# 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 DERIVED, and the declaration is a floor ──────────────────
|
||||
#
|
||||
# This file used to release only when a merge to `main` left `module.json` at a
|
||||
# version with no release yet — the version DECLARED, never computed, on the
|
||||
# argument that two sources for one number is how they drift. That was true and
|
||||
# it was still the wrong trade: it makes every bundle cost a second reviewed PR
|
||||
# whose entire content is a number, and between 2026-08-12 and 2026-08-19 it cost
|
||||
# this repo *every* bundle — v0.3.0 was the only release while nine phases of
|
||||
# Teams work landed, because nothing in them touched that line.
|
||||
#
|
||||
# So the engine `link` and `installer` already run is adopted here (MODULE_SYSTEM
|
||||
# §2.7.1, decision 19 as amended):
|
||||
#
|
||||
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
|
||||
# nothing releasable -> no release is cut
|
||||
# (first ever run, no tag) -> releases what module.json declares
|
||||
#
|
||||
# **The declared version is kept as a floor, not deleted.** If `module.json` names
|
||||
# a version above the newest tag, that version releases — which is the old model
|
||||
# exactly, surviving as the special case it always was. Raising it by hand is
|
||||
# still how you say "this one is a minor, whatever the subjects imply", and it is
|
||||
# still the natural place to move when a `coreApi` bump forces the question. What
|
||||
# no longer happens is a merge full of `feat:` producing nothing.
|
||||
#
|
||||
# The number that ships is therefore the TAG, and CI writes it into the
|
||||
# `module.json` inside the bundle at assembly time. The committed `module.json` is
|
||||
# a floor and a starting point, not a record of the last release — `link` reached
|
||||
# the same arrangement with `Cargo.toml`, for the same reason: a release engine
|
||||
# that has to commit a bump back to `main` stops working the day someone protects
|
||||
# the branch, and this one is protected.
|
||||
#
|
||||
# ── The backdoor ────────────────────────────────────────────────────────────
|
||||
#
|
||||
# `workflow_dispatch` publishes on demand, for the case the rules above cannot
|
||||
# reach: `module.json` changed in a way worth shipping — a widened `coreApi`, a
|
||||
# new mount, a capability — with no releasable code behind it. Leave `version`
|
||||
# blank to bump the newest tag by `bump` (default `patch`), or name an exact
|
||||
# version to publish that. A dispatch releases even when nothing in the log is
|
||||
# releasable; that is the entire point of pressing the button.
|
||||
#
|
||||
# Re-running on a version that is already released is a no-op, so a rerun after an
|
||||
# unrelated failure is safe. A tag that exists with no release behind it is NOT a
|
||||
# no-op — see the recovery branch in the plan step.
|
||||
#
|
||||
# This workflow still never writes to a branch. It tags and publishes, so `main`
|
||||
# needs no push exception.
|
||||
#
|
||||
# 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]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Exact version to publish (e.g. 0.4.1). Blank = bump the newest tag by the level below.'
|
||||
required: false
|
||||
default: ''
|
||||
bump:
|
||||
description: 'Bump level when version is blank: patch | minor | major'
|
||||
required: false
|
||||
default: 'patch'
|
||||
|
||||
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:
|
||||
# Full history: the plan step reads every tag and every subject since the
|
||||
# newest one, and a shallow clone has neither.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Plan the release (version + changelog)
|
||||
id: plan
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
IN_VERSION: ${{ github.event.inputs.version }}
|
||||
IN_BUMP: ${{ github.event.inputs.bump }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
DECLARED="$(node -p "require('./module.json').version")"
|
||||
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||
CURRENT="${LAST_TAG#v}"
|
||||
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
|
||||
echo "module.json declares ${DECLARED}; newest tag is ${LAST_TAG:-<none>}"
|
||||
|
||||
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
|
||||
|
||||
BUMP=none
|
||||
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
|
||||
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
|
||||
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
|
||||
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:' ; then BUMP=patch; fi
|
||||
|
||||
bump() { # <x.y.z> <major|minor|patch> -> bumped
|
||||
IFS=. read -r MA MI PA <<< "$1"
|
||||
case "$2" in
|
||||
major) echo "$((MA+1)).0.0" ;;
|
||||
minor) echo "${MA}.$((MI+1)).0" ;;
|
||||
patch) echo "${MA}.${MI}.$((PA+1))" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# `sort -V` orders version strings, so the higher of two is its last
|
||||
# line. Used rather than a hand-rolled field compare because 0.10.0 vs
|
||||
# 0.9.0 is exactly the comparison a string sort gets wrong.
|
||||
higher() { printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1; }
|
||||
|
||||
rank() { case "$1" in major) echo 3 ;; minor) echo 2 ;; patch) echo 1 ;; *) echo 0 ;; esac; }
|
||||
bigger_bump() { if [ "$(rank "$1")" -ge "$(rank "$2")" ]; then echo "$1"; else echo "$2"; fi; }
|
||||
|
||||
VERSION=""
|
||||
if [ -n "${IN_VERSION:-}" ]; then
|
||||
# The backdoor's exact form. Deliberately unvalidated against the log:
|
||||
# a human typed it, and the already-released check below is the only
|
||||
# guard that matters.
|
||||
VERSION="${IN_VERSION}"
|
||||
echo "dispatch: publishing the requested version ${VERSION}"
|
||||
else
|
||||
LEVEL="$BUMP"
|
||||
# A dispatch with nothing releasable in the log still releases — that
|
||||
# is what the button is for. Where the log DOES say something, the
|
||||
# larger of the two wins rather than the input: pressing the button on
|
||||
# a log full of `feat:` without touching the dropdown would otherwise
|
||||
# publish its `patch` default over a minor's worth of work, and a
|
||||
# version that undersells its own contents cannot be taken back.
|
||||
if [ "${EVENT:-}" = workflow_dispatch ]; then
|
||||
LEVEL="$(bigger_bump "$LEVEL" "${IN_BUMP:-patch}")"
|
||||
if [ "$BUMP" = none ]; then
|
||||
echo "dispatch: nothing releasable in the log, bumping ${LEVEL} anyway"
|
||||
elif [ "$LEVEL" != "$BUMP" ]; then
|
||||
echo "dispatch: the log says ${BUMP}, the run asked for ${LEVEL} — taking ${LEVEL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$CURRENT" ]; then
|
||||
VERSION="$DECLARED" # first ever release: ship what is declared
|
||||
elif [ "$LEVEL" != none ]; then
|
||||
VERSION="$(bump "$CURRENT" "$LEVEL")"
|
||||
fi
|
||||
|
||||
# The floor. A `module.json` above the newest tag releases at that
|
||||
# version even when the log says nothing and even when the log says
|
||||
# patch — which is the pre-2026-08-19 model, kept as a special case.
|
||||
if [ -n "$CURRENT" ] && [ "$DECLARED" != "$CURRENT" ] \
|
||||
&& [ "$(higher "$DECLARED" "$CURRENT")" = "$DECLARED" ]; then
|
||||
if [ -z "$VERSION" ] || [ "$(higher "$DECLARED" "$VERSION")" = "$DECLARED" ]; then
|
||||
echo "module.json declares ${DECLARED}, above both ${CURRENT} and the derived version — releasing that."
|
||||
VERSION="$DECLARED"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
RELEASE=true
|
||||
if [ -z "$VERSION" ]; then
|
||||
RELEASE=false
|
||||
VERSION="$CURRENT"
|
||||
echo "Nothing releasable since ${LAST_TAG} (no feat/fix/perf/breaking subject) — standing down."
|
||||
fi
|
||||
|
||||
# An existing tag is NOT automatically "nothing to do". A tag with no
|
||||
# release behind it means a previous run tagged and then died before
|
||||
# publishing — which is what happened on servuo-plugins' first release,
|
||||
# where absent secrets took the release API call to 401 after the tag
|
||||
# had already been pushed. Standing down on the tag alone makes that
|
||||
# state permanent. Note this deliberately OVERRIDES the RELEASE=false
|
||||
# above: with the tag in place there is nothing releasable after it, so
|
||||
# the normal path would stand down, which is why it could never
|
||||
# self-heal. Anything other than 200/404 — a network failure, a bad
|
||||
# token — is not evidence of absence, and guessing "no" would publish
|
||||
# over a good release, so refuse instead.
|
||||
REUSE_TAG=false
|
||||
if [ -n "$VERSION" ] && git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
|
||||
REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
|
||||
case "$REL_HTTP" in
|
||||
200) echo "v${VERSION} is already released — nothing to do."; RELEASE=false ;;
|
||||
404) echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing."
|
||||
REUSE_TAG=true; RELEASE=true ;;
|
||||
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${REL_HTTP}). Refusing to guess."; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Changelog range. A recovery run has nothing after the tag, so
|
||||
# summarize what the tag itself contains rather than emitting an empty
|
||||
# list: the range that produced it, i.e. previous-tag..this-tag.
|
||||
if [ "$REUSE_TAG" = true ]; then
|
||||
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)"
|
||||
CL_RANGE="${PREV_TAG:+${PREV_TAG}..}v${VERSION}"
|
||||
SINCE="$PREV_TAG"
|
||||
else
|
||||
CL_RANGE="$RANGE"
|
||||
SINCE="$LAST_TAG"
|
||||
fi
|
||||
CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)"
|
||||
|
||||
{
|
||||
echo "## module-uo v${VERSION}"
|
||||
echo
|
||||
echo "Install from the website's Admin → Modules screen by pasting the URL of"
|
||||
echo "\`module-uo-${VERSION}.json\`, or unpack the tarball onto the modules volume"
|
||||
echo "as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\` satisfies"
|
||||
echo "\`$(node -p "require('./module.json').coreApi")\`."
|
||||
echo
|
||||
FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)"
|
||||
FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
||||
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
||||
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
||||
echo "### All changes"
|
||||
if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi
|
||||
echo "$CL_SUBJECTS" | sed 's/^/- /'
|
||||
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
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||
echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} declared=${DECLARED} last_tag=${LAST_TAG:-<none>}"
|
||||
|
||||
# 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.
|
||||
#
|
||||
# The list itself lives in `ci/bundle.json`, not here, because it has a
|
||||
# second reader: `server/scripts/checkBundle.js` runs in PR checks and asks
|
||||
# whether the list still covers everything `server/index.js` reaches. It
|
||||
# was hardcoded in this file until v1.0.0 shipped without `server/commands/`
|
||||
# — added by the Teams cutover, never added here — and the module died at
|
||||
# the register stage on the operator's box. One declaration, two readers,
|
||||
# so the next directory cannot go missing quietly.
|
||||
- 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 "$OUT" && mkdir -p "$OUT"
|
||||
|
||||
# The manifest core reads — with the RELEASED version written into it.
|
||||
# The committed `module.json` is a floor, not a record of the last
|
||||
# release (see the header), so copying it verbatim would ship a bundle
|
||||
# whose `installed_modules` row and admin screen disagree with the tag
|
||||
# it came from. This is the one place the derived number becomes the
|
||||
# module's own.
|
||||
jq --arg v "$VERSION" '.version = $v' module.json > "$OUT/module.json"
|
||||
|
||||
# The two fragments, and the licence the code is under — a bundle that
|
||||
# ships GPL code without its licence is not distributable.
|
||||
for f in $(jq -r '.root[]' ci/bundle.json); do
|
||||
cp "$f" "$OUT/"
|
||||
done
|
||||
|
||||
# The server half, minus what never runs inside core's process.
|
||||
mkdir -p "$OUT/server"
|
||||
for d in $(jq -r '.server[]' ci/bundle.json); do
|
||||
cp -r "server/$d" "$OUT/server/"
|
||||
done
|
||||
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. The version assertion guards the
|
||||
# rewrite above — a bundle that still carries the declared version would
|
||||
# install under a number that is not the one it was released as.
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const [root, want] = process.argv.slice(1);
|
||||
const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8"));
|
||||
if (m.version !== want) {
|
||||
console.error(`bundle declares ${m.version}, but this is release ${want}`);
|
||||
process.exit(1);
|
||||
}
|
||||
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" "$VERSION"
|
||||
|
||||
# ── And that it can actually LOAD ─────────────────────────────────
|
||||
#
|
||||
# The check above stats the paths `module.json` declares, which is a
|
||||
# real question but a shallow one: v1.0.0 passed it and was still
|
||||
# missing `server/commands/`, because a file reached only by a require
|
||||
# inside `register()` is named nowhere in `module.json`. This resolves
|
||||
# every relative require in the assembled tree and asserts the target is
|
||||
# in it — asked of the artifact, so it also catches a copy that half
|
||||
# failed or a list naming a path that has since moved.
|
||||
#
|
||||
# Run from the SOURCE tree (`server/scripts/` never ships) against the
|
||||
# assembled bundle.
|
||||
node server/scripts/checkBundle.js --bundle "$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"
|
||||
|
||||
# Skipped on a recovery run: the tag is already there and is the thing being
|
||||
# published against.
|
||||
- name: Tag the release
|
||||
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != '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
|
||||
103
.gitea/workflows/sonarqube.yml
Normal file
103
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,103 @@
|
||||
# Run SonarQube static analysis against the code that just landed on `main` and
|
||||
# report the results to the self-hosted SonarQube server for review. This is
|
||||
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
|
||||
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
|
||||
# (which gates PRs) and release.yml (which publishes the bundle) — this one only
|
||||
# feeds the dashboard.
|
||||
#
|
||||
# Mirrors RunicGateway/website's sonarqube.yml, for the same reason pr-checks.yml
|
||||
# does: this module is two npm packages shaped like that repo's `server/` and
|
||||
# `client/`, and it is loaded into that repo's process. Until now it was the one
|
||||
# part of the platform that had never been scanned — 75 files that arrived in the
|
||||
# Phase 3 extraction with core's Sonar history left behind in core's project.
|
||||
#
|
||||
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
|
||||
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
|
||||
# My Account → Security in SonarQube for the
|
||||
# Module-uo project (or a global one).
|
||||
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
|
||||
# http://192.168.0.56:9000
|
||||
# (kept as a variable, not committed, so the internal address stays out of git.)
|
||||
#
|
||||
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
|
||||
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
|
||||
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
|
||||
# dashboard when you want to.
|
||||
|
||||
name: SonarQube
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Allow re-running the analysis on demand from the Actions tab.
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: sonarqube-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out (full history for accurate new-code + blame)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# SonarQube uses git history to attribute issues to authors and to
|
||||
# compute "new code". A shallow clone degrades both.
|
||||
fetch-depth: 0
|
||||
|
||||
# Node 22, where pr-checks.yml pins 20: the built-in `lcov` coverage
|
||||
# reporter this job depends on needs >= 22. The version that matters for
|
||||
# correctness is the one in pr-checks.yml, which matches the core process
|
||||
# this module is loaded into; nothing here ships.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install deps for both halves
|
||||
run: |
|
||||
npm ci --prefix server
|
||||
npm ci --prefix client
|
||||
|
||||
# The chunk has to exist before the client suite runs: build.test.js and
|
||||
# registration.test.js read `client/dist/entry.js`, and both SKIP when
|
||||
# there is no build. Run the other way round they skip silently and this
|
||||
# job reports coverage for a suite that quietly asked less than it looks
|
||||
# like it did — the same ordering pr-checks.yml calls load-bearing.
|
||||
- name: Build the client chunk
|
||||
run: npm run build --prefix client
|
||||
|
||||
# SonarQube runs static analysis only — it never executes the test suite,
|
||||
# so we must produce the coverage report ourselves and hand it to the
|
||||
# scanner (see sonar.javascript.lcov.reportPaths in sonar-project.properties).
|
||||
#
|
||||
# Both suites are invoked from the REPO ROOT rather than with `--prefix`,
|
||||
# so the LCOV `SF:` paths come out repo-root-relative (`server/router/...`,
|
||||
# `client/src/...`) and resolve against sonar.sources. That is also why the
|
||||
# server suite's `--require` is spelled out here instead of reusing
|
||||
# `npm test --prefix server`, whose path is relative to `server/`.
|
||||
- name: Generate server test coverage (LCOV)
|
||||
run: |
|
||||
mkdir -p server/coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--require ./server/test/_setup.js \
|
||||
--test-reporter=spec --test-reporter-destination=stdout \
|
||||
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
|
||||
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
|
||||
server/test/*.test.js
|
||||
|
||||
- name: Generate client test coverage (LCOV)
|
||||
run: |
|
||||
mkdir -p client/coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=spec --test-reporter-destination=stdout \
|
||||
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
|
||||
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
|
||||
client/test/*.test.js
|
||||
|
||||
- name: Run SonarQube scan
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
||||
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
|
||||
|
||||
@@ -96,6 +96,19 @@ when someone builds on the server is not shippable.
|
||||
branch and no cutover, unlike `website`, whose module work accumulates on `edge`
|
||||
and reaches `main` once.
|
||||
|
||||
### Static analysis runs after the merge, not on the PR
|
||||
|
||||
`.gitea/workflows/sonarqube.yml` scans `main` on push and reports to the
|
||||
self-hosted SonarQube instance under the project key **`Module-uo`**. It is
|
||||
deliberately non-blocking: it never gates a pull request, and a failing quality
|
||||
gate does not fail the job. Check the dashboard when you want to; the things
|
||||
that must not reach `main` are gated by `pr-checks.yml` instead.
|
||||
|
||||
It runs both suites from the repo root to produce coverage, and builds the
|
||||
client chunk first — two of the client tests read `dist/entry.js` and skip
|
||||
without it, which would leave this job reporting on a suite that quietly asked
|
||||
less than it appears to.
|
||||
|
||||
### Commit messages
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/) —
|
||||
|
||||
146
README.md
146
README.md
@@ -13,6 +13,7 @@ module that follows.
|
||||
│ module-uo (>>> HERE <<<) │
|
||||
│ shard status · spawn atlas · marketplace │
|
||||
│ governors · cliloc · town crier · uo-link│
|
||||
│ client files: portraits, item art, names │
|
||||
└───────────────────────────────────────────┘
|
||||
│ server half: routers, models, schema fragment
|
||||
│ client half: prebuilt ESM chunk, SPA routes + nav
|
||||
@@ -24,7 +25,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 +38,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 |
|
||||
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ |
|
||||
| 3 — extract the UO half of the site into this repo | `website`, here | ✅ done |
|
||||
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ✅ done |
|
||||
|
||||
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 +91,43 @@ 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, the engagement triggers and audiences
|
||||
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`.
|
||||
**What this module registers with core, beyond its routes.** Seven push streams, one announce leg
|
||||
(the in-game town crier), a Team provider (a UO guild is a Team), one slash command, and — since
|
||||
ENGAGEMENT.md Phase 11 — **24 engagement triggers and 3 audiences**. A trigger is a payload contract:
|
||||
what a rule may fire on, what a template may interpolate, and the widest audience an operator may ever
|
||||
give it. Core learns none of the vocabulary; it holds ids, labels and ceilings. Declaring a trigger
|
||||
sends nobody anything — every rule ships disabled. The catalogue, the four rows deliberately absent
|
||||
and the reasons are in [`docs/modules/uo/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/uo/API.md) §5.
|
||||
|
||||
**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 +141,54 @@ 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
|
||||
|
||||
**Every merge to `main` that carries a releasable commit publishes a bundle.** The next version is
|
||||
computed from conventional-commit subjects since the newest `v*` tag, as in `link` and `installer`:
|
||||
`feat!:` or `BREAKING CHANGE` is a major, `feat:` a minor, `fix:` or `perf:` a patch, and a `main`
|
||||
that gained none of those cuts no release. The number that ships is the **tag**, and CI writes it
|
||||
into the `module.json` inside the bundle.
|
||||
|
||||
`module.json`'s version survives as a **floor**: name a version there above the newest tag and that
|
||||
version is what releases, which is how you overrule the subjects — when a `coreApi` bump forces a
|
||||
minor, say. What no longer happens is a `main` full of `feat:` producing nothing because a separate
|
||||
PR to move one number had not been merged yet.
|
||||
|
||||
For a change with nothing releasable behind it — a widened `coreApi`, a new mount, a capability —
|
||||
run the **Release** workflow by hand (Actions → Release → Run workflow). Leave `version` blank to
|
||||
bump the newest tag by `bump` (default `patch`), or type an exact version to publish that.
|
||||
|
||||
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` | `8` | 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`.
|
||||
|
||||
43
ci/bundle.json
Normal file
43
ci/bundle.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$comment": [
|
||||
"What a release copies into the bundle, declared ONCE. Read by .gitea/workflows/release.yml",
|
||||
"when it assembles the tarball, and by server/scripts/checkBundle.js when CI asks whether",
|
||||
"that list still covers everything the module's entry point can reach.",
|
||||
"",
|
||||
"This is an INCLUDE list on purpose (release.yml's header argues the case): an exclude list",
|
||||
"ships whatever it forgot, so the day someone adds server/tools/ with a scratch credential",
|
||||
"in it, an exclude list packs it and nobody finds out. The cost of that choice is that a new",
|
||||
"top-level directory silently drops OUT of every release instead — which is exactly what",
|
||||
"happened to server/commands/ between v0.3.0 and v1.0.0, and is why checkBundle.js exists.",
|
||||
"",
|
||||
"server[] entries are paths under server/; root[] and generated[] are paths under the module",
|
||||
"root. node_modules is not listed: the release installs it with `npm ci --omit=dev` and copies",
|
||||
"it separately, so it is not a checked-in path.",
|
||||
"",
|
||||
"generated[] ships but is not copied — release.yml writes module.json through jq to stamp the",
|
||||
"released version into it, since the committed one is a floor rather than a record of the last",
|
||||
"release. It is listed because server/index.js requires it, and a check that did not know it",
|
||||
"ships would report the module's own manifest as missing from the bundle."
|
||||
],
|
||||
"server": [
|
||||
"boot.js",
|
||||
"commands",
|
||||
"config",
|
||||
"core.js",
|
||||
"data",
|
||||
"db",
|
||||
"index.js",
|
||||
"model",
|
||||
"package.json",
|
||||
"router",
|
||||
"utils"
|
||||
],
|
||||
"root": [
|
||||
"swagger-fragment.json",
|
||||
"LICENSE.md",
|
||||
"README.md"
|
||||
],
|
||||
"generated": [
|
||||
"module.json"
|
||||
]
|
||||
}
|
||||
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 a branch 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. **It pointed at `edge` for the length of the Event System window** (org lead, 2026-09-04), and this commit ends that: `api.registerEventActions` exists only from MODULE_API 1.10.0, so under the previous `main` pin `register()` threw and the module did not load at all — the job would have been red by construction for eight phases and would have proved nothing while a real regression hid behind it. The Phase 16b cutover put 1.10.0 on `main`, so the pin comes home, and this is the same move that turns the Integration kit green again. **routes.manifest.json needed NO regeneration**: the job's own steps were run against this exact ref and answered `routes.manifest.json is current — 73 routes, all documented`, so the \"commit both together\" instruction above had nothing to pair with this time.",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "655fbf3f69a6a1fd650ecbc81afd6cf9c2ad9f66",
|
||||
"refName": "main @ MODULE_API 1.10.0, the Event System cutover (website#199)"
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export const shard = {
|
||||
champs: () => req('/public/shard/champs'),
|
||||
// Protocol 2.0 boards.
|
||||
guilds: () => req('/public/shard/guilds'),
|
||||
guild: (id) => req(`/public/shard/guilds/${encodeURIComponent(id)}`),
|
||||
governors: () => req('/public/shard/governors'),
|
||||
governorHistory: (city, limit) =>
|
||||
req(`/public/shard/governors/${encodeURIComponent(city)}/history${withQs(limit ? `limit=${limit}` : '')}`),
|
||||
@@ -163,6 +164,38 @@ export const admin = {
|
||||
setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }),
|
||||
},
|
||||
|
||||
// The Asset Bridge (docs/link/v8.md §6, §14 — protocol 8 phase 8). Client
|
||||
// artwork and the cliloc table both come off the operator's own UO client, over
|
||||
// the same bridge, and boot deliberately never asks the shard for either — so
|
||||
// these calls are the only thing that imports them, and the panel that makes
|
||||
// them is where an operator goes after patching their client.
|
||||
//
|
||||
// `update` and `reimport` are §6's two stages rather than one call with a flag,
|
||||
// because they cost wildly different things: an Update that finds the client
|
||||
// files unchanged transfers nothing, and a re-import fetches every sprite in
|
||||
// the catalogue. A checkbox spells that difference the same size as the button.
|
||||
assets: {
|
||||
status: () => req('/admin/shard/assets'),
|
||||
update: (approve = false) =>
|
||||
req('/admin/shard/assets/import', { method: 'POST', body: { approve } }),
|
||||
reimport: (approve = false) =>
|
||||
req('/admin/shard/assets/import', { method: 'POST', body: { force: true, approve } }),
|
||||
// Item and land pictures, which arrive one at a time because a page asked for
|
||||
// one. The pass runs on its own timer; this is for the operator who has just
|
||||
// patched a client and would rather not wait for the interval.
|
||||
warm: (force = false) => req('/admin/shard/assets/warm', { method: 'POST', body: { force } }),
|
||||
},
|
||||
|
||||
clilocs: {
|
||||
status: () => req('/admin/shard/clilocs'),
|
||||
import: (opts = {}) =>
|
||||
req('/admin/shard/clilocs/import', {
|
||||
method: 'POST',
|
||||
body: { force: !!opts.force, approve: !!opts.approve },
|
||||
}),
|
||||
setPath: (path) => req('/admin/shard/clilocs/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: {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// player can reach is safe.
|
||||
|
||||
import ShardAccountActions from './ShardAccountActions.jsx'
|
||||
import ItemIcon from './ItemIcon'
|
||||
|
||||
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||
|
||||
@@ -270,7 +271,16 @@ export default function CharacterSheet({ char, moderation = false }) {
|
||||
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)' }} />
|
||||
{/* The sheet has always drawn an empty swatch here to hold the
|
||||
row's alignment. As of phase 5 the shard can hand over the
|
||||
item's real picture, hued the way the client would draw it —
|
||||
so the swatch becomes the fallback rather than the only
|
||||
state, and a row with no picture looks exactly as it did. */}
|
||||
{it.art ? (
|
||||
<ItemIcon art={it.art} name={label} size={22} />
|
||||
) : (
|
||||
<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>
|
||||
|
||||
31
client/src/components/DetailRow.jsx
Normal file
31
client/src/components/DetailRow.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// ── A label/value line in an admin detail panel ────────────────────────────
|
||||
//
|
||||
// Extracted from `SpawnAtlas.jsx` in phase 8, when the Client Files panel needed
|
||||
// the same thing for the third time. Two copies of twenty lines is a coincidence;
|
||||
// three is a component, and the reason to make it one here rather than later is
|
||||
// that these lines are read side by side — an operator moves between Spawn Atlas
|
||||
// and Client Files doing one job, and a panel whose rows are a few pixels off
|
||||
// from its neighbour's looks like a different part of the product.
|
||||
//
|
||||
// Deliberately not styled through a class: this module ships as a prebuilt chunk
|
||||
// into core's SPA and owns no stylesheet there (MODULE_API.md §3.2), so its own
|
||||
// layout is inline and only core's theme VARIABLES are borrowed.
|
||||
export default function DetailRow({ 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>
|
||||
)
|
||||
}
|
||||
47
client/src/components/ItemIcon.jsx
Normal file
47
client/src/components/ItemIcon.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// One item's picture, when this site holds one (docs/link/v8.md §5, §11 — phase 5).
|
||||
//
|
||||
// `art` is a FILENAME under uploads/items/, never a path or a URL — the same
|
||||
// shape `CreaturePortrait` takes, so there is one place in this module that knows
|
||||
// where uploads are mounted rather than one per surface.
|
||||
//
|
||||
// **NULL is ordinary and permanent for some items, and this renders nothing for
|
||||
// it.** Three separate reasons an item has no picture, and none of them is a
|
||||
// fault: the site has no shard link and never fetched one; the warm pass has not
|
||||
// reached this key yet (pictures are fetched behind the page, never by it, so a
|
||||
// new listing shows text first and gains its icon a few minutes later); or the
|
||||
// operator's own client simply has no art at that id — 9,963 of a stock client's
|
||||
// static ids have an empty index entry. Every layout using this is written to sit
|
||||
// correctly with the icon absent, because that is the state all of them were
|
||||
// built in.
|
||||
//
|
||||
// A hued item is a DIFFERENT picture, not a tinted one: the shard applies the hue
|
||||
// out of `hues.mul` before it sends anything, because whether a hue repaints the
|
||||
// whole sprite or only its grey pixels is decided by a flag in `tiledata.mul`
|
||||
// that this browser has no way to read. So there is nothing to style here — the
|
||||
// bytes already are the right colour.
|
||||
//
|
||||
// `imageRendering: 'pixelated'` for the same reason the creature portraits use
|
||||
// it: UO art is pixel art, and a browser's default smoothing turns a 22×26
|
||||
// item into a smear at any size above its own.
|
||||
export default function ItemIcon({ art, name, size = 32 }) {
|
||||
if (!art) return null
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`/uploads/items/${encodeURIComponent(art)}`}
|
||||
alt=""
|
||||
// Decorative: the item's name is already beside it as text, and an alt
|
||||
// repeating it would make a screen reader say it twice.
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
flex: 'none',
|
||||
objectFit: 'contain',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
title={name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.creat
|
||||
)
|
||||
}
|
||||
|
||||
// The curated kit (§3.4). Seven members, closed: anything else this module needs
|
||||
// The curated kit (§3.4). Eight members, closed: anything else this module needs
|
||||
// it bundles itself, which is why `components/` next door exists at all.
|
||||
export const {
|
||||
PublicLayout,
|
||||
@@ -59,6 +59,11 @@ export const {
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
// Eighth member (MODULE_API 1.6.0): the slot renderer, for the INVERTED
|
||||
// direction — this module declares a place on its own page and CORE fills it.
|
||||
// Shared rather than reimplemented so core's content failing inside our page is
|
||||
// contained by core's own error boundary.
|
||||
Slot,
|
||||
} = rg.ui
|
||||
|
||||
// The registry, for entry.jsx. Everything else here is read by pages.
|
||||
|
||||
@@ -26,6 +26,7 @@ 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 Guild from './routes/public/Guild.jsx'
|
||||
import Governors from './routes/public/Governors.jsx'
|
||||
import Houses from './routes/public/Houses.jsx'
|
||||
import Rules from './routes/public/Rules.jsx'
|
||||
@@ -40,6 +41,7 @@ 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 ClientFiles from './routes/admin/ClientFiles.jsx'
|
||||
import HousesAdmin from './routes/admin/HousesAdmin.jsx'
|
||||
import AdminCharacters from './routes/admin/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/AdminCharacter.jsx'
|
||||
@@ -81,6 +83,7 @@ registry.registerRoutes(ID, {
|
||||
{ path: 'shard/activity', element: <ShardActivity /> },
|
||||
{ path: 'champs', element: <ChampSpawns /> },
|
||||
{ path: 'guilds', element: <Guilds /> },
|
||||
{ path: 'guilds/:id', element: <Guild /> },
|
||||
{ path: 'governors', element: <Governors /> },
|
||||
{ path: 'houses', element: <Houses /> },
|
||||
{ path: 'rules', element: <Rules /> },
|
||||
@@ -91,12 +94,14 @@ registry.registerRoutes(ID, {
|
||||
{ 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.
|
||||
// Admin-only: the sidecar's configuration, who may see which surface, the
|
||||
// atlas import and the client-file imports. No `gate` on these four because
|
||||
// AdminLayout already requires staff and they carry their own role rows
|
||||
// below.
|
||||
{ path: 'link', element: <ShardAdmin /> },
|
||||
{ path: 'visibility', element: <ShardVisibility /> },
|
||||
{ path: 'atlas', element: <SpawnAtlas /> },
|
||||
{ path: 'files', element: <ClientFiles /> },
|
||||
{ path: 'ops', element: <ShardOps />, gate: STAFF },
|
||||
{ path: 'houses', element: <HousesAdmin />, gate: STAFF },
|
||||
// Self-service, and deliberately ungated: a staff member's own characters
|
||||
@@ -148,6 +153,7 @@ registry.registerNav(ID, {
|
||||
{ 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'] },
|
||||
{ label: 'Client Files', to: '/admin/uo/files', 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.
|
||||
@@ -180,6 +186,35 @@ registry.registerFeatureProvider(ID, ID, useShardFlags)
|
||||
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
|
||||
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
|
||||
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
|
||||
// ── The inverted slot: this module DECLARES, core fills ────────────────────
|
||||
//
|
||||
// The other three above are core's slots that this module fills. This one is the
|
||||
// reverse (TEAMS.md Part 3): Teams are a core primitive that this module
|
||||
// populates, but core does not own the word "guild" and publishes no Team page of
|
||||
// its own — so the page is ours and core contributes the activity feed to it.
|
||||
//
|
||||
// Declared under this module's own namespace, which core enforces. The second
|
||||
// argument is what gets core's content into the place: **core offers a
|
||||
// CONTRIBUTION and never names a slot**, so this module says where each one goes
|
||||
// and keeps its own word for the place. Core's fills are applied after every
|
||||
// module chunk has evaluated, so declaring here is early enough; on a core that
|
||||
// knows nothing of Teams the slot simply stays empty.
|
||||
registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
|
||||
|
||||
// A SECOND place on the same page, for core's Team forum (TEAMS.md Part 5). Two
|
||||
// declarations rather than one, because a slot holds one component and this module
|
||||
// wants to decide where each of core's two contributions sits on its own page —
|
||||
// the feed reads as part of the guild's story, the forum is a room you go into.
|
||||
// Neither knows the other exists, and a core that fills only one leaves the other
|
||||
// empty.
|
||||
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
|
||||
|
||||
// And a THIRD, at the top of the same page, for core's per-Team notification
|
||||
// control (TEAMS.md §6.3). Same reasoning as the other two and a different place:
|
||||
// muting a guild is an action ON this page, so it sits with the page's heading
|
||||
// rather than after its content. Core resolves whether this viewer is in the
|
||||
// Team at all — this module neither knows nor asks.
|
||||
registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
|
||||
|
||||
// `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
|
||||
|
||||
663
client/src/routes/admin/ClientFiles.jsx
Normal file
663
client/src/routes/admin/ClientFiles.jsx
Normal file
@@ -0,0 +1,663 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.js'
|
||||
import Row from '../../components/DetailRow.jsx'
|
||||
import { CreaturePortrait } from '../public/Atlas.jsx'
|
||||
|
||||
// ── Admin · Client files ────────────────────────────────────────────────────
|
||||
//
|
||||
// Everything on this site that comes out of the operator's own UO client, and
|
||||
// the buttons that bring it in (docs/link/v8.md §6, §14 — the Asset Bridge,
|
||||
// phase 8).
|
||||
//
|
||||
// Three things, one page, because they are one job. Creature portraits, item and
|
||||
// land pictures, and the cliloc table all live in files inside a UO client
|
||||
// install; the shard decodes them and hands them over the bridge; and every one
|
||||
// of them changes at the same moment, when the operator patches that client. An
|
||||
// operator who has just done that has exactly one place to come.
|
||||
//
|
||||
// **Boot never asks the shard for any of it** (org lead, phase 2 and again in
|
||||
// phase 7). A client patch is an event the operator knows about and the website
|
||||
// does not, and a site that re-read 343 MB of client files on every restart to
|
||||
// discover nothing had changed would be paying for the rare case forever. The
|
||||
// consequence is the reason this panel exists at all: these buttons are the ONLY
|
||||
// thing that imports. Nothing here happens on its own except the item-art warm
|
||||
// pass, which is lazy by design and only fetches what a page has already asked
|
||||
// for.
|
||||
//
|
||||
// **Nothing on this page throws for an operator-visible problem.** A shard that
|
||||
// is down, an asset plane switched off, a Linux host with no libgdiplus, a client
|
||||
// with no cliloc file — each is a reported state with a reason naming what to
|
||||
// fix. A red box that says "500" would be the one thing an operator cannot act
|
||||
// on, and every one of these states is ordinary.
|
||||
|
||||
// ── outcomes ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// An import reports its result rather than throwing, so these are answers, not
|
||||
// errors. They are written in the operator's terms — what happened to their
|
||||
// site — rather than in the protocol's.
|
||||
|
||||
const ASSET_OUTCOME = {
|
||||
imported: (r) =>
|
||||
`Imported — ${r.written?.toLocaleString() ?? 0} picture(s) written, ` +
|
||||
`${r.assets?.toLocaleString() ?? 0} in the catalogue, ` +
|
||||
`${r.bodies?.resolved?.toLocaleString() ?? 0} creature(s) matched to a body.`,
|
||||
unchanged: () =>
|
||||
'Unchanged — the shard’s client files match what was imported, so nothing was transferred.',
|
||||
needsReview: (r) =>
|
||||
`Waiting for you: ${r.vanishedCount?.toLocaleString() ?? 0} picture(s) this site holds are no` +
|
||||
' longer offered by the shard.',
|
||||
unavailable: (r) => `The shard could not serve this: ${r.reason || 'unknown reason'}`,
|
||||
skipped: () => 'No shard is linked, so there are no client files to read.',
|
||||
failed: (r) => `The import failed: ${r.reason || 'unknown reason'}`,
|
||||
}
|
||||
|
||||
// The warm pass speaks the same vocabulary as the body import deliberately
|
||||
// (`skipped` / `unavailable` / `unchanged` / `imported` / `failed`), but its
|
||||
// numbers mean something different: it is bounded, so "imported" routinely
|
||||
// leaves work behind and saying so is the difference between a button that looks
|
||||
// broken and one that is doing what it promised.
|
||||
const WARM_OUTCOME = {
|
||||
imported: (r) =>
|
||||
`Fetched ${r.written?.toLocaleString() ?? 0} picture(s)` +
|
||||
(r.remaining ? `; ${r.remaining.toLocaleString()} still waiting — press again.` : '.'),
|
||||
unchanged: () => 'Nothing waiting — every picture a page has asked for is already here.',
|
||||
unavailable: (r) => `The shard could not serve this: ${r.reason || 'unknown reason'}`,
|
||||
skipped: () => 'No shard is linked, so there is nothing to fetch.',
|
||||
failed: (r) => `That did not work: ${r.reason || 'unknown reason'}`,
|
||||
}
|
||||
|
||||
const CLILOC_OUTCOME = {
|
||||
imported: (r) => `Imported — ${r.count?.toLocaleString() ?? 0} names loaded.`,
|
||||
unchanged: () => 'Unchanged — the source matches the table that is already loaded.',
|
||||
needsReview: (r) =>
|
||||
`Waiting for you: ${r.missingSources?.length ?? 0} overlay file(s) that were loaded last time` +
|
||||
' are missing.',
|
||||
unavailable: (r) => `The source could not be read: ${r.reason || 'unknown reason'}`,
|
||||
skipped: (r) => r.reason || 'There is no cliloc source configured.',
|
||||
failed: (r) => `The import failed: ${r.reason || 'unknown reason'}`,
|
||||
}
|
||||
|
||||
const describe = (table, result) =>
|
||||
(table[result?.status] || (() => `Result: ${result?.status}`))(result || {})
|
||||
|
||||
const num = (n) => (n == null ? '—' : Number(n).toLocaleString())
|
||||
const when = (v) => (v ? new Date(v).toLocaleString() : 'Never')
|
||||
|
||||
// ── the vanished-key review (§6) ───────────────────────────────────────────
|
||||
//
|
||||
// A key the site holds that the shard no longer offers is refused rather than
|
||||
// applied, because an unmounted client volume and a deliberate client downgrade
|
||||
// are the same thing from the server and the wrong guess deletes artwork.
|
||||
//
|
||||
// It is held in this component's state and not in a table, deliberately (org
|
||||
// lead, 2026-09-14). The atlas persists its equivalent because BOOT re-parses the
|
||||
// tree and would otherwise re-prompt on every restart forever; an asset import
|
||||
// only ever happens because somebody pressed a button on this page, so the
|
||||
// review is in front of the person who caused it, by construction. Declining is
|
||||
// therefore not a decision to remember — it is simply not pressing the other
|
||||
// button.
|
||||
//
|
||||
// The pictures matter. `body/820/a23` names nothing a human recognises; the horse
|
||||
// it is a picture of does, and "is it right that these disappear?" is not a
|
||||
// question anyone can answer from a list of keys.
|
||||
function VanishedReview({ review, busy, onApprove, onDismiss }) {
|
||||
const rows = review.result.vanished || []
|
||||
const total = review.result.vanishedCount ?? rows.length
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: '1px solid #c58f4a',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
background: 'rgba(197,143,74,0.08)',
|
||||
}}
|
||||
>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
|
||||
An import is waiting for you
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
The shard no longer offers <strong>{num(total)}</strong> picture{total === 1 ? '' : 's'} this
|
||||
site is currently serving, so nothing was changed. That is what a client volume that failed
|
||||
to mount looks like as well as a deliberate client downgrade, and only you can tell them
|
||||
apart. Approving re-reads the shard as it is right now — if the mount was the problem and you
|
||||
have since fixed it, what lands is the corrected import, not a deletion.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
maxHeight: 260,
|
||||
overflowY: 'auto',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} style={{ width: 96, textAlign: 'center' }}>
|
||||
<CreaturePortrait art={row.file} name={row.key} size={48} />
|
||||
<div
|
||||
className="sans dim"
|
||||
style={{ fontSize: '0.7rem', wordBreak: 'break-all', marginTop: 2 }}
|
||||
title={row.key}
|
||||
>
|
||||
{row.key}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{total > rows.length && (
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.8rem' }}>
|
||||
Showing the first {num(rows.length)} of {num(total)}.
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={onDismiss}>
|
||||
Keep the pictures I have
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// What the last import did. Core's activity log records the same action, but it
|
||||
// is one unfiltered list of every admin action on the site — so the answer to
|
||||
// "did last week's import actually do anything" is here, beside the button that
|
||||
// caused it, rather than twenty pages into a log.
|
||||
function LastImport({ last, at }) {
|
||||
if (!last) {
|
||||
return <Row label="Last import">{at ? when(at) : 'No import recorded yet'}</Row>
|
||||
}
|
||||
|
||||
const tally = last.bodies || {}
|
||||
const unmatched = [
|
||||
tally.unknown ? `${num(tally.unknown)} unknown to the shard` : '',
|
||||
tally.notCreature ? `${num(tally.notCreature)} not a creature` : '',
|
||||
tally.failed ? `${num(tally.failed)} failed` : '',
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row label="Last import">
|
||||
{`${when(last.at || at)}${last.by ? ` · ${last.by}` : ''}${last.force ? ' · full re-import' : ''}`}
|
||||
</Row>
|
||||
<Row label="Pictures written">
|
||||
{`${num(last.written)} written, ${num(last.fetched)} fetched`}
|
||||
{last.removed ? `, ${num(last.removed)} removed` : ''}
|
||||
</Row>
|
||||
{unmatched.length > 0 && (
|
||||
// Only the creatures that did NOT match, because how many did is the row
|
||||
// above this block and a number that means "now" should not also appear
|
||||
// as a number that means "at that import". What is left is the part an
|
||||
// operator can act on: `unknown` is a spawn file naming a type this
|
||||
// shard's scripts do not define, which is real drift.
|
||||
<Row label="Could not be matched">{unmatched.join(', ')}</Row>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ClientFiles() {
|
||||
const [assets, setAssets] = useState(null)
|
||||
const [clilocs, setClilocs] = useState(null)
|
||||
const [clilocPath, setClilocPath] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
// One message per section: three panels that can each speak means an operator
|
||||
// must never have to work out which button a sentence belongs to.
|
||||
const [msg, setMsg] = useState({})
|
||||
// The in-session reviews, keyed by which plane raised them.
|
||||
const [review, setReview] = useState({})
|
||||
|
||||
// `quiet` re-reads without flipping `loading`, and that distinction is the
|
||||
// whole difference between a usable panel and a maddening one: `loading`
|
||||
// replaces the page with a spinner, so refreshing that way after an action
|
||||
// unmounts everything, throws the operator back to the top of a long page, and
|
||||
// takes the sentence saying what just happened with it — at the bottom of the
|
||||
// cliloc section, that means pressing Update appears to do nothing at all.
|
||||
const load = useCallback(async ({ quiet = false } = {}) => {
|
||||
if (!quiet) setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
// Both statuses call the shard, and neither one failing should cost the
|
||||
// other its panel: an operator whose cliloc file is missing still needs to
|
||||
// see what the asset import says.
|
||||
const [a, c] = await Promise.all([
|
||||
api.admin.assets.status().catch((err) => ({ error: err.message })),
|
||||
api.admin.clilocs.status().catch((err) => ({ error: err.message })),
|
||||
])
|
||||
setAssets(a)
|
||||
setClilocs(c)
|
||||
setClilocPath(c?.path || '')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load the client-file status.')
|
||||
} finally {
|
||||
if (!quiet) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// One automatic re-read when the shard answered BUSY (§3.2's single slot),
|
||||
// and exactly one per mount.
|
||||
//
|
||||
// BUSY is not a fault and it is not sticky on the shard — it means something
|
||||
// else held the asset slot for longer than the client's own 425 backoff, and
|
||||
// the two things that hold it are both ordinary: an import the operator
|
||||
// started, and the item-art warm pass refilling itself after a client patch.
|
||||
// The panel does not poll, so without this the operator is left reading a
|
||||
// refusal about a shard that was free again seconds later, until they think to
|
||||
// reload. A second read clears the common case; if it is still busy, the
|
||||
// sentence says to come back, because a page that retried forever would be
|
||||
// holding the slot it is waiting for.
|
||||
const retried = useRef(false)
|
||||
useEffect(() => {
|
||||
if (retried.current || busy) return
|
||||
const stillBusy = assets?.code === 'BUSY' || clilocs?.code === 'BUSY'
|
||||
if (!stillBusy) return
|
||||
retried.current = true
|
||||
const t = setTimeout(() => load({ quiet: true }), 4000)
|
||||
return () => clearTimeout(t)
|
||||
}, [assets, clilocs, busy, load])
|
||||
|
||||
// Every action shares this: run it, say what it said, then re-read status so
|
||||
// the panel reflects the world rather than what we assumed happened.
|
||||
async function run(section, table, fn) {
|
||||
setBusy(true)
|
||||
setMsg((m) => ({ ...m, [section]: '' }))
|
||||
setError('')
|
||||
try {
|
||||
const result = await fn()
|
||||
setMsg((m) => ({ ...m, [section]: describe(table, result) }))
|
||||
// Set or cleared from the SAME answer, in one place. Clearing separately
|
||||
// left the review standing after an approve that had already applied — a
|
||||
// banner asking for a decision that was made ten seconds ago, on pictures
|
||||
// that are already gone.
|
||||
setReview((r) => ({
|
||||
...r,
|
||||
[section]: result?.status === 'needsReview' ? { result, run: fn } : null,
|
||||
}))
|
||||
await load({ quiet: true })
|
||||
return result
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not work.')
|
||||
return null
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveClilocPath() {
|
||||
setBusy(true)
|
||||
setMsg((m) => ({ ...m, clilocs: '' }))
|
||||
setError('')
|
||||
try {
|
||||
const fresh = await api.admin.clilocs.setPath(clilocPath.trim())
|
||||
setClilocs(fresh)
|
||||
setClilocPath(fresh.path || '')
|
||||
setMsg((m) => ({
|
||||
...m,
|
||||
clilocs:
|
||||
fresh.source === 'bridge'
|
||||
? 'Saved. The base table still comes from the shard — this selects where custom/ overlay' +
|
||||
' files are read from.'
|
||||
: fresh.path === ''
|
||||
? 'Path cleared. The loaded table keeps serving; nothing new will be read.'
|
||||
: fresh.fileReadable
|
||||
? 'Saved. The file is readable — import when you are ready.'
|
||||
: 'Saved, but the file 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 && !assets && !clilocs) return <ErrorState message={error} />
|
||||
|
||||
const loaded = assets?.loaded || null
|
||||
const shard = assets?.shard || null
|
||||
const families = shard?.families || []
|
||||
// Reported by the server rather than inferred from `shard` being null — which
|
||||
// is also what a linked shard that is simply DOWN looks like, and those two
|
||||
// want opposite things from this page: one needs its buttons disabled, the
|
||||
// other needs them available so the operator can retry.
|
||||
const linked = Boolean(assets?.linked)
|
||||
const imagingBroken = shard?.imaging && shard.imaging.ok === false
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<header>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
|
||||
Client files
|
||||
</h2>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
|
||||
Creature portraits, item pictures and the names your shard’s items and titles are stored
|
||||
under all come out of the UO client on the shard host. The shard reads and decodes them
|
||||
itself and hands them over uo-link — nothing is converted on a desktop and nothing is
|
||||
uploaded. They change when you patch that client, which is something only you know about,
|
||||
so <strong>these buttons are the only thing that imports them</strong>: nothing here
|
||||
happens on a restart.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{(assets?.error || clilocs?.error) && (
|
||||
<section
|
||||
style={{ border: '1px solid #d98b84', borderRadius: 10, padding: 16 }}
|
||||
className="sans"
|
||||
>
|
||||
<strong style={{ color: 'var(--head)' }}>Part of this page could not be read.</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
{assets?.error || clilocs?.error} — the counts below may be missing. Both status calls
|
||||
are written never to fail for an ordinary problem (a shard that is down is an ANSWER
|
||||
here), so this one is worth the server log.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{assets?.reason && !shard && (
|
||||
<section
|
||||
style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}
|
||||
className="sans"
|
||||
>
|
||||
{/* BUSY is the one code here that is not a fault, and saying "the shard
|
||||
is not answering" about it sends an operator to check a shard that is
|
||||
working. The slot is held by something ordinary — an import running,
|
||||
or the warm pass — and it frees itself. */}
|
||||
<strong style={{ color: 'var(--head)' }}>
|
||||
{assets.code === 'BUSY'
|
||||
? 'The shard is busy with another client-file request.'
|
||||
: 'The shard is not answering for client files.'}
|
||||
</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
{assets.code === 'BUSY'
|
||||
? 'The shard serves one of these at a time, so an import running now — or the' +
|
||||
' item-picture pass refilling itself after a client patch — holds it until it is' +
|
||||
' done. This page re-reads once on its own; if the counts below are still missing' +
|
||||
' after that, reload in a moment.'
|
||||
: assets.reason}
|
||||
{assets.code === 'DISABLED' &&
|
||||
' — set Bridge.AssetsEnabled on the shard to allow it to read its own client files.'}
|
||||
</p>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
What is already imported keeps serving; only new imports are affected.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{imagingBroken && (
|
||||
<section
|
||||
style={{ border: '1px solid #c58f4a', borderRadius: 10, padding: 16, background: 'rgba(197,143,74,0.08)' }}
|
||||
className="sans"
|
||||
>
|
||||
<strong style={{ color: 'var(--head)' }}>The shard host cannot render images.</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.86rem', lineHeight: 1.6 }}>
|
||||
{shard.imaging.reason ||
|
||||
'A Linux shard host needs libgdiplus before it can decode a single sprite.'}{' '}
|
||||
Names (the cliloc table) are unaffected and can still be imported — they have no pixels
|
||||
in them.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{review.assets && (
|
||||
<VanishedReview
|
||||
review={review.assets}
|
||||
busy={busy}
|
||||
onApprove={() => run('assets', ASSET_OUTCOME, () => review.assets.run(true))}
|
||||
onDismiss={() => setReview((r) => ({ ...r, assets: null }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── creature portraits ── */}
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Creature portraits
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
One picture per creature body, imported as a set and shown on the bestiary. Creatures the
|
||||
client has no artwork for are normal and stay as text — a stock client has none for most
|
||||
ghost and gargoyle bodies. Portraits you drew yourself and named in{' '}
|
||||
<code>spawnAtlas.art.json</code> always win over an imported one.
|
||||
</p>
|
||||
<Row label="Pictures held">{`${num(loaded?.stored)} of ${num(loaded?.assets)} catalogued`}</Row>
|
||||
<Row label="Creatures matched">{`${num(loaded?.resolved)} of ${num(loaded?.creatures)}`}</Row>
|
||||
<LastImport last={loaded?.last} at={loaded?.importedAt} />
|
||||
<Row label="Client files changed since">
|
||||
{assets?.drift == null
|
||||
? '—'
|
||||
: assets.drift
|
||||
? 'Yes — an update would pick it up'
|
||||
: 'No'}
|
||||
</Row>
|
||||
{shard?.hashing && (
|
||||
<Row label="Shard is hashing">
|
||||
Yes — it is still fingerprinting its client files in the background. Drift may read as
|
||||
“yes” until it finishes.
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Extractor version">
|
||||
{/* "—" for a version nobody has imported yet reads as a missing value;
|
||||
it is an answer, and the shard's own version is the useful half of
|
||||
the sentence on exactly that install. */}
|
||||
{(loaded?.extractorVersion == null ? 'None' : num(loaded.extractorVersion)) +
|
||||
' imported' +
|
||||
(shard?.extractorVersion == null ? '' : ` · ${num(shard.extractorVersion)} on the shard`)}
|
||||
</Row>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center', marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('assets', ASSET_OUTCOME, (approve = false) => api.admin.assets.update(approve))}
|
||||
>
|
||||
{busy ? 'Working…' : 'Update'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('assets', ASSET_OUTCOME, (approve = false) => api.admin.assets.reimport(approve))}
|
||||
>
|
||||
Re-import everything
|
||||
</button>
|
||||
</div>
|
||||
<p className="sans dim" style={{ margin: '10px 0 0', fontSize: '0.8rem', lineHeight: 1.6 }}>
|
||||
<strong>Update</strong> checks the shard’s client files first and transfers only the
|
||||
pictures that actually changed — when nothing has, it costs one small round trip.{' '}
|
||||
<strong>Re-import everything</strong> fetches the whole catalogue again; use it after
|
||||
restoring a backup or losing the uploads volume, where the database still remembers
|
||||
pictures that are no longer on disk.
|
||||
</p>
|
||||
{msg.assets && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.85rem', color: '#7fd0a4' }}>{msg.assets}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── item and land pictures ── */}
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Item and land pictures
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
The pictures beside marketplace listings and on character sheets. These are never imported
|
||||
as a set — there are tens of thousands of item graphics, times every dye colour — so they
|
||||
arrive one at a time, shortly after a page asks for one, and refresh themselves after a
|
||||
client patch. This is here for the two moments waiting is the wrong answer: you have just
|
||||
linked a shard, or you have just patched a client and would rather not wait.
|
||||
</p>
|
||||
<Row label="Item pictures held">{num(loaded?.items)}</Row>
|
||||
<Row label="Land pictures held">{num(loaded?.land)}</Row>
|
||||
<Row label="Shard serves">
|
||||
{families.length > 0 ? families.join(', ') : '—'}
|
||||
{shard && !families.includes('static')
|
||||
? ' — this shard’s plugin predates item pictures; update the overlay to get them'
|
||||
: ''}
|
||||
</Row>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center', marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('warm', WARM_OUTCOME, () => api.admin.assets.warm(false))}
|
||||
>
|
||||
Fetch waiting pictures
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy || !linked}
|
||||
onClick={() => run('warm', WARM_OUTCOME, () => api.admin.assets.warm(true))}
|
||||
>
|
||||
Refresh the ones I have
|
||||
</button>
|
||||
</div>
|
||||
{msg.warm && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.85rem', color: '#7fd0a4' }}>{msg.warm}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── the cliloc table ── */}
|
||||
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
|
||||
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
|
||||
Item and title names (clilocs)
|
||||
</h3>
|
||||
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
UO stores most item, title and reward names as numbers, and the words live in the client’s
|
||||
cliloc file. Without this table the marketplace and character sheets show numbers. With a
|
||||
shard linked the shard decompresses and serves it; otherwise the site reads a file you
|
||||
point it at below.
|
||||
</p>
|
||||
<Row label="Names loaded">{num(clilocs?.count)}</Row>
|
||||
<Row label="Imported">{when(clilocs?.importedAt)}</Row>
|
||||
<Row label="Source">
|
||||
{clilocs?.source === 'bridge'
|
||||
? 'The shard, over uo-link'
|
||||
: clilocs?.configured
|
||||
? clilocs.path
|
||||
: 'None configured'}
|
||||
</Row>
|
||||
<Row label="Overlays">
|
||||
{clilocs?.sources?.length ? clilocs.sources.join(', ') : 'None'}
|
||||
</Row>
|
||||
<Row label="Changed since import">
|
||||
{clilocs?.drift == null ? '—' : clilocs.drift ? 'Yes — an import would pick it up' : 'No'}
|
||||
</Row>
|
||||
{clilocs?.problem && (
|
||||
<Row label="Problem">
|
||||
<span style={{ color: '#d98b84' }}>{clilocs.problem}</span>
|
||||
</Row>
|
||||
)}
|
||||
{clilocs?.missingSources?.length > 0 && (
|
||||
<Row label="Missing since last import">
|
||||
<span style={{ color: '#d98b84' }}>{clilocs.missingSources.join(', ')}</span>
|
||||
</Row>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center', marginTop: 14 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('clilocs', CLILOC_OUTCOME, (approve = false) =>
|
||||
api.admin.clilocs.import({ approve }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{busy ? 'Working…' : 'Update'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('clilocs', CLILOC_OUTCOME, (approve = false) =>
|
||||
api.admin.clilocs.import({ force: true, approve }),
|
||||
)
|
||||
}
|
||||
>
|
||||
Re-import everything
|
||||
</button>
|
||||
</div>
|
||||
{review.clilocs && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
border: '1px solid #c58f4a',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
background: 'rgba(197,143,74,0.08)',
|
||||
}}
|
||||
>
|
||||
<strong className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>
|
||||
An overlay file that was loaded last time is missing
|
||||
</strong>
|
||||
<p className="sans" style={{ margin: '6px 0 10px', fontSize: '0.85rem', color: 'var(--muted)', lineHeight: 1.6 }}>
|
||||
{(review.clilocs.result.missingSources || []).join(', ') || 'One or more overlays'} —
|
||||
the table was left exactly as it is. If you deleted those files on purpose, import
|
||||
anyway; if this is a mount that did not come back, fix it first and the next import
|
||||
picks the names up again.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
run('clilocs', CLILOC_OUTCOME, () => review.clilocs.run(true))
|
||||
}
|
||||
>
|
||||
Import without them
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sq"
|
||||
disabled={busy}
|
||||
onClick={() => setReview((r) => ({ ...r, clilocs: null }))}
|
||||
>
|
||||
Keep the names I have
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p className="sans dim" style={{ margin: '0 0 8px', fontSize: '0.8rem', lineHeight: 1.6 }}>
|
||||
{clilocs?.source === 'bridge'
|
||||
? 'Where custom/ overlay files are read from. The base table comes from the shard' +
|
||||
' either way; leave this blank if you have no overlays.'
|
||||
: 'The directory holding the cliloc file. Blank turns cliloc resolution off — the' +
|
||||
' table that is already loaded keeps serving.'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
className="input"
|
||||
value={clilocPath}
|
||||
onChange={(e) => setClilocPath(e.target.value)}
|
||||
placeholder="/srv/uo-client"
|
||||
style={{ flex: '1 1 320px', minWidth: 0 }}
|
||||
/>
|
||||
<button type="button" className="btn btn-sq" disabled={busy} onClick={saveClilocPath}>
|
||||
Save path
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{msg.clilocs && (
|
||||
<p className="sans" style={{ margin: '10px 0 0', fontSize: '0.85rem', color: '#7fd0a4' }}>{msg.clilocs}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading } from '../../core.js'
|
||||
import Row from '../../components/DetailRow.jsx'
|
||||
|
||||
// ── Admin · Spawn atlas ─────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -36,26 +37,6 @@ const OUTCOME = {
|
||||
|
||||
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 (
|
||||
@@ -159,11 +140,14 @@ export default function SpawnAtlas() {
|
||||
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.',
|
||||
fresh.source === 'bridge'
|
||||
? 'Saved, but not in use: this site reads the atlas from the linked shard. The path takes'
|
||||
+ ' over only if uo-link is disabled.'
|
||||
: 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.')
|
||||
@@ -185,9 +169,11 @@ export default function SpawnAtlas() {
|
||||
</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.
|
||||
Where those files come from depends on whether a shard is linked: with uo-link configured
|
||||
the shard serves them over the bridge and importing is something you do here, when a map
|
||||
changes. Without one, the site reads a local tree and re-imports itself on every server
|
||||
start. Either way the atlas is shard <em>content</em> rather than shard state, so what is
|
||||
loaded keeps serving in full while the shard is down.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -218,10 +204,23 @@ export default function SpawnAtlas() {
|
||||
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
|
||||
</>
|
||||
)}
|
||||
<Row label="Tree readable">
|
||||
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
|
||||
<Row label="Source">
|
||||
{status?.source === 'bridge'
|
||||
? 'The shard, over uo-link'
|
||||
: status?.configured
|
||||
? status.path
|
||||
: 'None — no shard linked and no path set'}
|
||||
</Row>
|
||||
<Row label="Tree changed since import">
|
||||
<Row label="Source readable">
|
||||
{!status?.configured
|
||||
? 'No source'
|
||||
: status.treeReadable
|
||||
? 'Yes'
|
||||
: status.source === 'bridge'
|
||||
? 'No — the shard did not answer, or Bridge.TreeEnabled is off'
|
||||
: 'No'}
|
||||
</Row>
|
||||
<Row label="Changed since import">
|
||||
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
|
||||
</Row>
|
||||
</section>
|
||||
@@ -231,9 +230,13 @@ export default function SpawnAtlas() {
|
||||
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
|
||||
A local ServUO tree the website can read directly — 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.
|
||||
mount can move without a redeploy.
|
||||
{status?.source === 'bridge'
|
||||
? ' It is not in use right now: this site has a shard linked, and the shard serves its' +
|
||||
' own files over the bridge. Unlink or disable uo-link to fall back to a path.'
|
||||
: ' Leave it blank to turn the atlas off.'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
@@ -254,9 +257,11 @@ export default function SpawnAtlas() {
|
||||
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.
|
||||
Applies a map change without restarting — and on a linked shard it is the only thing that
|
||||
does, because boot deliberately never calls the shard for this. An unchanged source costs
|
||||
almost nothing: the file list and its hashes are read first (about 32 KB over the bridge)
|
||||
and no file is transferred 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
|
||||
|
||||
@@ -45,6 +45,46 @@ function Chip({ active, onClick, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
// One creature's portrait, when there is one.
|
||||
//
|
||||
// `art` is a FILENAME under uploads/atlas/, never a path or a URL: it is either a
|
||||
// sprite the shard extracted from the operator's own UO client (docs/link/v8.md
|
||||
// §12) or a picture the operator drew and named in `spawnAtlas.art.json`, and the
|
||||
// two are indistinguishable here on purpose.
|
||||
//
|
||||
// **NULL is the ordinary case and always will be.** An install with no shard link
|
||||
// has never imported one; a shard whose host cannot render images has none; and
|
||||
// even on a complete import, two thirds of the playable ghost and gargoyle bodies
|
||||
// have no art in the client at all (§5.2). So this renders nothing rather than a
|
||||
// placeholder, and every layout around it is written to sit correctly with the
|
||||
// picture absent — which is the state the whole atlas was designed in.
|
||||
//
|
||||
// Sprites are small (a couple of dozen pixels square) and UO's art is pixel art,
|
||||
// so `imageRendering: 'pixelated'` matters: a browser's default smoothing turns a
|
||||
// 24×63 wolf into a smear at any size above its own.
|
||||
export function CreaturePortrait({ art, name, size = 40 }) {
|
||||
if (!art) return null
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`/uploads/atlas/${encodeURIComponent(art)}`}
|
||||
alt=""
|
||||
// Decorative: the creature's name is already beside it as text, so an alt
|
||||
// repeating it would make a screen reader say it twice.
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
flex: 'none',
|
||||
objectFit: 'contain',
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
title={name}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatureCard({ creature }) {
|
||||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||||
return (
|
||||
@@ -60,6 +100,7 @@ function CreatureCard({ creature }) {
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<CreaturePortrait art={creature.art} name={creature.name} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
import { CreaturePortrait } from './Atlas.jsx'
|
||||
|
||||
// One creature: where it spawns, and what spawns alongside it.
|
||||
//
|
||||
@@ -146,11 +147,22 @@ export default function AtlasCreature() {
|
||||
|
||||
{!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'}.`}
|
||||
/>
|
||||
{/* The portrait sits BESIDE the header rather than inside it: `art`
|
||||
is NULL for most creatures on most installs — no shard link, a
|
||||
host that cannot render images, or simply a body this client has
|
||||
no art for — and a header component that had to lay out around an
|
||||
absent picture would be carrying that case forever. Here the row
|
||||
collapses to exactly the header, which is what it was before. */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||
<CreaturePortrait art={data.art} name={data.name} size={96} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Panel
|
||||
|
||||
122
client/src/routes/public/Guild.jsx
Normal file
122
client/src/routes/public/Guild.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, Slot, useAsync } from '../../core.js'
|
||||
|
||||
// One guild: its roster, and the place core puts the Team activity feed.
|
||||
//
|
||||
// **This page is the reason the extension-slot direction inverts**
|
||||
// (docs/website/TEAMS.md Part 3). Teams are a core platform primitive and this
|
||||
// module is what populates them — but core does not own the word "guild", so it
|
||||
// publishes no Team page of its own. The page is this module's; the activity feed
|
||||
// on it is core's, because only core can resolve whether the viewer is inside the
|
||||
// Team, and the public/members split on that feed is a security boundary.
|
||||
//
|
||||
// So the module declares `uo.guild.detail` (entry.jsx) and core fills it. On a
|
||||
// core that does not know about Teams the slot is simply never filled and this
|
||||
// page renders its roster alone, which is the same tolerance every other slot has.
|
||||
//
|
||||
// The roster comes from this module's OWN board — the same data it answers core's
|
||||
// Team provider from — rather than from core's Team API. That is deliberate: the
|
||||
// board is the authoritative copy here, and reading core's projection of our own
|
||||
// answer back would be a round trip through a staler copy of our own data.
|
||||
|
||||
function rankOf(m) {
|
||||
// Absent rank means NOT KNOWN, never rank 0. The bridge omits it entirely for
|
||||
// staff, because ServUO reports GameMaster-and-above as Leader whatever their
|
||||
// real rank — emitting that verbatim would publish every staff member in a
|
||||
// guild as one of its leaders (docs/link/v4.md).
|
||||
if (m.rankName) return m.rankName
|
||||
return null
|
||||
}
|
||||
|
||||
function MemberRow({ m }) {
|
||||
const rank = rankOf(m)
|
||||
const linked = m.webId != null || m.acct != null
|
||||
return (
|
||||
<tr style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<td style={{ padding: '9px 10px', color: 'var(--head)' }}>
|
||||
{m.name || 'Unknown'}
|
||||
{m.rank === 4 && (
|
||||
<span className="sans" style={{ color: 'var(--accent)', marginLeft: 8, fontSize: '0.72rem' }}>Leader</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>{rank || '—'}</td>
|
||||
<td className="sans dim" style={{ padding: '9px 10px', fontSize: '0.86rem' }}>
|
||||
{linked ? 'Linked' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Guild() {
|
||||
const { id } = useParams()
|
||||
const { loading, error, data } = useAsync(() => api.shard.guild(id), [id])
|
||||
const roster = (data && data.roster) || []
|
||||
|
||||
return (
|
||||
<PublicLayout section="website">
|
||||
<div className="shell-narrow page-body">
|
||||
<p style={{ marginBottom: 14 }}>
|
||||
<Link to="/uo/guilds">← All guilds</Link>
|
||||
</p>
|
||||
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message="Could not load this guild right now." />}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow={data.abbr ? `[${data.abbr}]` : 'Guild'}
|
||||
title={data.name || 'A guild'}
|
||||
/>
|
||||
<p className="sans dim" style={{ fontSize: '0.88rem' }}>
|
||||
{data.members ?? roster.length} members
|
||||
{data.online != null && ` · ${data.online} online`}
|
||||
{data.alliance && ` · ${data.alliance}`}
|
||||
</p>
|
||||
|
||||
{/* A third place for core, up here rather than below the roster: core
|
||||
puts this guild's notification control in it, and a control that
|
||||
acts on the page belongs beside the page's title and not after its
|
||||
content. Empty for a visitor with no membership, and on a core
|
||||
that fills nothing. */}
|
||||
<Slot name="uo.guild.header" externalId={String(id)} moduleId="uo" />
|
||||
|
||||
{roster.length > 0 && (
|
||||
<div style={{ overflowX: 'auto', marginTop: 18 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<th style={{ padding: '8px 10px' }}>Name</th>
|
||||
<th style={{ padding: '8px 10px' }}>Rank</th>
|
||||
<th style={{ padding: '8px 10px' }}>Account</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Keyed by serial: two characters can share a display name,
|
||||
which this shard's own world actually contains. */}
|
||||
{roster.map((m) => <MemberRow key={m.serial} m={m} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roster.length === 0 && (
|
||||
<p className="sans dim" style={{ marginTop: 18 }}>No roster has been received for this guild yet.</p>
|
||||
)}
|
||||
|
||||
{/* Core's Team activity feed lands here. Nothing renders on a core
|
||||
that does not fill it, or when there is nothing to show. The guild
|
||||
is named in OUR terms — core maps its own Team from these two. */}
|
||||
<Slot name="uo.guild.detail" externalId={String(id)} moduleId="uo" />
|
||||
|
||||
{/* And the Team forum, in its own place below the feed. Core resolves
|
||||
who may read it — membership and manual grants are core's rules —
|
||||
so this module renders the room and never its door policy. */}
|
||||
<Slot name="uo.guild.forum" externalId={String(id)} moduleId="uo" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useShardFeed } from '../../lib/useShardFeed.js'
|
||||
import api from '../../api.js'
|
||||
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
@@ -15,9 +16,12 @@ function Leader({ leader }) {
|
||||
|
||||
function GuildRow({ g }) {
|
||||
return (
|
||||
<div
|
||||
// A link now, because the board gained a detail page: the roster and core's
|
||||
// Team activity feed live there (docs/website/TEAMS.md Part 3).
|
||||
<Link
|
||||
to={`/uo/guilds/${encodeURIComponent(g.id)}`}
|
||||
className="panel"
|
||||
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14 }}
|
||||
style={{ padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 14, textDecoration: 'none' }}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 0 }}>
|
||||
@@ -59,7 +63,7 @@ function GuildRow({ g }) {
|
||||
<Leader leader={g.leader} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
import ItemIcon from '../../components/ItemIcon'
|
||||
|
||||
// ── The player-vendor marketplace ───────────────────────────────────────────
|
||||
//
|
||||
@@ -86,6 +87,7 @@ function ListingRow({ listing }) {
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
|
||||
<ItemIcon art={listing.art} name={itemLabel(listing)} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
className="display"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import api from '../../api.js'
|
||||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
|
||||
import ItemIcon from '../../components/ItemIcon'
|
||||
|
||||
// One player vendor: where to find it and everything it is selling.
|
||||
//
|
||||
@@ -75,8 +76,9 @@ export default function MarketVendor() {
|
||||
<div
|
||||
key={i.serial}
|
||||
className="panel"
|
||||
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}
|
||||
style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'center' }}
|
||||
>
|
||||
<ItemIcon art={i.art} name={itemLabel(i)} size={28} />
|
||||
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>
|
||||
{i.amount > 1 ? `${num(i.amount)} × ` : ''}
|
||||
{itemLabel(i)}
|
||||
|
||||
@@ -102,6 +102,42 @@ test('admin atlas actions use the right methods and bodies', async () => {
|
||||
assert.deepEqual(calls[1].opts.body, { path: '/srv/servuo' })
|
||||
})
|
||||
|
||||
// ── the Asset Bridge's two stages (docs/link/v8.md §6) ──────────────────────
|
||||
// Update and Re-import are one route and differ only by `force`, and the
|
||||
// difference is not cosmetic: one transfers nothing when the client files are
|
||||
// unchanged, the other fetches the whole catalogue. A binding that sent `force`
|
||||
// on both would make the cheap button the expensive one, and nothing visible
|
||||
// would change — the pictures would be correct either way.
|
||||
test('assets.update asks for the diff and assets.reimport asks for everything', async () => {
|
||||
await admin.assets.update()
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/assets/import')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
assert.deepEqual(calls[0].opts.body, { approve: false })
|
||||
|
||||
await admin.assets.reimport()
|
||||
assert.deepEqual(calls[1].opts.body, { force: true, approve: false })
|
||||
})
|
||||
|
||||
// Approving a vanished key re-runs the SAME operation the operator pressed, so
|
||||
// `approve` has to ride on both. Sending the update's approval as a re-import
|
||||
// would quietly turn "yes, accept those deletions" into a full re-download.
|
||||
test('approve rides on whichever import the operator ran', async () => {
|
||||
await admin.assets.update(true)
|
||||
await admin.assets.reimport(true)
|
||||
assert.deepEqual(calls[0].opts.body, { approve: true })
|
||||
assert.deepEqual(calls[1].opts.body, { force: true, approve: true })
|
||||
})
|
||||
|
||||
test('cliloc admin actions use the right methods and bodies', async () => {
|
||||
await admin.clilocs.import({ force: true })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/shard/clilocs/import')
|
||||
assert.deepEqual(calls[0].opts.body, { force: true, approve: false })
|
||||
|
||||
await admin.clilocs.setPath('/srv/uo-client')
|
||||
assert.equal(calls[1].opts.method, 'PUT')
|
||||
assert.deepEqual(calls[1].opts.body, { path: '/srv/uo-client' })
|
||||
})
|
||||
|
||||
// ── 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
|
||||
|
||||
@@ -39,11 +39,18 @@ const CHUNK = path.resolve(HERE, '..', 'dist', 'entry.js')
|
||||
// nothing here renders, so a named stub is enough to be imported and passed on.
|
||||
const stub = (name) => Object.assign(() => null, { displayName: name })
|
||||
|
||||
// Core's contribution catalogue, as of MODULE_API 1.6.0. Written down rather than
|
||||
// imported — this suite runs against the BUILT chunk with no core in the process
|
||||
// — which means it is a claim about core that has to be re-read when core's list
|
||||
// changes. That is the same trade the rest of this fake makes.
|
||||
const CORE_CONTRIBUTIONS = ['team.activity', 'team.forum', 'team.notify']
|
||||
|
||||
function fakeRg() {
|
||||
const routes = { public: [], admin: [], player: [] }
|
||||
const nav = { public: [], admin: [], player: [] }
|
||||
const providers = new Map()
|
||||
const extensions = new Map()
|
||||
const declaredSlots = new Map()
|
||||
return {
|
||||
version: '1.3.0',
|
||||
react,
|
||||
@@ -54,7 +61,7 @@ function fakeRg() {
|
||||
// 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']
|
||||
['PublicLayout', 'PageHeader', 'Loading', 'ErrorState', 'EmptyState', 'useAsync', 'useAuth', 'useSite', 'Slot']
|
||||
.map((n) => [n, stub(n)]),
|
||||
),
|
||||
api: { request: async () => ({}), ApiError: Error, BASE: '/api/v1' },
|
||||
@@ -72,10 +79,24 @@ function fakeRg() {
|
||||
if (extensions.has(slot)) throw new Error(`slot "${slot}" already filled`)
|
||||
extensions.set(slot, { id, Component })
|
||||
},
|
||||
// The INVERTED direction (core API 1.6.0): this module declares a place on
|
||||
// its OWN page and core fills it. Core enforces the namespace and the
|
||||
// contribution name, so the fake does too — a chunk that declared an
|
||||
// unnamespaced slot, or asked for a contribution core does not offer, would
|
||||
// pass here and throw in a browser.
|
||||
declareModuleSlot(id, name, options = {}) {
|
||||
if (!name.startsWith(`${id}.`)) throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
|
||||
if (declaredSlots.has(name)) throw new Error(`extension slot "${name}" already declared`)
|
||||
const wants = options.core ?? null
|
||||
if (wants !== null && !CORE_CONTRIBUTIONS.includes(wants)) {
|
||||
throw new Error(`declareModuleSlot: "${name}" asks for core contribution "${wants}", which core does not offer`)
|
||||
}
|
||||
declaredSlots.set(name, wants)
|
||||
},
|
||||
routesFor: (area) => routes[area],
|
||||
navFor: (area) => nav[area],
|
||||
},
|
||||
_read: () => ({ routes, nav, providers, extensions }),
|
||||
_read: () => ({ routes, nav, providers, extensions, declaredSlots }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +119,8 @@ const it = (name, fn) => test(name, { skip: skip && 'no dist/entry.js — run np
|
||||
|
||||
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.public.length, 13)
|
||||
assert.equal(routes.admin.length, 8)
|
||||
assert.equal(routes.player.length, 2)
|
||||
for (const area of ['public', 'admin', 'player']) {
|
||||
for (const r of routes[area]) {
|
||||
@@ -166,7 +187,7 @@ it('a nav row that gates on a feature is gated by a namespace this module provid
|
||||
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', () => {
|
||||
it('fills the three CORE extension slots, each with a component', () => {
|
||||
const { extensions } = registered
|
||||
assert.deepEqual(
|
||||
[...extensions.keys()].sort(),
|
||||
@@ -198,3 +219,34 @@ it('registers under exactly one module id, matching the manifest', () => {
|
||||
])
|
||||
assert.deepEqual([...owners], [manifest.id])
|
||||
})
|
||||
|
||||
it('declares its own guild slots, each naming the core contribution it wants', () => {
|
||||
// The inverted direction (TEAMS.md Part 3). Teams are a core primitive with no
|
||||
// core page: core owns the activity feed and the forum, this module owns the
|
||||
// word "guild", so this module declares the places and core puts them in.
|
||||
//
|
||||
// THREE slots rather than one because a slot holds one component: stacking the
|
||||
// feed, the forum and the notification control into a single fill would take
|
||||
// away this module's ability to place them separately on its own page — and it
|
||||
// does place them separately, the control above the roster and the other two
|
||||
// below it.
|
||||
//
|
||||
// The second argument is what actually gets core's content here. **Core offers
|
||||
// a contribution and never names a slot** — the first cut of this reached only
|
||||
// this module, because core filled the literal name `uo.guild.detail` and any
|
||||
// other game's page went empty with no error.
|
||||
assert.deepEqual([...registered.declaredSlots.entries()], [
|
||||
['uo.guild.detail', 'team.activity'],
|
||||
['uo.guild.forum', 'team.forum'],
|
||||
['uo.guild.header', 'team.notify'],
|
||||
])
|
||||
})
|
||||
|
||||
it('every declared slot is rendered by the page that owns it', () => {
|
||||
// A slot nothing renders is a slot core fills into the void. Asserted against
|
||||
// the source rather than the chunk, since the chunk is minified.
|
||||
const page = fs.readFileSync(path.resolve(HERE, '..', 'src', 'routes', 'public', 'Guild.jsx'), 'utf8')
|
||||
for (const name of registered.declaredSlots.keys()) {
|
||||
assert.match(page, new RegExp(`name="${name.replace(/\./g, '\.')}"`))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "uo",
|
||||
"name": "Ultima Online",
|
||||
"version": "0.3.0",
|
||||
"coreApi": "^1.3.0",
|
||||
"version": "0.6.0",
|
||||
"coreApi": "^1.10.0",
|
||||
"server": "server/index.js",
|
||||
"client": { "entry": "client/dist/entry.js" },
|
||||
"schema": "server/db/schema.sql",
|
||||
|
||||
385
routes.manifest.json
Normal file
385
routes.manifest.json
Normal file
@@ -0,0 +1,385 @@
|
||||
{
|
||||
"$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/assets",
|
||||
"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/guilds/:id",
|
||||
"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/assets/import",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/shard/assets/warm",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
64
scripts/sonar-test-reporter.mjs
Normal file
64
scripts/sonar-test-reporter.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
// Custom node:test reporter that emits SonarQube's Generic Test Execution XML.
|
||||
//
|
||||
// Node's built-in reporters give us coverage (`lcov`) and pass/fail output
|
||||
// (`spec`/`tap`/`junit`), but SonarQube's "Unit Tests" measure is fed by a
|
||||
// SEPARATE report in *its own* format via `sonar.testExecutionReportPaths` — the
|
||||
// lcov report only populates Coverage, which is why the dashboard shows coverage
|
||||
// while the Unit Tests tile stays "-". This reporter produces that missing report.
|
||||
//
|
||||
// Format: https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/test-coverage/generic-test-data/
|
||||
// <testExecutions version="1">
|
||||
// <file path="server/test/foo.test.js">
|
||||
// <testCase name="..." duration="12"/> <!-- duration = integer ms -->
|
||||
// </file>
|
||||
// </testExecutions>
|
||||
//
|
||||
// Paths are emitted repo-root-relative (POSIX separators) so they match the
|
||||
// `sonar.tests` roots; the workflow runs `node --test` from the repo root, so the
|
||||
// absolute `file` on each event strips cleanly against process.cwd().
|
||||
import path from 'node:path'
|
||||
|
||||
function xmlEscape(s) {
|
||||
return String(s).replace(/[<>&"']/g, (c) => ({
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[c])
|
||||
}
|
||||
|
||||
export default async function* sonarTestReporter(source) {
|
||||
const byFile = new Map()
|
||||
const cwd = process.cwd()
|
||||
|
||||
for await (const event of source) {
|
||||
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue
|
||||
const d = event.data
|
||||
// Skip the container events (a `describe` suite) and anything without a file
|
||||
// — only real test cases go in the report, so the count matches the runner's.
|
||||
if (!d.file || (d.details && d.details.type === 'suite')) continue
|
||||
|
||||
const rel = path.relative(cwd, d.file).split(path.sep).join('/')
|
||||
if (!byFile.has(rel)) byFile.set(rel, [])
|
||||
byFile.get(rel).push({
|
||||
name: d.name,
|
||||
duration: Math.max(0, Math.round(d.details?.duration_ms ?? 0)),
|
||||
failed: event.type === 'test:fail',
|
||||
skipped: Boolean(d.skip || d.todo),
|
||||
})
|
||||
}
|
||||
|
||||
yield '<?xml version="1.0" encoding="UTF-8"?>\n<testExecutions version="1">\n'
|
||||
for (const [file, cases] of byFile) {
|
||||
yield ` <file path="${xmlEscape(file)}">\n`
|
||||
for (const c of cases) {
|
||||
const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"`
|
||||
if (c.failed) yield ` <testCase ${attrs}><failure message="test failed"/></testCase>\n`
|
||||
else if (c.skipped) yield ` <testCase ${attrs}><skipped/></testCase>\n`
|
||||
else yield ` <testCase ${attrs}/>\n`
|
||||
}
|
||||
yield ' </file>\n'
|
||||
}
|
||||
yield '</testExecutions>\n'
|
||||
}
|
||||
@@ -33,6 +33,7 @@ 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')
|
||||
const shardItemArt = require('./model/shardAssets/shardItemArt.model')
|
||||
|
||||
/**
|
||||
* Best-effort startup probe of the uo-link sidecar.
|
||||
@@ -80,11 +81,18 @@ async function onBoot() {
|
||||
// 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.
|
||||
// Refresh the cliloc table (UO's id → display-string map).
|
||||
//
|
||||
// **On an install with uo-link configured this imports nothing** — protocol 8
|
||||
// moved the base table to the shard, and asking for it would put a sidecar
|
||||
// round trip in the boot sequence to answer a question whose answer is "no"
|
||||
// except after a client patch. That is an operator action, so importing is an
|
||||
// operator action: Admin → Shard (docs/link/v8.md §9).
|
||||
//
|
||||
// Without a shard link it is the old file pipeline, unchanged: 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
|
||||
@@ -107,6 +115,15 @@ async function onBoot() {
|
||||
// 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 }))
|
||||
|
||||
// Item and land pictures for the keys this site's own rows name (§11, phase 5).
|
||||
//
|
||||
// A timer rather than a boot pass, and it is the same rule §9.2 set for clilocs:
|
||||
// **boot does not call the shard.** The first pass is one interval away, so an
|
||||
// unreachable sidecar costs a log line rather than a startup delay, and an
|
||||
// operator who has just configured the bridge does not have to restart to get
|
||||
// pictures. `unref`ed, so it never holds shutdown open.
|
||||
shardItemArt.startWarming()
|
||||
}
|
||||
|
||||
async function onShutdown() {
|
||||
@@ -114,6 +131,7 @@ async function onShutdown() {
|
||||
// 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.
|
||||
shardItemArt.stopWarming() // stop the item-art warm pass
|
||||
uoLinkSocket.stop() // close the uo-link WS ingest client
|
||||
shardBroadcast.closeAll() // end any open shard live-feed SSE streams
|
||||
}
|
||||
|
||||
201
server/commands/guild.command.js
Normal file
201
server/commands/guild.command.js
Normal file
@@ -0,0 +1,201 @@
|
||||
// ── `/guild` — the first chat command through the module contract ──────────
|
||||
//
|
||||
// Registered with `api.registerSlashCommands` (MODULE_API 1.6.0, TEAMS.md §7.1).
|
||||
// The definition and this handler live here; the bot pulls the definition over
|
||||
// the app's internal API and runs nothing of ours. Nothing in this file knows
|
||||
// what Discord is — it is handed an `actor` and returns an envelope, and the
|
||||
// same handler would serve a second platform unchanged.
|
||||
//
|
||||
// **Why `/guild` and not `/team`.** Teams are core's primitive and "guild" is
|
||||
// this module's word for one; core does not own the word, so it does not publish
|
||||
// the noun in a channel either. That is the same correction that deleted core's
|
||||
// Team pages in phase 3, applied to the chat surface.
|
||||
//
|
||||
// **The audience rungs are enforced here, exactly as they are on the website.**
|
||||
// A shard whose `guilds` feature is gated to staff does not become public
|
||||
// because the question arrived over Discord — this handler resolves the caller's
|
||||
// rung through the same `shardVisibility` config the routes use. It is the one
|
||||
// piece of this file that is a security boundary rather than presentation.
|
||||
const core = require('../core')
|
||||
const db = require('../model/teamProvider/teamProvider.db')
|
||||
const provider = require('../model/teamProvider/teamProvider.model')
|
||||
const visibility = require('../utils/shardVisibility')
|
||||
|
||||
const log = core.logger('guild-command')
|
||||
|
||||
// How many guilds the no-argument form lists. A Discord embed takes 25 fields;
|
||||
// ten is a summary a person reads rather than a table they scroll past.
|
||||
const LIST_LIMIT = 10
|
||||
|
||||
/**
|
||||
* Where the caller sits on this module's ladder.
|
||||
*
|
||||
* The same resolution `projectRoster` does, and it is duplicated in shape rather
|
||||
* than shared because the inputs differ: that one is handed a viewer core
|
||||
* described, this one an actor. Both end at `viewerLevel`, and both answer
|
||||
* `anonymous` DIRECTLY for a caller with no site account — handing `viewerLevel`
|
||||
* a synthetic empty request makes it fall through to `auth.getUserFromRequest`,
|
||||
* which expects real cookies and throws (the phase 3 bug).
|
||||
*/
|
||||
async function levelFor(actor) {
|
||||
if (!actor || !actor.userId) return 'anonymous'
|
||||
return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } })
|
||||
}
|
||||
|
||||
// The nudge §9 answer 5 asks for, and only when it is TRUE.
|
||||
//
|
||||
// **Linking reaches exactly two rungs and no further.** Signing in gets a caller
|
||||
// to `logged_in` and linking a game account to `player`; `staff` and `admin` are
|
||||
// roles an operator grants and no amount of linking will earn. So a shard that
|
||||
// gates guilds to staff refuses an unlinked caller WITHOUT the invitation —
|
||||
// telling them to link would be telling them to do something that changes
|
||||
// nothing, which is worse than saying no.
|
||||
//
|
||||
// The live walk found this: gated to `staff`, the refusal still read "this shard
|
||||
// shows guild information to linked players".
|
||||
const LINKING_REACHES = new Set(['logged_in', 'player'])
|
||||
|
||||
function linkPrompt(actor, audience) {
|
||||
if (actor.isLinked) return null
|
||||
if (!LINKING_REACHES.has(audience)) return null
|
||||
return 'Link your account on the site to see more — this shard shows guild information to linked players.'
|
||||
}
|
||||
|
||||
const pageUrl = (externalId) =>
|
||||
`${core.baseUrl}${provider.pageUrlTemplate.replace('{externalId}', externalId)}`
|
||||
|
||||
// Match on abbreviation first, then an exact name, then a unique prefix. Players
|
||||
// type the abbreviation — it is what appears over a character's head — and a
|
||||
// wrong-guild answer is worse than "say which one".
|
||||
function findByName(rows, wanted) {
|
||||
const needle = wanted.trim().toLowerCase()
|
||||
const byAbbr = rows.filter((r) => (r.abbr || '').toLowerCase() === needle)
|
||||
if (byAbbr.length === 1) return { guild: byAbbr[0] }
|
||||
const exact = rows.filter((r) => r.name.toLowerCase() === needle)
|
||||
if (exact.length === 1) return { guild: exact[0] }
|
||||
const partial = rows.filter((r) => r.name.toLowerCase().includes(needle))
|
||||
if (partial.length === 1) return { guild: partial[0] }
|
||||
if (partial.length > 1) return { ambiguous: partial.slice(0, LIST_LIMIT) }
|
||||
return {}
|
||||
}
|
||||
|
||||
/** The counts for one guild, from the roster rather than the board's assertions. */
|
||||
async function summarise(guild) {
|
||||
const members = await db.listGuildMembers(guild.id)
|
||||
const leaders = members
|
||||
.filter((m) => Number(m.rank) >= db.LEADER_RANK)
|
||||
.map((m) => m.name)
|
||||
// The board's founder-leader is folded in as a floor, the same way
|
||||
// getTeamLeaders does it: it arrives on a different frame, and a shard whose
|
||||
// roster predates the rank amendment has no other leadership signal.
|
||||
if (guild.leader_name && !leaders.includes(guild.leader_name)) leaders.push(guild.leader_name)
|
||||
|
||||
return {
|
||||
// `members`/`online` are the BOARD's counts, which is what the shard asserts;
|
||||
// the roster is what it enumerated, and the two legitimately disagree for the
|
||||
// moment between a membership change and the sweep that reports it. The
|
||||
// assertion is the more current of the two, so it is what is shown.
|
||||
members: guild.members,
|
||||
online: guild.online,
|
||||
linked: members.filter((m) => provider.resolveUserId(m) !== null).length,
|
||||
leaders,
|
||||
}
|
||||
}
|
||||
|
||||
async function detail(guild, actor, audience) {
|
||||
const counts = await summarise(guild)
|
||||
const fields = [
|
||||
{ name: 'Members', value: String(counts.members ?? '—'), inline: true },
|
||||
{ name: 'Online', value: String(counts.online ?? 0), inline: true },
|
||||
{ name: 'Linked accounts', value: String(counts.linked), inline: true },
|
||||
]
|
||||
if (counts.leaders.length) {
|
||||
fields.push({ name: 'Leaders', value: counts.leaders.join(', ') })
|
||||
}
|
||||
return {
|
||||
title: guild.abbr ? `${guild.name} [${guild.abbr}]` : guild.name,
|
||||
text: guild.alliance ? `Alliance: ${guild.alliance}` : undefined,
|
||||
fields,
|
||||
url: pageUrl(guild.id),
|
||||
notice: linkPrompt(actor, audience),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/guild [name]` — one guild's summary, or the shard's largest guilds.
|
||||
*
|
||||
* Never throws for an ordinary miss: "no such guild" and "the shard is offline"
|
||||
* are answers, and letting either become an exception would turn a routine
|
||||
* question into "that command failed" with nothing an operator could act on.
|
||||
*/
|
||||
async function handler({ options, actor }) {
|
||||
const config = await visibility.getConfig()
|
||||
const feature = config.guilds
|
||||
|
||||
// An admin turned guilds off. The switch means "this shard does not publish
|
||||
// guild data" — over any surface, to anyone, staff included.
|
||||
if (!feature || !feature.enabled) {
|
||||
return { text: 'This shard does not publish guild information.', ephemeral: true }
|
||||
}
|
||||
|
||||
const level = await levelFor(actor)
|
||||
if (!visibility.meets(level, feature.audience)) {
|
||||
return {
|
||||
text: 'Guild information on this shard is not shown to your account.',
|
||||
ephemeral: true,
|
||||
notice: linkPrompt(actor, feature.audience),
|
||||
}
|
||||
}
|
||||
|
||||
// The provider's own staleness guard, asked before any board read: an
|
||||
// unreachable sidecar means the board is a snapshot of unknown age, and
|
||||
// reporting it as current here would contradict what every other surface says.
|
||||
const ready = await provider.boardIsCurrent()
|
||||
if (!ready.ok) {
|
||||
log.info('guild command answered offline', { reason: ready.reason })
|
||||
return { text: 'The shard is not connected right now, so guild information may be out of date.', ephemeral: true }
|
||||
}
|
||||
|
||||
const rows = await db.listGuilds()
|
||||
if (!rows.length) return { text: 'No guilds are on the board yet.', ephemeral: true }
|
||||
|
||||
const wanted = options && typeof options.name === 'string' ? options.name : null
|
||||
if (!wanted) {
|
||||
const top = [...rows].sort((a, b) => (b.members || 0) - (a.members || 0)).slice(0, LIST_LIMIT)
|
||||
return {
|
||||
// Not "Guilds on <host>": `ctx.site` carries a base URL and no brand name,
|
||||
// so naming the deployment here can only mean printing its hostname into
|
||||
// an embed title, which is noise on a shard's own Discord server.
|
||||
title: 'Guilds on this shard',
|
||||
fields: top.map((g) => ({
|
||||
name: g.abbr ? `${g.name} [${g.abbr}]` : g.name,
|
||||
value: `${g.members || 0} members · ${g.online || 0} online`,
|
||||
inline: true,
|
||||
})),
|
||||
notice: linkPrompt(actor, feature.audience),
|
||||
}
|
||||
}
|
||||
|
||||
const { guild, ambiguous } = findByName(rows, wanted)
|
||||
if (ambiguous) {
|
||||
return {
|
||||
text: `Several guilds match “${wanted}”: ${ambiguous.map((g) => g.name).join(', ')}`,
|
||||
ephemeral: true,
|
||||
}
|
||||
}
|
||||
if (!guild) return { text: `No guild matches “${wanted}”.`, ephemeral: true }
|
||||
return detail(guild, actor, feature.audience)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'guild',
|
||||
description: 'Show a guild on this shard — members, who is online, and its leaders',
|
||||
options: [
|
||||
{ name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
|
||||
],
|
||||
// Everyone, deliberately. The gate that matters is the shard's own audience
|
||||
// rung, resolved inside the handler — `access: 'linked'` would hide the command
|
||||
// from exactly the unlinked members §9 answer 5 wants to invite to link.
|
||||
access: 'everyone',
|
||||
handler,
|
||||
}
|
||||
46
server/config/clientPaths.js
Normal file
46
server/config/clientPaths.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// ── The module's own client paths, in one place ────────────────────────────
|
||||
//
|
||||
// Every link a notification puts in front of a player is a path into this
|
||||
// module's SPA routes, and Phase 11b's live walk found that not one of them was
|
||||
// right: the declared examples all read `/shard/…` (module.json's `mounts`), the
|
||||
// bodies hard-coded a mixture of `/shard/…` and `/player/uo/…`, and the mapper
|
||||
// populated none of the URL variables at all — so every in-universe letter shipped
|
||||
// with an empty href and every template preview showed a dead one.
|
||||
//
|
||||
// **The prefix is the module ID, not the mount.** `registry.registerRoutes`
|
||||
// prefixes a module's client routes with `<id>/` and nothing else
|
||||
// (`client/src/modules/registry.js`), which is why `module.json`'s `mounts` is not
|
||||
// the answer — that field says what the module CLAIMS, and the router says where
|
||||
// it landed. `client/src/entry.jsx`'s own `registerNav` is the check: the hrefs it
|
||||
// gives the sidebar are these, and if the two ever disagree the sidebar is right.
|
||||
//
|
||||
// Kept server-side and shared by BOTH the trigger declarations (their `example`s,
|
||||
// which the template editor previews and test-sends with) and the seeded bodies,
|
||||
// so a route that moves is one edit rather than thirty.
|
||||
|
||||
const ID = 'uo'
|
||||
|
||||
const PATHS = {
|
||||
shard: `/${ID}/shard`,
|
||||
champs: `/${ID}/champs`,
|
||||
guilds: `/${ID}/guilds`,
|
||||
governors: `/${ID}/governors`,
|
||||
houses: `/${ID}/houses`,
|
||||
atlas: `/${ID}/atlas`,
|
||||
leaderboards: `/${ID}/leaderboards`,
|
||||
market: `/${ID}/market`,
|
||||
// Self-service and staff areas sit under core's own wrappers, so they carry
|
||||
// core's prefix as well as the module's.
|
||||
characters: `/player/${ID}/characters`,
|
||||
ops: `/admin/${ID}/ops`,
|
||||
}
|
||||
|
||||
/** One guild's roster, when the frame names a guild; the list otherwise. */
|
||||
const guildPath = (guildId) =>
|
||||
(guildId === undefined || guildId === null ? PATHS.guilds : `${PATHS.guilds}/${guildId}`)
|
||||
|
||||
/** One vendor's page, when the frame names one; the market otherwise. */
|
||||
const vendorPath = (serial) =>
|
||||
(serial ? `${PATHS.market}/vendors/${serial}` : PATHS.market)
|
||||
|
||||
module.exports = { PATHS, guildPath, vendorPath }
|
||||
1054
server/config/engagementSeeds.js
Normal file
1054
server/config/engagementSeeds.js
Normal file
File diff suppressed because it is too large
Load Diff
99
server/config/shardAudiences.js
Normal file
99
server/config/shardAudiences.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// ── module-uo's registered audiences ───────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §5.1a, and this module's first three. An audience is a NAMED SET
|
||||
// OF PEOPLE an operator can point a rule at, or compose into a saved segment with
|
||||
// and/or/not — "the members of guild 1042", "the governors", "everyone who has
|
||||
// linked a game account".
|
||||
//
|
||||
// **This is a different mechanism from the `members` audience the guild triggers
|
||||
// use, and the difference is worth stating because the words are the same.** A
|
||||
// guild event is about the members of THAT guild, which is a different answer for
|
||||
// every firing; a segment's parameters are CONSTANTS, so it cannot express it,
|
||||
// and the access-checked set travels on the envelope as `recipientUserIds`
|
||||
// instead (Phase 6, decision 2). What is here answers the same question every
|
||||
// time it is asked, which is exactly what makes it composable and storable.
|
||||
//
|
||||
// **Four rules, all of them from §5.1a:**
|
||||
//
|
||||
// 1. **Core learns no game vocabulary.** It knows an id, a label, a parameter
|
||||
// list and a `resolve` it may call. It has never heard of a guild.
|
||||
// 2. **The resolver returns user ids and NOTHING else.** It is not handed a
|
||||
// template, a channel or an address and cannot enumerate them. A module still
|
||||
// cannot send mail, and this must not become the door that lets it — core
|
||||
// maps ids to addresses on its own side, after preferences, suppression and
|
||||
// the verification gate.
|
||||
// 3. **Composition narrows, never widens.** The `ceiling` below is the widest
|
||||
// this audience can EVER resolve to; a segment takes the narrowest ceiling it
|
||||
// contains, and the result is still checked against the trigger's own.
|
||||
// 4. **An uninstalled module's audience goes dormant**, resolving empty, rather
|
||||
// than erroring or silently reaching a different set of people.
|
||||
//
|
||||
// All three ceiling at `members`, and none higher. `members` is the lattice value
|
||||
// for "a module-declared list", and it is the honest one here: these sets are not
|
||||
// "everyone signed in" narrowed down, they are lists this module happens to know.
|
||||
//
|
||||
// Every resolver is bounded by `shardLinks.MAX_AUDIENCE` through the queries it
|
||||
// calls, and every one of them fails to the EMPTY set rather than throwing — a
|
||||
// dormant audience is a rule that reaches nobody, which is §5.1a rule 4's
|
||||
// behaviour and much better than a rule that 500s the engine.
|
||||
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const shardState = require('../model/shardState/shardState.model')
|
||||
const core = require('../core')
|
||||
|
||||
const log = core.logger('shard-audiences')
|
||||
|
||||
// One wrapper, so every resolver has the same failure behaviour and none of them
|
||||
// has to remember it. A resolver that throws would fail the whole enqueue for
|
||||
// every other audience in the same segment.
|
||||
const safely = (id, fn) => async (params) => {
|
||||
try {
|
||||
return await fn(params || {})
|
||||
} catch (err) {
|
||||
log.warn('audience resolve failed — treating as empty', { audience: id, message: err.message })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const AUDIENCES = [
|
||||
{
|
||||
// `namespaced()` requires the module's own prefix, so these are declared with
|
||||
// it rather than relying on core to add one. Audiences have their own id
|
||||
// space — an audience names a set of PEOPLE and a trigger names an EVENT — so
|
||||
// `uo.guild.members` here does not collide with any trigger id.
|
||||
id: 'uo.guild.members',
|
||||
label: 'Members of a guild',
|
||||
description: 'Everyone with a linked game account on one guild\'s roster.',
|
||||
params: [{ id: 'guildId', type: 'int', required: true }],
|
||||
ceiling: 'members',
|
||||
resolve: safely('uo.guild.members', async ({ guildId }) => {
|
||||
if (guildId == null) return []
|
||||
const accounts = await shardState.listGuildMemberAccounts(guildId)
|
||||
return shardLinks.userIdsForAccounts(accounts)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'uo.governors',
|
||||
label: 'Town governors',
|
||||
description: 'Everyone with a linked game account currently holding a city governorship.',
|
||||
params: [],
|
||||
ceiling: 'members',
|
||||
resolve: safely('uo.governors', async () => {
|
||||
const accounts = await shardState.listGovernorAccounts()
|
||||
return shardLinks.userIdsForAccounts(accounts)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'uo.linked.accounts',
|
||||
label: 'Players with a linked game account',
|
||||
// The set an operator reaches for first, and — more usefully — the one a
|
||||
// `not` composes against: "everyone who has NOT linked" is the audience for
|
||||
// the message that asks them to.
|
||||
description: 'Every website user who has linked at least one game account.',
|
||||
params: [],
|
||||
ceiling: 'members',
|
||||
resolve: safely('uo.linked.accounts', () => shardLinks.allLinkedUserIds()),
|
||||
},
|
||||
]
|
||||
|
||||
module.exports = { AUDIENCES }
|
||||
871
server/config/shardTriggers.js
Normal file
871
server/config/shardTriggers.js
Normal file
@@ -0,0 +1,871 @@
|
||||
// ── module-uo's engagement triggers ────────────────────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md §8.6 and Phase 11. The twin of `config/shardStreams.js`: that
|
||||
// file declares which shard events a player may get a content-free PUSH tickle
|
||||
// for, and this one declares the PAYLOAD CONTRACT behind an event — what a rule
|
||||
// may fire on, what a template may interpolate, and the widest audience an
|
||||
// operator may ever give it.
|
||||
//
|
||||
// **One namespace, two facets** (§7.2, the org lead's Phase 2 decision). A
|
||||
// trigger id and a stream id live in the same space and an id has exactly one
|
||||
// owner across both, so the seven grandfathered stream ids in `shardStreams.js`
|
||||
// (`idoc.warning`, `house.idoc`, …) are ALSO this module's for trigger purposes.
|
||||
// Nothing below reuses one: the trigger ids here are the `uo.*`-prefixed names
|
||||
// §8.6 specifies, and they are new. A trigger-only id gets email and in-app
|
||||
// preferences and no push toggle, which is correct — `allStreams()` serves the
|
||||
// stream facet only, so the shipped Android client's catalog is unchanged.
|
||||
//
|
||||
// **Every ✅ row of §8.6 is here except four, and each carve-out is recorded**
|
||||
// in ENGAGEMENT.md §8.6 with its reason rather than being silently absent:
|
||||
//
|
||||
// • `uo.market.item_listed` — a saved SEARCH, not a trigger. Its audience is
|
||||
// "users whose stored query matches this listing" and no per-user query store
|
||||
// exists anywhere in the tree.
|
||||
// • `uo.guild.joined` — core's `team.member.joined` already fires for it. A UO
|
||||
// guild IS a Team and this module is the Team provider, so `teamSync` emits
|
||||
// on every roster reconcile; a second trigger would be two mails for one join.
|
||||
// `uo.guild.left` and `uo.guild.disbanded` DO ship — core has neither.
|
||||
// • `uo.link.requested` — no addressable recipient by construction (the account
|
||||
// is not yet linked, which is the point of the event) and a ~5-minute TTL no
|
||||
// channel can beat.
|
||||
// • `uo.points.rank_changed`'s personal half — `points.board`'s `top[]` names a
|
||||
// mobile SERIAL and `shard_account_links` is keyed by ACCOUNT. The board-change
|
||||
// feed ships at `subscribers`; "you were pushed out" does not.
|
||||
//
|
||||
// **Three rules every declaration below obeys, all of them enforced at
|
||||
// registration** (`registries.js`), so a mistake here is a boot failure rather
|
||||
// than a defect discovered in someone's mailbox:
|
||||
//
|
||||
// 1. **`ceiling` is required and there is no default.** It is the widest
|
||||
// audience a rule may ever be given (G24), re-checked at save AND at send.
|
||||
// `uo.cheat.detected` is why the lattice exists: `owner` would mail the
|
||||
// cheat report to the player who was detected, and `staff` is the answer.
|
||||
// 2. **Every variable carries an `example`.** It is what the template editor
|
||||
// previews and test-sends with; without one, testing a template needs a live
|
||||
// game event, which is how template systems ship untested (§4.3 property 3).
|
||||
// 3. **A `url` variable is site-RELATIVE** and validated as such. A payload
|
||||
// value ends up in an href in an email, and `//evil.test/x` passes an "is it
|
||||
// rooted" check while being protocol-relative.
|
||||
//
|
||||
// **Nothing here emits.** `utils/shardEngagement.js` is the mapper that turns a
|
||||
// wire frame into a call; this file is only the contract. Keeping them apart is
|
||||
// what lets the declarations be read as a catalogue and diffed against §8.6.
|
||||
|
||||
// Every trigger's `version`. Bumped per declaration when a variable's MEANING
|
||||
// changes, not when one is added — an added optional is what `required: false`
|
||||
// is for, and a stored rule keeps working across it.
|
||||
const V1 = 1
|
||||
|
||||
|
||||
// ── The presentational fragments (Phase 11b, decision 8) ────────────────────────
|
||||
//
|
||||
// Sixteen of these triggers render through an IN-UNIVERSE body — a letter from
|
||||
// the Office of Deeds, a herald's notice, a dispatch from Lord Blackthorn's
|
||||
// court. A letter is a sentence, and a template has no conditionals by design
|
||||
// (`interpolate.js`), so an unset optional interpolates to the EMPTY STRING and
|
||||
// leaves a hole mid-clause: "The house , in , stands in peril."
|
||||
//
|
||||
// The fix is Phase 5a's `forWhom` precedent, not a template language: the
|
||||
// ternary stays in `utils/shardEngagement.js` and its RESULT arrives here as a
|
||||
// declared optional. Two shapes, and each `example` shows which it is —
|
||||
//
|
||||
// • a LABEL always has a value, so it can carry a sentence's spine;
|
||||
// • a TRAILING FRAGMENT may be empty and leads with its OWN SPACE, so the
|
||||
// sentence closes cleanly without it (`{{slainBy}}.` → "has fallen.").
|
||||
//
|
||||
// They are `required: false` and therefore additive: adding one is not a
|
||||
// version bump (§4.3 — that is what `required: false` is for), and a rule or a
|
||||
// template written before them keeps working unchanged.
|
||||
|
||||
// ── Owned asset at risk — the flagship family ──────────────────────────────
|
||||
//
|
||||
// All three resolve through the frame's `ownerAcct` → `shard_account_links` →
|
||||
// a website user, which is what `ownerUserId` on the envelope carries. A house
|
||||
// or vendor whose owner never linked an account is nobody to notify, and the
|
||||
// mapper drops it rather than treating it as an error.
|
||||
|
||||
const OWNED_ASSET = [
|
||||
{
|
||||
id: 'uo.house.idoc_warning',
|
||||
label: 'Your house is decaying',
|
||||
description: 'One of your houses reached a late decay stage and will collapse if it is not refreshed.',
|
||||
kind: 'event',
|
||||
// The house, not the owner. A player with three decaying houses should hear
|
||||
// about all three; a cooldown keyed on them would report one and swallow the
|
||||
// rest. This is the case that makes `subjectKey` worth having at all.
|
||||
subjectKey: 'houseSerial',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
|
||||
description: 'The house, as the shard names it. Also the cooldown subject.' },
|
||||
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
|
||||
description: 'The house sign\'s name, when it has one.' },
|
||||
{ name: 'stage', type: 'string', required: true, example: 'Greatly',
|
||||
description: 'The decay stage it just entered: Slightly, Somewhat, Fairly, Greatly or IDOC.' },
|
||||
{ name: 'previousStage', type: 'string', required: false, example: 'Fairly',
|
||||
description: 'The stage it was in before.' },
|
||||
{ name: 'region', type: 'string', required: false, example: 'Britain',
|
||||
description: 'The named region the house stands in.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
||||
description: 'Facet and coordinates, already formatted for reading.' },
|
||||
// **Protocol 5, and both are `required: false` on purpose.** A shard still
|
||||
// running a v4 overlay emits no `schedule` at all, and a dynamic-decay shard
|
||||
// omits `estimatedCollapse` at every stage before IDOC because ServUO draws
|
||||
// each stage's duration at random when the stage is entered. So the mail has
|
||||
// to read correctly without them — which is exactly what an optional
|
||||
// variable and a template that omits an absent one give you.
|
||||
{ name: 'nextStage', type: 'datetime', required: false, example: '2026-09-01T20:33:15Z',
|
||||
description: 'When it leaves this stage. Absent under static decay, which keeps no stage clock.' },
|
||||
{ name: 'estimatedCollapse', type: 'datetime', required: false, example: '2026-09-06T20:33:15Z',
|
||||
description: 'When it collapses — present ONLY when the shard can state it exactly. Absent is "not knowable", never "not yet read".' },
|
||||
{ name: 'lastRefreshed', type: 'datetime', required: false, example: '2026-08-25T17:21:14Z',
|
||||
description: 'When the house was last refreshed.' },
|
||||
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
|
||||
description: 'Site-relative path to the IDOC page.' },
|
||||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
|
||||
{ name: 'stageLabel', type: 'string', required: false, example: 'greatly worn',
|
||||
description: 'The decay stage as words rather than as the wire\'s enum.' },
|
||||
{ name: 'whereLine', type: 'string', required: false, example: 'Recorded at: Felucca 1480, 1600. Stage entered: Greatly.',
|
||||
description: 'A whole detail line, assembled from the parts the frame actually carried. Absent when it carried none.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.house.collapsed',
|
||||
label: 'Your house collapsed',
|
||||
description: 'One of your houses fell — the bad news, so that it is not a surprise.',
|
||||
kind: 'event',
|
||||
subjectKey: 'houseSerial',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
|
||||
description: 'The house, as the shard names it. Also the cooldown subject.' },
|
||||
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
|
||||
description: 'The house sign\'s name, when it had one.' },
|
||||
{ name: 'region', type: 'string', required: false, example: 'Britain',
|
||||
description: 'The named region it stood in.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
||||
description: 'Facet and coordinates, already formatted for reading.' },
|
||||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it had no name.' },
|
||||
{ name: 'whereLine', type: 'string', required: false, example: 'Last recorded at: Felucca 1480, 1600.',
|
||||
description: 'A whole detail line, assembled from the parts the frame actually carried.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// **The good outcome, and it exists because a delay without a cancel is just
|
||||
// a late mail** (ENGAGEMENT.md §4.2a). `uo.house.idoc_warning` ships
|
||||
// `delay_seconds: 900` so an owner who repairs the house inside the window is
|
||||
// never told it is in peril — and until Phase 11b's live walk there was
|
||||
// nothing that could cancel it: the mapper returned early on every transition
|
||||
// that was not a late stage, so a refresh reached the engine as silence. The
|
||||
// wire already carried the transition; only this declaration was missing.
|
||||
//
|
||||
// It is a real notification as well as a cancel signal (decision 11), so it
|
||||
// carries the labels a body needs rather than the serial alone.
|
||||
id: 'uo.house.refreshed',
|
||||
label: 'Your house was refreshed',
|
||||
description: 'One of your houses was refreshed and is out of danger. Cancels a pending decay warning.',
|
||||
kind: 'event',
|
||||
// The SAME subject as the warning it cancels, and that is load-bearing rather
|
||||
// than tidy: `outboxDb.cancel` matches on (rule, subject_key), so a refresh
|
||||
// whose subject were anything else would cancel nothing.
|
||||
subjectKey: 'houseSerial',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'houseSerial', type: 'string', required: true, example: '0x400142F9',
|
||||
description: 'The house, as the shard names it. Also the cooldown subject, and what the cancellation matches on.' },
|
||||
{ name: 'houseName', type: 'string', required: false, example: 'Millrace',
|
||||
description: 'The house sign\'s name, when it has one.' },
|
||||
{ name: 'previousStage', type: 'string', required: false, example: 'Greatly',
|
||||
description: 'The decay stage it was in before it was refreshed.' },
|
||||
{ name: 'region', type: 'string', required: false, example: 'Britain',
|
||||
description: 'The named region the house stands in.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 1480, 1600',
|
||||
description: 'Facet and coordinates, already formatted for reading.' },
|
||||
{ name: 'houseUrl', type: 'url', required: false, example: '/uo/houses',
|
||||
description: 'Site-relative path to the housing page.' },
|
||||
{ name: 'houseLabel', type: 'string', required: false, example: '“The Silver Anvil”, in Britain',
|
||||
description: 'A label: the house\'s name in quotes with its region, or its seal number when it has no name.' },
|
||||
{ name: 'fromLine', type: 'string', required: false, example: ' It stood greatly worn.',
|
||||
description: 'A trailing fragment naming the stage it was rescued from. Leads with its own space, and is empty when the frame carried no previous stage.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.vendor.expiring',
|
||||
label: 'Your vendor is about to be dismissed',
|
||||
description: 'One of your player vendors is running out of gold for its fees and will be dismissed.',
|
||||
kind: 'event',
|
||||
subjectKey: 'vendorSerial',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'vendorSerial', type: 'string', required: true, example: '0x40001234',
|
||||
description: 'The vendor, as the shard names it. Also the cooldown subject.' },
|
||||
{ name: 'shopName', type: 'string', required: false, example: 'Darrow\'s Bargains',
|
||||
description: 'The shop\'s name.' },
|
||||
{ name: 'dismissalAt', type: 'datetime', required: true, example: '2026-09-08T21:01:21Z',
|
||||
description: 'When the vendor is destroyed if nothing is deposited. Exact — unlike a house\'s collapse, there is no randomness in it.' },
|
||||
// **The int an operator narrows with**, because `conditions.js` compares a
|
||||
// declared variable against a LITERAL and has no relative-time operator:
|
||||
// "within 24 hours of dismissal" is not expressible as `dismissalAt < now +
|
||||
// 24h`. So the hours are computed at emit and the operator writes
|
||||
// `hoursRemaining is at most 24`. The mapper additionally fires only on a
|
||||
// threshold CROSSING, because `vendor.listing` is a sweep frame re-emitted
|
||||
// on any price change.
|
||||
{ name: 'hoursRemaining', type: 'int', required: true, example: 22,
|
||||
description: 'Whole hours until dismissal at the moment this fired. The value to write a rule condition against.' },
|
||||
{ name: 'periodsRemaining', type: 'int', required: false, example: 1,
|
||||
description: 'Pay ticks the vendor survives. NOT days — under the old vendor system a period is one UO day (~2 real hours).' },
|
||||
{ name: 'funds', type: 'int', required: false, example: 8204,
|
||||
description: 'Gold available to pay the fees.' },
|
||||
{ name: 'chargePerPeriod', type: 'int', required: false, example: 10548,
|
||||
description: 'What each tick deducts.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699 (Britain)',
|
||||
description: 'Where the shop stands, already formatted for reading.' },
|
||||
{ name: 'marketUrl', type: 'url', required: false, example: '/uo/market',
|
||||
description: 'Site-relative path to the market page.' },
|
||||
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
|
||||
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
|
||||
{ name: 'ledgerLine', type: 'string', required: false, example: 'On hand: 1200 gold. Charged each period: 400 gold. Periods remaining: 3.',
|
||||
description: 'The whole ledger line, assembled from the fee fields the frame carried. A pre-v5 overlay carries none, and then there is no line.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Passive income ─────────────────────────────────────────────────────────
|
||||
|
||||
const PASSIVE_INCOME = [
|
||||
{
|
||||
id: 'uo.vendor.sale',
|
||||
label: 'Your vendor sold something',
|
||||
// **The tier caveat belongs in the operator-facing text, not only in a
|
||||
// comment.** `vendor.sale` is emitted by a `PlayerVendorSale` EventSink that
|
||||
// lives in `servuo-plugins/patches/` — the opt-in patch tier — and is verified
|
||||
// only against ServUO 57.4. A shard that declined the tier emits this kind
|
||||
// never, so a rule on it is silently dormant rather than broken, and the only
|
||||
// way an operator finds out is if something says so where they are looking.
|
||||
description:
|
||||
'One of your player vendors made a sale. Requires the optional ServUO patch tier — a shard that '
|
||||
+ 'declined it never emits this event, and a rule on it stays silent.',
|
||||
kind: 'event',
|
||||
subjectKey: 'vendorSerial',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'vendorSerial', type: 'string', required: true, example: '0x2E1',
|
||||
description: 'The vendor that made the sale. Also the cooldown subject.' },
|
||||
{ name: 'itemName', type: 'string', required: true, example: 'Longsword',
|
||||
description: 'What was sold.' },
|
||||
{ name: 'amount', type: 'int', required: false, example: 1,
|
||||
description: 'How many.' },
|
||||
{ name: 'price', type: 'int', required: true, example: 100,
|
||||
description: 'What it sold for, in gold.' },
|
||||
{ name: 'commission', type: 'int', required: false, example: 0,
|
||||
description: 'Commission taken, on a commission vendor.' },
|
||||
{ name: 'shopLabel', type: 'string', required: false, example: 'thy shop “The Silver Anvil”',
|
||||
description: 'A label: the shop named, or simply \'thy vendor\' when it has no name.' },
|
||||
{ name: 'itemLine', type: 'string', required: false, example: '3 × Iron Ingot',
|
||||
description: 'A label: the item with its count when more than one was sold, the item alone otherwise.' },
|
||||
{ name: 'ledgerLine', type: 'string', required: false, example: 'Commission withheld: 5 gold.',
|
||||
description: 'The whole ledger line, or absent when the sale carried no commission.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Personal security ──────────────────────────────────────────────────────
|
||||
|
||||
const PERSONAL_SECURITY = [
|
||||
{
|
||||
id: 'uo.account.login_failed',
|
||||
label: 'A failed login to your game account',
|
||||
description: 'Someone tried to log into your game account and was refused.',
|
||||
kind: 'event',
|
||||
// The account, so a burst of attempts against one account is one mail and
|
||||
// attempts against two accounts are two.
|
||||
subjectKey: 'account',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'account', type: 'string', required: true, example: 'seed_000',
|
||||
description: 'The game account that was tried. Also the cooldown subject.' },
|
||||
{ name: 'reason', type: 'string', required: false, example: 'BadPass',
|
||||
description: 'The shard\'s refusal reason: BadPass, Invalid, Blocked, InUse or BadComm.' },
|
||||
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
|
||||
description: 'Where the attempt came from.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.account.unlinked',
|
||||
label: 'Your game account was unlinked',
|
||||
description: 'Someone severed the tie between this game account and your website account, from in game.',
|
||||
kind: 'event',
|
||||
subjectKey: 'account',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'account', type: 'string', required: true, example: 'seed_000',
|
||||
description: 'The game account that was unlinked. Also the cooldown subject.' },
|
||||
{ name: 'characterName', type: 'string', required: false, example: 'Zara Crowe',
|
||||
description: 'The character who ran the command.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Personal milestone ─────────────────────────────────────────────────────
|
||||
//
|
||||
// The two death triggers are a killfeed some players want and most do not.
|
||||
// Every rule ships disabled anyway (Q3), and 11b's seeded rules for these two
|
||||
// additionally default their channels `off` rather than relying on the rule
|
||||
// switch alone.
|
||||
|
||||
const PERSONAL_MILESTONE = [
|
||||
{
|
||||
id: 'uo.skill.capped',
|
||||
label: 'You capped a skill',
|
||||
description: 'One of your characters reached the cap in a skill.',
|
||||
kind: 'event',
|
||||
subjectKey: 'skill',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||||
description: 'The character who capped it.' },
|
||||
{ name: 'skill', type: 'string', required: true, example: 'Blacksmithy',
|
||||
description: 'The skill. Also the cooldown subject — capping two skills is two events.' },
|
||||
{ name: 'cap', type: 'float', required: true, example: 100,
|
||||
description: 'The cap that was reached.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.quest.complete',
|
||||
label: 'You completed a quest',
|
||||
description: 'One of your characters finished a quest.',
|
||||
kind: 'event',
|
||||
subjectKey: 'quest',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||||
description: 'The character who finished it.' },
|
||||
{ name: 'quest', type: 'string', required: true, example: 'The Ancient Tome',
|
||||
description: 'The quest. Also the cooldown subject.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.character.death',
|
||||
label: 'Your character died',
|
||||
description: 'One of your characters was killed. Opt-in — most players do not want this.',
|
||||
kind: 'event',
|
||||
subjectKey: 'characterName',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||||
description: 'Who died. Also the cooldown subject.' },
|
||||
{ name: 'killerName', type: 'string', required: false, example: 'an ogre lord',
|
||||
description: 'What killed them, when the shard names it.' },
|
||||
{ name: 'slainBy', type: 'string', required: false, example: ' at the hands of a lich lord',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the killer is unknown.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.character.murdered',
|
||||
label: 'Your character was murdered',
|
||||
description: 'One of your characters was killed by another player. Opt-in — most players do not want this.',
|
||||
kind: 'event',
|
||||
subjectKey: 'characterName',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||||
description: 'Who was murdered. Also the cooldown subject.' },
|
||||
{ name: 'murdererName', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'Who did it, when the shard names them.' },
|
||||
{ name: 'slainBy', type: 'string', required: false, example: ' by the hand of Aldric',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the murderer is unknown.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Social / civic ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The two guild triggers ceiling at `members` and resolve through the recipient
|
||||
// set the emit carries, not through a saved segment: "the members of THIS guild"
|
||||
// is a different answer for every firing, which a segment's constant params
|
||||
// cannot express. That is Phase 6's decision 2, and the Team fan-out is the
|
||||
// precedent it was built for.
|
||||
|
||||
const SOCIAL_CIVIC = [
|
||||
{
|
||||
id: 'uo.guild.left',
|
||||
label: 'A member left your guild',
|
||||
description: 'Someone left a guild you are in.',
|
||||
kind: 'event',
|
||||
subjectKey: 'guildName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
|
||||
description: 'The guild. Also the cooldown subject.' },
|
||||
// `guild.leave`'s `who` is a bare SERIAL string, not an actor object — the
|
||||
// mobile has already left, so the shard has nothing to attribute. The name
|
||||
// comes from this module's own roster mirror (`shard_guild_members`), and
|
||||
// is optional because a member the sweep never saw has no row there.
|
||||
{ name: 'memberName', type: 'string', required: false, example: 'Bran',
|
||||
description: 'Who left, when the roster mirror still knows their name.' },
|
||||
{ name: 'guildUrl', type: 'url', required: false, example: '/uo/guilds/1042',
|
||||
description: 'Site-relative path to the guilds page.' },
|
||||
{ name: 'memberLabel', type: 'string', required: false, example: 'Aldric',
|
||||
description: 'A label: the departing member\'s name, or \'A member\' when the roster mirror has no name for them.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.guild.disbanded',
|
||||
label: 'Your guild disbanded',
|
||||
description: 'A guild you are in was disbanded or removed.',
|
||||
kind: 'event',
|
||||
subjectKey: 'guildName',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'guildName', type: 'string', required: true, example: 'The Silver Hand',
|
||||
description: 'The guild that is gone. Also the cooldown subject.' },
|
||||
{ name: 'abbreviation', type: 'string', required: false, example: 'TSH',
|
||||
description: 'Its abbreviation.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// **The town's bulletin and the governor's letter are two triggers, not one**
|
||||
// (ENGAGEMENT.md Phase 11b, decision 10). §8.6 records that
|
||||
// `uo.points.rank_changed` cannot address a person — `top[]` names a mobile
|
||||
// serial and links are keyed by account — and the same reasoning was silently
|
||||
// assumed to cover this one. It does not: `city.update`'s `governor` field is
|
||||
// written by `BridgeJson.Actor()`, which emits `serial`, `name`, `acct` and
|
||||
// `webId`. The new governor is addressable today, with no protocol change.
|
||||
//
|
||||
// Widening `uo.governor.elected` to two audiences was the tempting answer and
|
||||
// was refused: one trigger means one rule means ONE template, and the town's
|
||||
// announcement and the governor's letter are not the same text. Two also lets
|
||||
// an operator run the announcement and leave the letter off, or the reverse.
|
||||
id: 'uo.governor.appointed',
|
||||
label: 'You were named governor',
|
||||
description: 'You hold the governor\'s seat of a city — the letter to the person who won it.',
|
||||
kind: 'event',
|
||||
// The city, not the governor: a player who somehow takes two seats in an hour
|
||||
// should get two letters, and the seat is what the event is about.
|
||||
subjectKey: 'city',
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'city', type: 'string', required: true, example: 'Britain',
|
||||
description: 'The city whose seat you now hold. Also the cooldown subject.' },
|
||||
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'Your character\'s name, as the city knows it.' },
|
||||
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
|
||||
description: 'Who held the seat before, when there was someone.' },
|
||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
|
||||
description: 'Site-relative path to the governors page.' },
|
||||
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
|
||||
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.governor.elected',
|
||||
label: 'A town elected a governor',
|
||||
description: 'A city has a new governor.',
|
||||
kind: 'event',
|
||||
subjectKey: 'city',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'city', type: 'string', required: true, example: 'Britain',
|
||||
description: 'The city. Also the cooldown subject.' },
|
||||
{ name: 'governorName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'The new governor.' },
|
||||
{ name: 'previousGovernorName', type: 'string', required: false, example: 'Mireille',
|
||||
description: 'Who held the seat before, when there was someone.' },
|
||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
|
||||
description: 'Site-relative path to the governors page.' },
|
||||
{ name: 'inSuccessionTo', type: 'string', required: false, example: ' in succession to Mireille',
|
||||
description: 'A trailing fragment, LEADING SPACE included. Empty today: the frame names no outgoing governor.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.election.opened',
|
||||
label: 'Voting opened in a town',
|
||||
// **The first trigger whose call to action genuinely expires**, which is why
|
||||
// `autoPickAt` is required rather than decorative: a mail saying "vote" with
|
||||
// no deadline is a mail nobody acts on, and one delivered after the deadline
|
||||
// is worse than none. 11b's template says the date, and the seeded rule uses
|
||||
// no delay for the same reason.
|
||||
description: 'A city\'s election entered its nomination or voting phase, with a deadline.',
|
||||
kind: 'event',
|
||||
subjectKey: 'city',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'city', type: 'string', required: true, example: 'Britain',
|
||||
description: 'The city. Also the cooldown subject.' },
|
||||
{ name: 'phase', type: 'string', required: true, example: 'vote',
|
||||
description: 'Which phase opened: nominate or vote.' },
|
||||
{ name: 'autoPickAt', type: 'datetime', required: true, example: '2026-09-04T00:00:00Z',
|
||||
description: 'When the game decides for itself — the real deadline.' },
|
||||
// The same instant a person can read. A `datetime` renders as the string the
|
||||
// payload holds and core has no interpolation filters by design, so a body
|
||||
// that interpolates the machine value prints an ISO-8601 stamp mid-sentence.
|
||||
// The machine value STAYS — an operator writes `is at most` conditions
|
||||
// against it — and the body uses this one.
|
||||
{ name: 'autoPickWhen', type: 'string', required: false, example: '4 September 2026, 00:00 UTC',
|
||||
description: 'The deadline as prose, for a body. `autoPickAt` remains the machine value a condition compares.' },
|
||||
{ name: 'candidates', type: 'int', required: false, example: 3,
|
||||
description: 'How many candidates stand.' },
|
||||
{ name: 'governorsUrl', type: 'url', required: false, example: '/uo/governors',
|
||||
description: 'Site-relative path to the governors page.' },
|
||||
{ name: 'phaseLabel', type: 'string', required: false, example: 'The ballot is open',
|
||||
description: 'The phase as a clause rather than as the wire\'s enum.' },
|
||||
{ name: 'candidateNote', type: 'string', required: false, example: ' 3 candidates stand.',
|
||||
description: 'A trailing sentence, LEADING SPACE included, or empty when the count is unknown.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Come online now ────────────────────────────────────────────────────────
|
||||
|
||||
const COME_ONLINE = [
|
||||
{
|
||||
id: 'uo.champ.started',
|
||||
label: 'A champion spawn started',
|
||||
description: 'A champion spawn became active.',
|
||||
kind: 'event',
|
||||
subjectKey: 'spawnSerial',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||||
description: 'The spawn controller. Also the cooldown subject.' },
|
||||
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
|
||||
description: 'What is spawning.' },
|
||||
{ name: 'category', type: 'string', required: false, example: 'champion',
|
||||
description: 'champion, mini or sea.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
|
||||
description: 'Where, already formatted for reading.' },
|
||||
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||||
description: 'Site-relative path to the champions page.' },
|
||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.champ.boss_up',
|
||||
label: 'A champion boss is up',
|
||||
description: 'A champion spawn reached its boss.',
|
||||
kind: 'event',
|
||||
subjectKey: 'spawnSerial',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||||
description: 'The spawn controller. Also the cooldown subject.' },
|
||||
{ name: 'spawnName', type: 'string', required: true, example: 'Abyss',
|
||||
description: 'The spawn.' },
|
||||
{ name: 'bossName', type: 'string', required: false, example: 'Semidar',
|
||||
description: 'The boss, when the shard names it.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570',
|
||||
description: 'Where, already formatted for reading.' },
|
||||
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||||
description: 'Site-relative path to the champions page.' },
|
||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Protocol 6, and the reason the kind exists at all. Its first consumer is not
|
||||
// a mail rule but an EVENT PHASE CONDITION: `{ on: 'uo.champ.boss_killed',
|
||||
// where: [...], count: 1 }` is how an author says "move to the next phase when
|
||||
// the boss falls", and a condition is expressed over a trigger firing. That is
|
||||
// also why it is declared here rather than only ingested — a kind nothing
|
||||
// declares is a kind no event can wait on.
|
||||
id: 'uo.champ.boss_killed',
|
||||
label: 'A champion boss was defeated',
|
||||
description: 'Players brought down a champion spawn boss.',
|
||||
kind: 'event',
|
||||
subjectKey: 'spawnSerial',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'spawnSerial', type: 'string', required: true, example: '0x40012345',
|
||||
description: 'The spawn controller, or the boss itself where the shard could not name an altar. Also the cooldown subject.' },
|
||||
{ name: 'bossName', type: 'string', required: true, example: 'Semidar',
|
||||
description: 'The boss that fell.' },
|
||||
{ name: 'category', type: 'string', required: false, example: 'champion',
|
||||
description: 'champion or sea.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Felucca 5187, 570 (Destard)',
|
||||
description: 'Where, already formatted for reading.' },
|
||||
{ name: 'killerName', type: 'string', required: false, example: 'Aldric',
|
||||
description: 'Who struck the last blow, when the shard names one.' },
|
||||
{ name: 'damagerCount', type: 'int', required: false, example: 14,
|
||||
description: 'How many players did damage to it. The names themselves are staff-only and are deliberately not offered here.' },
|
||||
{ name: 'damagerNote', type: 'string', required: false, example: ' 14 players fought it.',
|
||||
description: 'A trailing sentence, LEADING SPACE included, or empty when nobody is credited.' },
|
||||
{ name: 'champsUrl', type: 'url', required: false, example: '/uo/champs',
|
||||
description: 'Site-relative path to the champions page.' },
|
||||
{ name: 'atPlace', type: 'string', required: false, example: ' at Felucca 1480, 1600 (Destard)',
|
||||
description: 'A trailing fragment, LEADING SPACE included, or empty when the frame carries no location.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.server.up',
|
||||
label: 'The shard came online',
|
||||
description: 'The game server started or came back after an outage.',
|
||||
kind: 'event',
|
||||
// **No `subjectKey`, and that is the whole point of this pair.** There is one
|
||||
// shard, so the subject a cooldown counts is the RECIPIENT — "do not tell me
|
||||
// the shard bounced more than once an hour". Keying it on a boot id would make
|
||||
// every restart a new subject and every cooldown a no-op, which is precisely
|
||||
// the mail loop §8.6 warns a flapping shard produces. 11b's seeded rules carry
|
||||
// a hard cooldown; this declaration is what makes that cooldown mean anything.
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
|
||||
description: 'What the shard calls itself.' },
|
||||
{ name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
|
||||
description: 'Site-relative path to the shard status page.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.server.down',
|
||||
label: 'The shard went offline',
|
||||
description: 'The game server shut down or crashed.',
|
||||
kind: 'event',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'shardName', type: 'string', required: false, example: 'UOMysticmoon',
|
||||
description: 'What the shard calls itself.' },
|
||||
{ name: 'clean', type: 'boolean', required: false, example: true,
|
||||
description: 'Whether it was a clean shutdown rather than a crash.' },
|
||||
{ name: 'statusUrl', type: 'url', required: false, example: '/uo/shard',
|
||||
description: 'Site-relative path to the shard status page.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Leaderboard ────────────────────────────────────────────────────────────
|
||||
|
||||
const LEADERBOARD = [
|
||||
{
|
||||
id: 'uo.points.rank_changed',
|
||||
label: 'A leaderboard top spot changed',
|
||||
// §8.6 originally described this firing both ways — "you entered a top N" and
|
||||
// "you were pushed out". The personal half is carved out: `points.board`'s
|
||||
// `top[]` entries are `{rank, serial, name, points}` and `shard_account_links`
|
||||
// is keyed by game ACCOUNT, so a serial resolves to a person only for someone
|
||||
// currently online (`shard_online`) or in a guild (`shard_guild_members`). A
|
||||
// leaderboard mail that reaches half the board reads as favouritism, so the
|
||||
// board feed ships and the personal one waits for a serial→account map.
|
||||
description: 'The top of a leaderboard changed hands.',
|
||||
kind: 'event',
|
||||
subjectKey: 'system',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'authenticated',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'system', type: 'string', required: true, example: 'QueensLoyalty',
|
||||
description: 'The points system. Also the cooldown subject.' },
|
||||
{ name: 'systemName', type: 'string', required: false, example: 'Queen\'s Loyalty',
|
||||
description: 'Its display name, when the shard gives one.' },
|
||||
{ name: 'leaderName', type: 'string', required: true, example: 'Darrow',
|
||||
description: 'Who is first now.' },
|
||||
{ name: 'previousLeaderName', type: 'string', required: false, example: 'Mireille',
|
||||
description: 'Who was first before.' },
|
||||
{ name: 'points', type: 'int', required: false, example: 29500,
|
||||
description: 'The new leader\'s points.' },
|
||||
{ name: 'boardLabel', type: 'string', required: false, example: 'Virtue',
|
||||
description: 'A label: the board\'s display name, or its system id when it has none.' },
|
||||
{ name: 'standingLine', type: 'string', required: false, example: 'Darrow now stands first upon it, with 4210 to their name.',
|
||||
description: 'The whole standing sentence, with the score when the board carried one and without it when it did not.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Staff-facing ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// These are why the ceiling exists. Phase 3 already filters a role-ceilinged
|
||||
// trigger out of a player's preferences catalogue AND gates it on write, so this
|
||||
// family is the production proof of that work rather than new mechanism.
|
||||
|
||||
const STAFF_FACING = [
|
||||
{
|
||||
id: 'uo.page.new',
|
||||
label: 'A player opened a help page',
|
||||
description: 'A player raised a support ticket in game.',
|
||||
kind: 'event',
|
||||
subjectKey: 'pageType',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'pageType', type: 'string', required: true, example: 'Stuck',
|
||||
description: 'Bug, Stuck, Account, Question, Suggestion, Other, VerbalHarassment or PhysicalHarassment. Also the cooldown subject.' },
|
||||
{ name: 'senderName', type: 'string', required: false, example: 'Zara Crowe',
|
||||
description: 'Who raised it.' },
|
||||
{ name: 'message', type: 'string', required: false, example: 'I am stuck under the Britain bank.',
|
||||
description: 'What they wrote.' },
|
||||
{ name: 'location', type: 'string', required: false, example: 'Trammel 1421, 1699',
|
||||
description: 'Where they are, already formatted for reading.' },
|
||||
{ name: 'pagesUrl', type: 'url', required: false, example: '/admin/uo/ops',
|
||||
description: 'Site-relative path to the help-page queue.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.cheat.detected',
|
||||
label: 'The cheat detector fired',
|
||||
description: 'The shard\'s own speed-hack detector flagged a player.',
|
||||
kind: 'event',
|
||||
// **`staff`, and never `owner`.** This is the declaration the whole lattice
|
||||
// was written for: under a flat "fewer people is narrower" ordering a
|
||||
// `staff` ceiling would also permit `owner`, and the rule an operator would
|
||||
// then be able to save mails the cheat report to the player who was detected.
|
||||
subjectKey: 'characterName',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'characterName', type: 'string', required: true, example: 'Zara Crowe',
|
||||
description: 'Who was flagged. Also the cooldown subject.' },
|
||||
{ name: 'account', type: 'string', required: false, example: 'seed_000',
|
||||
description: 'Their game account.' },
|
||||
{ name: 'ip', type: 'string', required: false, example: '203.0.113.9',
|
||||
description: 'Where they were connected from.' },
|
||||
{ name: 'detector', type: 'string', required: false, example: 'fastwalk',
|
||||
description: 'Which detector fired.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Operator-facing ────────────────────────────────────────────────────────
|
||||
//
|
||||
// `admin`, the ceiling Phase 11 added to the lattice (decision 1). The narrowest
|
||||
// value before it was `staff` — admin, editor AND moderator — so ceilinging a
|
||||
// digest of what moderators did at `staff` would have sent it to the moderators.
|
||||
// All three are digest-shaped by nature; none should ever be instant, which is a
|
||||
// property of 11b's seeded rules rather than of these declarations.
|
||||
|
||||
const OPERATOR_FACING = [
|
||||
{
|
||||
id: 'uo.audit.staff_action',
|
||||
label: 'A staff member acted in game',
|
||||
description: 'A staff command, a property change, or a moderation action.',
|
||||
kind: 'event',
|
||||
subjectKey: 'staffName',
|
||||
audience: 'admin',
|
||||
ceiling: 'admin',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'staffName', type: 'string', required: false, example: 'Mireille',
|
||||
description: 'Who acted. Absent when the shard cannot attribute it. Also the cooldown subject.' },
|
||||
{ name: 'action', type: 'string', required: true, example: 'set',
|
||||
description: 'What kind of action: set, command, ban, kick, mute…' },
|
||||
{ name: 'detail', type: 'string', required: false, example: 'Str 100 → 125 on Zara Crowe',
|
||||
description: 'The action in one line, already formatted for reading.' },
|
||||
{ name: 'target', type: 'string', required: false, example: 'Zara Crowe',
|
||||
description: 'Who or what it was applied to.' },
|
||||
{ name: 'origin', type: 'string', required: false, example: 'in-game',
|
||||
description: 'web or in-game — where the action was issued from.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.economy.milestone',
|
||||
label: 'The economy crossed a threshold',
|
||||
description: 'The shard\'s total gold supply or account count crossed one of the module\'s reporting thresholds.',
|
||||
kind: 'event',
|
||||
subjectKey: 'metric',
|
||||
audience: 'admin',
|
||||
ceiling: 'admin',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'metric', type: 'string', required: true, example: 'gold',
|
||||
description: 'gold or accounts. Also the cooldown subject.' },
|
||||
{ name: 'value', type: 'int', required: true, example: 1000000000,
|
||||
description: 'The value that crossed.' },
|
||||
{ name: 'threshold', type: 'int', required: true, example: 1000000000,
|
||||
description: 'The threshold it crossed.' },
|
||||
{ name: 'direction', type: 'string', required: true, example: 'up',
|
||||
description: 'up or down.' },
|
||||
{ name: 'economyUrl', type: 'url', required: false, example: '/uo/shard',
|
||||
description: 'Site-relative path to the shard status page.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uo.world.saved',
|
||||
label: 'The world saved',
|
||||
description: 'A world save completed, with the item and mobile counts it wrote.',
|
||||
kind: 'event',
|
||||
audience: 'admin',
|
||||
ceiling: 'admin',
|
||||
version: V1,
|
||||
variables: [
|
||||
{ name: 'items', type: 'int', required: false, example: 1482301,
|
||||
description: 'Items written.' },
|
||||
{ name: 'mobiles', type: 'int', required: false, example: 41022,
|
||||
description: 'Mobiles written.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGERS = [
|
||||
...OWNED_ASSET,
|
||||
...PASSIVE_INCOME,
|
||||
...PERSONAL_SECURITY,
|
||||
...PERSONAL_MILESTONE,
|
||||
...SOCIAL_CIVIC,
|
||||
...COME_ONLINE,
|
||||
...LEADERBOARD,
|
||||
...STAFF_FACING,
|
||||
...OPERATOR_FACING,
|
||||
]
|
||||
|
||||
// The ids, as a Set, for the mapper's own guard: `shardEngagement.js` refuses to
|
||||
// emit an id this file does not declare, so a typo there is a boot-time-visible
|
||||
// mistake rather than a dropped event nobody notices.
|
||||
const TRIGGER_IDS = new Set(TRIGGERS.map((t) => t.id))
|
||||
|
||||
module.exports = {
|
||||
TRIGGERS,
|
||||
TRIGGER_IDS,
|
||||
OWNED_ASSET,
|
||||
PASSIVE_INCOME,
|
||||
PERSONAL_SECURITY,
|
||||
PERSONAL_MILESTONE,
|
||||
SOCIAL_CIVIC,
|
||||
COME_ONLINE,
|
||||
LEADERBOARD,
|
||||
STAFF_FACING,
|
||||
OPERATOR_FACING,
|
||||
}
|
||||
2314
server/config/uoEventActions.js
Normal file
2314
server/config/uoEventActions.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -93,6 +93,28 @@ module.exports = {
|
||||
},
|
||||
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
||||
push: { publish: (...args) => need().push.publish(...args) },
|
||||
|
||||
// The engagement seam (MODULE_API 1.7.0, ENGAGEMENT.md §5.1). `emit` says an
|
||||
// event this module DECLARED has happened; the engine decides whether anyone is
|
||||
// told, on which channel, subject to which rule and preference. `inbox.push`
|
||||
// writes an in-app item with no rule at all, for the cases that are not events.
|
||||
//
|
||||
// Both are fire-and-forget and return undefined by contract — a module calls
|
||||
// them from inside a game-event handler and there is nothing it could correctly
|
||||
// do with a storage failure of core's. `inbox.push` additionally does not report
|
||||
// "the user has this switched off", because a module that could see that would
|
||||
// be a module that could enumerate people's preferences one write at a time.
|
||||
events: {
|
||||
emit: (...args) => need().events.emit(...args),
|
||||
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). "Ask every action of mine which
|
||||
// of its ledgered resources the game still has." Core cannot know when to
|
||||
// ask -- it has no concept of the game being up -- so the module says when,
|
||||
// and `shardIngest` says it on a changed `bootId`. Fire-and-forget like
|
||||
// `emit`, and for the same reason: core owns what happens next and there is
|
||||
// nothing a game-event handler could correctly do with the answer.
|
||||
reconcile: (...args) => need().events.reconcile(...args),
|
||||
},
|
||||
inbox: { push: (...args) => need().inbox.push(...args) },
|
||||
secretBox: {
|
||||
encrypt: (...args) => need().secretBox.encrypt(...args),
|
||||
decrypt: (...args) => need().secretBox.decrypt(...args),
|
||||
|
||||
@@ -27,6 +27,19 @@
|
||||
-- marker, and deleting it would re-arm a protocol bump against tables this
|
||||
-- file has just dropped.
|
||||
|
||||
-- The Asset Bridge's three (phase 3). No foreign keys of their own, so they lead:
|
||||
-- `shard_creature_bodies.slug` mirrors an atlas slug and `shard_assets.body` a body
|
||||
-- id, but neither is declared as a constraint — the atlas tables are rebuilt from
|
||||
-- scratch on every refresh, and an FK into a table that is emptied and refilled
|
||||
-- would make an ordinary re-parse fail on rows that are about to be re-inserted.
|
||||
--
|
||||
-- The uploaded PNGs are NOT removed here. They live under the uploads directory
|
||||
-- alongside the operator's own artwork, this file drops tables rather than files,
|
||||
-- and a purge that deleted an operator's hand-drawn creature portraits because
|
||||
-- they shared a directory with imported ones would be unrecoverable.
|
||||
DROP TABLE IF EXISTS `shard_asset_meta`;
|
||||
DROP TABLE IF EXISTS `shard_creature_bodies`;
|
||||
DROP TABLE IF EXISTS `shard_assets`;
|
||||
DROP TABLE IF EXISTS `shard_atlas_pending`;
|
||||
DROP TABLE IF EXISTS `shard_atlas_meta`;
|
||||
DROP TABLE IF EXISTS `shard_cliloc_meta`;
|
||||
@@ -45,6 +58,7 @@ 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_guild_members`;
|
||||
DROP TABLE IF EXISTS `shard_guilds`;
|
||||
DROP TABLE IF EXISTS `shard_pages`;
|
||||
DROP TABLE IF EXISTS `shard_champs`;
|
||||
|
||||
@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
||||
base_url VARCHAR(255) NULL,
|
||||
ws_url VARCHAR(255) NULL,
|
||||
auth_token_enc TEXT NULL,
|
||||
protocol INT NOT NULL DEFAULT 3,
|
||||
protocol INT NOT NULL DEFAULT 8,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
|
||||
status_detail VARCHAR(500) NULL,
|
||||
@@ -220,6 +220,52 @@ CREATE TABLE IF NOT EXISTS shard_guilds (
|
||||
INDEX idx_shard_guilds_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Guild membership (Protocol 4). One row per member per guild, replaced on
|
||||
-- guild.roster and thinned by guild.leave. Protocol 2 could only say HOW MANY
|
||||
-- members a guild had, so this table has no pre-4 equivalent and the Guilds page
|
||||
-- could show a count but never a roster.
|
||||
--
|
||||
-- `acct` / `web_id` are the site-identity fields and are stored because the
|
||||
-- sidecar forwards them; they are NOT public. shardVisibility locks any key that
|
||||
-- is or ends in acct/webId to `admin` and recurses into arrays, so a projected
|
||||
-- roster loses them below that rung — storing them here is what lets a linked
|
||||
-- member be matched to a site user at all.
|
||||
--
|
||||
-- A roster over the shard's per-frame cap arrives in several frames, so rows are
|
||||
-- keyed on (guild_id, serial) and the frame carrying seq 0 clears the guild first;
|
||||
-- see upsertGuildRoster.
|
||||
CREATE TABLE IF NOT EXISTS shard_guild_members (
|
||||
guild_id INT NOT NULL,
|
||||
serial VARCHAR(20) NOT NULL, -- in-game mobile serial, "0x1F5"
|
||||
name VARCHAR(120) NULL,
|
||||
acct VARCHAR(120) NULL, -- absent for a mobile with no account
|
||||
web_id INT NULL, -- set only when the account is linked
|
||||
is_player TINYINT(1) NOT NULL DEFAULT 1,
|
||||
-- Guild rank, 0-4, with 4 being Leader (ServUO RankDefinition.Ranks). NULL means
|
||||
-- "not known", which is a real state and not a demotion: the shard omits the rank
|
||||
-- for a staff account, because PlayerMobile.GuildRank reports Leader for anyone at
|
||||
-- GameMaster or above whatever their actual rank, and publishing that would put a
|
||||
-- staff member on a public roster as a guild leader.
|
||||
-- Backticked, like `int` on shard_online: RANK is a reserved word in MySQL 8 and
|
||||
-- a non-reserved keyword in MariaDB, so it parses here bare but must not be
|
||||
-- written that way anywhere it might not.
|
||||
`rank` TINYINT NULL,
|
||||
-- The rank's NAME, as the game states it: a cliloc id for the five standard ranks
|
||||
-- (1062959-1062963, which ship with no text), or a literal string when a shard has
|
||||
-- replaced the rank table with custom definitions. Resolving one to a label is this
|
||||
-- module's job -- it owns the cliloc table and the game vocabulary.
|
||||
rank_cliloc INT NULL,
|
||||
rank_name VARCHAR(64) NULL,
|
||||
t BIGINT NULL, -- roster event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (guild_id, serial),
|
||||
INDEX idx_shard_guild_members_acct (acct),
|
||||
INDEX idx_shard_guild_members_web (web_id),
|
||||
-- Leadership is "rank >= 4", asked per guild, which is the query the Team provider
|
||||
-- runs on every reconcile.
|
||||
INDEX idx_shard_guild_members_rank (guild_id, rank)
|
||||
) 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
|
||||
@@ -439,6 +485,13 @@ 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
|
||||
-- `XmlSpawner.UniqueId` (Phase 12b): the only name for one particular spawner
|
||||
-- that exists OFF the shard. A property lease is targeted by it, because a
|
||||
-- serial is assigned when the world is built and nothing here could know one --
|
||||
-- so without this column the lease's target field could have no dropdown at
|
||||
-- all. NULLable: a shard's own spawners, added in-world rather than from the
|
||||
-- spawn files, carry none, and they are addressed by serial instead.
|
||||
unique_id VARCHAR(64) NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
width INT NOT NULL DEFAULT 0,
|
||||
@@ -454,7 +507,10 @@ CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
||||
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)
|
||||
INDEX idx_shard_spawn_points_label (label),
|
||||
-- The spawner target's dropdown searches by name, and 6,707 rows is more than
|
||||
-- a dropdown holds, so the search is the read rather than a filter over one.
|
||||
INDEX idx_shard_spawn_points_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The many-to-many between the two above: one spawner commonly carries several
|
||||
@@ -498,6 +554,26 @@ CREATE TABLE IF NOT EXISTS shard_landmarks (
|
||||
INDEX idx_shard_landmarks_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Item types this shard uses as decoration, from Data/Decoration/**/*.cfg.
|
||||
--
|
||||
-- Import-owned like every other shard_* atlas table. It exists so the events
|
||||
-- decoration verb can offer an author a dropdown of what THIS shard already
|
||||
-- calls scenery, rather than a list of item types curated by us: a shard with
|
||||
-- custom decoration gets its own, and the list resolves with the shard offline
|
||||
-- because it came out of the tree at import time.
|
||||
--
|
||||
-- `item_id` is a preview, not an identity. A type appears under as many item
|
||||
-- ids as it has facings or variants (a BarredMetalDoor under eight), and the
|
||||
-- first one seen is kept; the plugin constructs from the TYPE NAME and picks
|
||||
-- its own graphic. `uses` is how many times the shard's own decoration reaches
|
||||
-- for the type, which is the only ordering signal available that means anything.
|
||||
CREATE TABLE IF NOT EXISTS shard_decor_types (
|
||||
type VARCHAR(120) NOT NULL PRIMARY KEY,
|
||||
item_id INT NOT NULL DEFAULT 0,
|
||||
uses INT NOT NULL DEFAULT 0,
|
||||
INDEX idx_shard_decor_types_uses (uses)
|
||||
) 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").
|
||||
@@ -584,6 +660,108 @@ CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── The Asset Bridge (docs/link/v8.md, protocol 8 phase 3) ─────────────────
|
||||
--
|
||||
-- One row per imported asset: the manifest side of §6, and what makes an Update
|
||||
-- a diff rather than a re-download. `sha256` is of the PNG the shard produced, so
|
||||
-- a re-import fetches only the keys whose hash moved.
|
||||
--
|
||||
-- **`file` is a filename under the uploads directory, never a path.** Images are
|
||||
-- written through `ctx.uploads`, the same door the operator's own atlas art comes
|
||||
-- in by, and storing a path here would let a row decide where the server reads
|
||||
-- from.
|
||||
--
|
||||
-- `bytes`/`width`/`height` are carried from the manifest rather than re-derived,
|
||||
-- because the manifest reports them before the pixels are fetched and a screen
|
||||
-- that lists what WOULD be imported needs them then.
|
||||
CREATE TABLE IF NOT EXISTS shard_assets (
|
||||
asset_key VARCHAR(191) NOT NULL PRIMARY KEY, -- §5's key: `body/34/a0`, `body/820/a23`
|
||||
family VARCHAR(24) NOT NULL DEFAULT 'body',
|
||||
sha256 CHAR(64) NOT NULL,
|
||||
bytes INT NOT NULL DEFAULT 0,
|
||||
width INT NOT NULL DEFAULT 0,
|
||||
height INT NOT NULL DEFAULT 0,
|
||||
body INT NULL, -- the body id, for the atlas join
|
||||
direction TINYINT NULL,
|
||||
file VARCHAR(191) NULL, -- filename under uploads/, NULL until fetched
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
-- The atlas art derivation joins creature → body → asset on every atlas refresh,
|
||||
-- so the body lookup is the read that has to be fast, not the key.
|
||||
INDEX idx_shard_assets_body (body)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Which of the shard's catalogues a row was fetched under (§7, phase 5).
|
||||
--
|
||||
-- The body catalogue can answer "is this stale?" from `shard_asset_meta`, because
|
||||
-- it is imported as a SET: one manifest walk covers every key, so one stored
|
||||
-- fingerprint describes all of them. Item and land art has no manifest and never
|
||||
-- will — 49,152 static ids times three thousand hues is not a set anyone
|
||||
-- enumerates — so staleness has to be recorded per row, and this is it.
|
||||
--
|
||||
-- The shard derives the id from the files that decide the bytes (its art data
|
||||
-- file, hues.mul, tiledata.mul, verdata.mul, and its own extractor version), so a
|
||||
-- client patch changes it and a restart does not. A row whose `catalog` is not the
|
||||
-- shard's current one is stale: the warm pass re-fetches it the next time
|
||||
-- something asks for that key, and pictures nobody looks at any more are simply
|
||||
-- never re-fetched, which is the whole reason this is per-row and lazy rather than
|
||||
-- a sweep. NULL means "written before this column existed", which is stale by the
|
||||
-- same test and costs one re-fetch.
|
||||
ALTER TABLE shard_assets ADD COLUMN IF NOT EXISTS catalog VARCHAR(32) NULL;
|
||||
|
||||
-- Which action a body's thumbnail came from (§11.2, phase 6).
|
||||
--
|
||||
-- The catalogue is still one row per body and still a first frame; what changed
|
||||
-- is that a body with no art at action 0 is catalogued at the first action that
|
||||
-- has any, and the key says so — `body/820/a23` is a horse whose action 0 is
|
||||
-- empty. 73 of a stock client's bodies are in that state, and they rendered as
|
||||
-- text on the bestiary until this phase looked one action further.
|
||||
--
|
||||
-- It is stored rather than parsed back out of the key because the atlas join
|
||||
-- needs it in SQL, and re-deriving it there with SUBSTRING_INDEX would put a
|
||||
-- second, weaker parser of §5's key scheme in the schema. NULL means a row
|
||||
-- written before this column existed, which is action 0 by definition — every
|
||||
-- key the catalogue had then ended in `a0`.
|
||||
ALTER TABLE shard_assets ADD COLUMN IF NOT EXISTS action TINYINT NULL;
|
||||
|
||||
-- Slug → body id, as the shard itself answered it (§8).
|
||||
--
|
||||
-- **Deliberately NOT a column on `shard_spawn_creatures`.** That table is
|
||||
-- IMPORT-OWNED: `replaceAtlas` empties and refills it inside one transaction on
|
||||
-- every atlas refresh. A body id living there would be destroyed by a routine
|
||||
-- re-parse of the ServUO tree — and the next asset Update would find the source
|
||||
-- hashes unchanged, report "nothing to do", and never put it back. The portrait
|
||||
-- would simply vanish from every creature page until somebody thought to force a
|
||||
-- re-import.
|
||||
--
|
||||
-- So the resolution lives here, outside the atlas's blast radius, and
|
||||
-- `replaceAtlas` READS it to derive `shard_spawn_creatures.art` on the way past.
|
||||
--
|
||||
-- `status` is the shard's own verdict and each value is a different thing an
|
||||
-- operator can act on: `ok`, `unknown` (the spawn file names a type this shard's
|
||||
-- scripts do not define — real drift), `notCreature` (a spawn file legitimately
|
||||
-- naming an item or decoration, a permanent answer), `failed` (its constructor
|
||||
-- threw). A row is kept for every one of them, because "asked and answered no" is
|
||||
-- what stops the next pass asking again.
|
||||
CREATE TABLE IF NOT EXISTS shard_creature_bodies (
|
||||
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- → shard_spawn_creatures.slug (no FK)
|
||||
type_name VARCHAR(120) NOT NULL, -- the ServUO class name that was asked
|
||||
body INT NULL, -- NULL unless status = 'ok'
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ok',
|
||||
resolved_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_shard_creature_bodies_body (body)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) describing the asset import currently applied: the shard's
|
||||
-- catalogue id, its extractor version, the counts and when it ran. Same shape and
|
||||
-- same job as `shard_cliloc_meta` — it is what an Update compares against to
|
||||
-- decide there is nothing to do.
|
||||
CREATE TABLE IF NOT EXISTS shard_asset_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_asset_meta_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
|
||||
@@ -629,6 +807,35 @@ UPDATE uo_link_config SET protocol = 3
|
||||
-- not cut over yet.
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
|
||||
|
||||
-- Protocol 4 cutover: the same migration one step later, and the one this module
|
||||
-- OWED and did not pay.
|
||||
--
|
||||
-- The protocol-4 work shipped across three repos — `link`'s PROTOCOL_VERSION, the
|
||||
-- overlay's `overlay.toml`, and this module's `guild.roster` / `guild.leave` ingest —
|
||||
-- but the pinned version stayed at 3 on both of its declaration sites here. A fresh
|
||||
-- install therefore came up speaking 3 to a sidecar speaking 4, and a sidecar answers
|
||||
-- a stale client with `409 protocol version mismatch` rather than mis-parsing it. The
|
||||
-- symptom is total: every REST read fails and the WS closes on ws.hello, so a new
|
||||
-- deployment shows an empty marketplace, an empty guild board and no shard status,
|
||||
-- with the cause visible only in the server log. Found while standing up a demo
|
||||
-- deployment for the marketing site's screenshots.
|
||||
--
|
||||
-- Same shape as the block above, for the same reasons: MODIFY fixes the column
|
||||
-- default for databases created before the bump, and the UPDATE is one-shot against
|
||||
-- its own marker so that an operator who deliberately pins an older sidecar in
|
||||
-- Admin → Shard stays pinned. `protocol < 4` and not `= 3`, so an install that
|
||||
-- somehow never took the protocol-3 migration is carried the whole way rather than
|
||||
-- one step.
|
||||
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 4;
|
||||
UPDATE uo_link_config SET protocol = 4
|
||||
WHERE id = 1 AND protocol < 4
|
||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_4_migrated');
|
||||
-- The marker is written HERE, in this module's fragment, for the reason spelled out
|
||||
-- above: core's schema is replayed in full BEFORE any module fragment, so a marker
|
||||
-- left in core would already exist when this UPDATE read it and the one-shot could
|
||||
-- never fire.
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_4_migrated', '1');
|
||||
|
||||
-- ── Settings rows this module owns ─────────────────────────────────────────
|
||||
--
|
||||
-- Both keys predate the module system and both name a game concept, so core
|
||||
@@ -641,4 +848,129 @@ INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated'
|
||||
-- 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');
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled');
|
||||
-- Protocol 4 guild rank, added to databases that already have shard_guild_members.
|
||||
--
|
||||
-- The table itself is new in Protocol 4 and unreleased, so no production install has
|
||||
-- it — but `edge` deployments do, from the roster work that landed before the rank
|
||||
-- amendment, and CREATE TABLE IF NOT EXISTS adds a table and never a column. This is
|
||||
-- the same gap the sidecar's own store hit when `guilds.members` was added.
|
||||
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS `rank` TINYINT NULL;
|
||||
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_cliloc INT NULL;
|
||||
ALTER TABLE shard_guild_members ADD COLUMN IF NOT EXISTS rank_name VARCHAR(64) NULL;
|
||||
ALTER TABLE shard_guild_members ADD INDEX IF NOT EXISTS idx_shard_guild_members_rank (guild_id, `rank`);
|
||||
|
||||
-- ── Protocol 5 ───────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Three wire enrichments, bumped together (link/sidecar/src/main.rs, overlay.toml).
|
||||
-- Two of them land as columns here; the third is a new event kind and needs none.
|
||||
--
|
||||
-- 1. house.decay's decay SCHEDULE. `shard_houses` could say what stage a house was
|
||||
-- at and when it was last refreshed, but nothing about WHEN the next thing
|
||||
-- happens — which is the only part a player can act on. `estimated_collapse` is
|
||||
-- nullable and stays null far more often than not, deliberately: under dynamic
|
||||
-- decay (Core.ML) ServUO draws each stage's duration at random when the stage is
|
||||
-- entered, so collapse is exactly knowable only once the house is already at
|
||||
-- IDOC. A null here means "not knowable", never "not yet read".
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS next_stage DATETIME NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS estimated_collapse DATETIME NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay_period_sec INT NULL;
|
||||
ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS dynamic_decay TINYINT(1) NULL;
|
||||
|
||||
-- 2. vendor.listing's owner account and fee state.
|
||||
--
|
||||
-- `owner_acct` is the one that matters structurally: the table has carried
|
||||
-- `owner_name` since Protocol 3, but a character name is not an identity — only
|
||||
-- the game ACCOUNT joins to shard_account_links, so until now a vendor row named
|
||||
-- an owner the site could not resolve to a user.
|
||||
--
|
||||
-- The fee columns describe PlayerVendor.PayTimer's dismissal rule: at each tick
|
||||
-- the charge is compared with the funds and the vendor is destroyed when the
|
||||
-- charge wins. `dismissal_at` is that comparison resolved into an instant, which
|
||||
-- is what any surface actually wants; the parts are kept alongside it so a
|
||||
-- display can explain the number rather than only state it.
|
||||
--
|
||||
-- `fees_exempt` marks a commission vendor: it has no pay timer at all and is
|
||||
-- never dismissed for fees, which is a different thing from having a long time
|
||||
-- left and must not render as one.
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS owner_acct VARCHAR(120) NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS fees_exempt TINYINT(1) NOT NULL DEFAULT 0;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS charge_per_period INT NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS funds INT NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS pay_interval_sec INT NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS next_pay_at DATETIME NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS periods_remaining INT NULL;
|
||||
ALTER TABLE shard_vendors ADD COLUMN IF NOT EXISTS dismissal_at DATETIME NULL;
|
||||
-- Both of these exist for the same reader: the Phase 11 trigger that has to find
|
||||
-- "vendors about to be dismissed" without scanning every shop, and the owner join
|
||||
-- that turns one into a person.
|
||||
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_dismissal (dismissal_at);
|
||||
ALTER TABLE shard_vendors ADD INDEX IF NOT EXISTS idx_shard_vendors_owner_acct (owner_acct);
|
||||
|
||||
-- 3. The protocol pin, one step on from the Protocol 4 block above and for exactly
|
||||
-- the reasons it spells out. `protocol < 5` rather than `= 4`, so an install that
|
||||
-- missed an earlier migration is carried the whole way; the one-shot marker is
|
||||
-- written here in the module's own fragment, because core's schema is replayed in
|
||||
-- full BEFORE any module fragment and a marker left in core would already exist
|
||||
-- when this UPDATE read it.
|
||||
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 5;
|
||||
UPDATE uo_link_config SET protocol = 5
|
||||
WHERE id = 1 AND protocol < 5
|
||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_5_migrated');
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_5_migrated', '1');
|
||||
|
||||
-- 4. The protocol pin again, at 7 -- and this block is a FIX to already-merged
|
||||
-- code rather than ordinary Phase 12b work.
|
||||
--
|
||||
-- Phase 11a took the wire to 6 and Phase 12a took it to 7, and neither moved
|
||||
-- this. `uoLinkClient` sends `X-UOLink-Version: <this column>` on every call and
|
||||
-- the sidecar answers an exact mismatch with a 409, so a deployment that installed
|
||||
-- this module at any point since Phase 10 would have had EVERY sidecar call
|
||||
-- refused against a protocol-7 sidecar -- the whole event plane dead, loudly but
|
||||
-- for a reason nobody would look here for.
|
||||
--
|
||||
-- It survived two phases because both live walks set the column by hand while
|
||||
-- standing the rig up, which is exactly the shape of a migration nobody runs.
|
||||
-- One block carries an install the whole way rather than one per missed version:
|
||||
-- `protocol < 7` is deliberate, and it is why the 4 and 5 blocks above wrote
|
||||
-- `< n` rather than `= n-1`.
|
||||
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 7;
|
||||
UPDATE uo_link_config SET protocol = 7
|
||||
WHERE id = 1 AND protocol < 7
|
||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_7_migrated');
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_7_migrated', '1');
|
||||
|
||||
-- The protocol pin at 8 -- the Asset Bridge (docs/link/v8.md), and the first bump this
|
||||
-- module takes IN the phase that consumes it rather than a phase or two later.
|
||||
--
|
||||
-- Phase 1 of that work moved `link`'s PROTOCOL_VERSION and the overlay's `overlay.toml`
|
||||
-- together, because the installer refuses to pair a sidecar and an overlay that disagree.
|
||||
-- Nothing enforces the third declaration -- this one -- and the block above is the record
|
||||
-- of what that costs: two phases of every REST call answered `409 protocol version
|
||||
-- mismatch`, invisible because both live walks had set the column by hand.
|
||||
--
|
||||
-- Phase 2 is where this module first calls a protocol-8 route (`GET /cliloc`), so it is
|
||||
-- where the pin moves. Same one-shot shape and the same `protocol < 8`, so an install
|
||||
-- that missed an earlier bump is carried the whole way rather than one step.
|
||||
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 8;
|
||||
UPDATE uo_link_config SET protocol = 8
|
||||
WHERE id = 1 AND protocol < 8
|
||||
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_8_migrated');
|
||||
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_8_migrated', '1');
|
||||
|
||||
-- `shard_spawn_points.unique_id` for an install that already had the table
|
||||
-- (Asset Bridge phase 9; the column itself is Events phase 12b).
|
||||
--
|
||||
-- The column was added to the CREATE TABLE above and nowhere else, so it reached
|
||||
-- fresh installs and no existing one -- `CREATE TABLE IF NOT EXISTS` does not add
|
||||
-- a column to a table that is already there, which is what every ALTER in this
|
||||
-- file exists to do. `replaceAtlas` inserts `unique_id` unconditionally, so on an
|
||||
-- upgraded install EVERY spawn-atlas import since v1.2.0 has failed outright with
|
||||
-- `Unknown column 'unique_id' in 'INSERT INTO'` -- the bestiary, the spawn map and
|
||||
-- the champion altars all frozen at whatever was last imported.
|
||||
--
|
||||
-- Found by the phase 9 acceptance walk, on a rig whose tables predate 12b: a fresh
|
||||
-- install cannot reproduce it, and neither can a test whose schema is this file
|
||||
-- applied to an empty database. That is the same blind spot the protocol-pin block
|
||||
-- above records, two phases running.
|
||||
ALTER TABLE shard_spawn_points ADD COLUMN IF NOT EXISTS unique_id VARCHAR(64) NULL;
|
||||
|
||||
@@ -44,7 +44,13 @@ module.exports = function register(ctx, api) {
|
||||
const usersShardExtension = require('./router/admin/usersShard.router')
|
||||
|
||||
const shardStreams = require('./config/shardStreams')
|
||||
const shardTriggers = require('./config/shardTriggers')
|
||||
const shardAudiences = require('./config/shardAudiences')
|
||||
const engagementSeeds = require('./config/engagementSeeds')
|
||||
const uoEventActions = require('./config/uoEventActions')
|
||||
const townCrierLeg = require('./utils/shardAnnounce')
|
||||
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
||||
const guildCommand = require('./commands/guild.command')
|
||||
const boot = require('./boot')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
@@ -86,6 +92,95 @@ module.exports = function register(ctx, api) {
|
||||
api.registerNotificationStreams(shardStreams.STREAMS)
|
||||
api.registerAnnounceLeg(townCrierLeg.leg)
|
||||
|
||||
// The engagement contract (MODULE_API 1.7.0, ENGAGEMENT.md Phase 11). Triggers
|
||||
// are PAYLOAD contracts: what a rule may fire on, what a template may
|
||||
// interpolate, and — the part that is a security boundary — the widest audience
|
||||
// an operator may ever give each one. `uo.cheat.detected` ceilings at `staff`
|
||||
// and the three operator-facing ones at `admin` (added to the lattice in 1.8.0),
|
||||
// and core refuses a rule that widens either.
|
||||
//
|
||||
// **Triggers and notification streams share ONE id namespace** (§7.2), so this
|
||||
// registration and the one above are two facets of one space and core enforces
|
||||
// that an id has exactly one owner across both. None of the ids below reuses a
|
||||
// stream id: the stream catalog keeps its seven grandfathered names and these
|
||||
// are the `uo.*`-prefixed ones §8.6 specifies. A trigger-only id gets email and
|
||||
// in-app preferences and no push toggle, which is correct — there is nothing to
|
||||
// push it to, and the shipped Android client's catalog is unchanged.
|
||||
api.registerEventTriggers(shardTriggers.TRIGGERS)
|
||||
|
||||
// Audiences are named sets of PEOPLE an operator composes rules and segments
|
||||
// out of (§5.1a). Their own id space, and their own ceiling arithmetic: a
|
||||
// composition takes the narrowest ceiling it contains, never the widest.
|
||||
//
|
||||
// Registration is a claim; nothing resolves until the engine asks, which is
|
||||
// after `onBoot` — and it must be, because every resolver reads the database
|
||||
// and registration must not (§2.2 rule 1).
|
||||
api.registerAudiences(shardAudiences.AUDIENCES)
|
||||
|
||||
// What this module SHIPS behind those two (MODULE_API 1.9.0, ENGAGEMENT.md
|
||||
// Phase 11b): sixteen in-universe message bodies on two channels each, and
|
||||
// twenty-five rules — every one of them `enabled = 0`, which the registry
|
||||
// enforces rather than trusts.
|
||||
//
|
||||
// **A catalogue an operator turns on, not a switch that fires on upgrade.**
|
||||
// Nothing here mails anybody: a rule that is off produces nothing, and a rule
|
||||
// that is on still passes the ceiling, the per-user preference, the suppression
|
||||
// list and the verification gate before anything is sent — all of them core's.
|
||||
//
|
||||
// The nine security and operational triggers point at core's generic bodies
|
||||
// (decision 9). A cheat report should read like a cheat report.
|
||||
//
|
||||
// ONE rule group, and the choice is deliberate: a group is seeded once, so a
|
||||
// twenty-sixth rule appended to `triggers-v1` in a later version would reach
|
||||
// fresh installs ONLY. A future trigger wants its own group key.
|
||||
api.registerEngagementSeeds({
|
||||
templates: engagementSeeds.TEMPLATES,
|
||||
ruleGroups: engagementSeeds.RULE_GROUPS,
|
||||
})
|
||||
|
||||
|
||||
// Teams: a UO guild is a Team, and this module is the authoritative source of
|
||||
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
|
||||
// everything about what a guild IS stays here.
|
||||
//
|
||||
// Registration is a claim, not a call — nothing below runs until core
|
||||
// reconciles, which is after `onBoot`. That matters because every method reads
|
||||
// the database, and registration must not.
|
||||
api.registerTeamProvider(teamProvider)
|
||||
|
||||
// `/guild` — the chat surface for the same guilds (MODULE_API 1.6.0, TEAMS.md
|
||||
// §7.1). The definition travels to the bot; the handler stays here and runs in
|
||||
// the website process, because the bot container has no `modules` volume and
|
||||
// cannot load a line of this module's code.
|
||||
//
|
||||
// Core registers NO commands of its own. "Guild" is this module's word — core
|
||||
// does not own it on a page (phase 3) and does not publish it in a channel
|
||||
// either.
|
||||
api.registerSlashCommands([guildCommand])
|
||||
|
||||
// The event contract (MODULE_API 1.10.0, EVENTS.md F, EVENTS_PLAN.md Phase 9).
|
||||
// Three verbs an event author can put in a step, the one budget dimension that
|
||||
// bounds a broadcast, and the three option sources the spawn atlas answers.
|
||||
//
|
||||
// **All of it is optional, by the contract's own posture.** A deployment
|
||||
// without this module still has an event engine that can announce, wait, cue a
|
||||
// human and publish results; what these add is the ability for an event to
|
||||
// reach the GAME. Nothing here is a precondition for anything of core's.
|
||||
//
|
||||
// The wave is deliberately the verbs that need no protocol change: the write
|
||||
// plane they use has existed since protocol 2.1 and the admin screens have
|
||||
// driven it by hand for months. The world verbs -- creatures, gates, leases --
|
||||
// wait for Phase 11 to put an idempotency key and a lease deadline on the wire,
|
||||
// because a world write core cannot prove ran exactly once is not one this
|
||||
// module is willing to make unattended.
|
||||
api.registerEventBudgets(uoEventActions.BUDGETS)
|
||||
api.registerEventActions(uoEventActions.ACTIONS)
|
||||
// Phase 11b. One live-read config key, and the module never writes it: an author
|
||||
// puts `core.lease` in a step and core owns the duration bound, the
|
||||
// two-events-one-target check and the teardown restore.
|
||||
api.registerEventLeases(uoEventActions.LEASES)
|
||||
api.registerEventOptionSources(uoEventActions.OPTION_SOURCES)
|
||||
|
||||
api.onBoot(boot.onBoot)
|
||||
api.onShutdown(boot.onShutdown)
|
||||
|
||||
@@ -93,5 +188,8 @@ module.exports = function register(ctx, api) {
|
||||
version: require('../module.json').version,
|
||||
routes: 'public:/shard,/atlas admin:/shard,/uo-link player:/shard',
|
||||
streams: shardStreams.STREAMS.length,
|
||||
triggers: shardTriggers.TRIGGERS.length,
|
||||
audiences: shardAudiences.AUDIENCES.length,
|
||||
eventActions: uoEventActions.ACTIONS.length,
|
||||
})
|
||||
}
|
||||
|
||||
380
server/model/shardAssets/shardAssets.db.js
Normal file
380
server/model/shardAssets/shardAssets.db.js
Normal file
@@ -0,0 +1,380 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const { query } = core
|
||||
|
||||
// Raw SQL for the Asset Bridge's three tables (docs/link/v8.md §6, §8, §12).
|
||||
//
|
||||
// Unlike `shard_clilocs` and the atlas tables, these are NOT import-owned in the
|
||||
// empty-and-refill sense, and the difference is the whole reason phase 3 put them
|
||||
// in their own tables rather than in columns on `shard_spawn_creatures`.
|
||||
//
|
||||
// An asset row is expensive to obtain — a decode on the shard, a PNG across the
|
||||
// wire, a file written under uploads/ — and it is valid until the operator
|
||||
// patches their client. An atlas refresh, by contrast, happens on every boot and
|
||||
// destroys everything it owns. Putting the two in one table would mean a routine
|
||||
// re-parse of the ServUO tree silently deleting every imported portrait, with the
|
||||
// next Update reporting "nothing changed" and never restoring them.
|
||||
//
|
||||
// So these are upserted per key, and the only thing that ever deletes from them
|
||||
// is an explicit removal of a key the shard no longer offers — which is staged
|
||||
// for review, never applied silently (§6).
|
||||
|
||||
const BATCH = 500
|
||||
|
||||
async function batched(conn, sql, rows) {
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(sql, rows.slice(i, i + BATCH))
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
// ── the manifest side ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The asset rows we hold in one family, as a Map of key → row.
|
||||
*
|
||||
* **The family is required, and the reason is a deletion.** The import diffs what
|
||||
* this returns against a manifest, and a manifest is always of ONE family (§14 —
|
||||
* the reply carries a single catalogue id, so it could not be otherwise). Phase 5
|
||||
* put item and land art in this table beside the body catalogue; read whole, the
|
||||
* body import then sees every item picture as a key the shard has stopped
|
||||
* offering and stages all of them for deletion. On a real install that is a few
|
||||
* hundred pictures the operator is asked to approve the loss of, with a sentence
|
||||
* that is entirely wrong about what happened.
|
||||
*
|
||||
* `null` reads every family, which nothing in the import path should ever want.
|
||||
*/
|
||||
async function allAssets(family = null) {
|
||||
const rows = await query(
|
||||
'SELECT asset_key, family, sha256, bytes, width, height, body, action, direction, file, catalog ' +
|
||||
'FROM shard_assets' +
|
||||
(family ? ' WHERE family = ?' : ''),
|
||||
family ? [family] : [],
|
||||
)
|
||||
|
||||
const map = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
map.set(row.asset_key, {
|
||||
key: row.asset_key,
|
||||
family: row.family,
|
||||
sha256: row.sha256,
|
||||
bytes: Number(row.bytes) || 0,
|
||||
width: Number(row.width) || 0,
|
||||
height: Number(row.height) || 0,
|
||||
body: row.body === null ? null : Number(row.body),
|
||||
action: row.action === null ? null : Number(row.action),
|
||||
direction: row.direction === null ? null : Number(row.direction),
|
||||
file: row.file || null,
|
||||
catalog: row.catalog || null,
|
||||
})
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the assets an import produced, and record what the import was.
|
||||
*
|
||||
* One transaction for the rows and the meta together: the meta row is what an
|
||||
* Update compares against to decide there is nothing to do, so a meta written
|
||||
* without its rows would make the site believe it holds a catalogue it does not.
|
||||
*
|
||||
* `ON DUPLICATE KEY UPDATE` rather than delete-and-insert, because an unchanged
|
||||
* key must keep the file it already points at — re-writing the file for every
|
||||
* asset on every Update is exactly the cost the manifest diff exists to avoid.
|
||||
*
|
||||
* `remove` is the keys an operator has APPROVED the loss of (§6). They are
|
||||
* deleted here, inside the same transaction, because a half-applied removal is
|
||||
* the worst of the three outcomes: until phase 8 the import unlinked the sprite
|
||||
* and left the row, so the catalogue still counted a picture that was gone, the
|
||||
* atlas could point a creature at a deleted file, and the very next forced
|
||||
* import staged the same key for review again — telling the operator nothing had
|
||||
* changed, about a file it had already deleted.
|
||||
*/
|
||||
async function saveAssets(rows, meta, remove = []) {
|
||||
const conn = await core.pool.getConnection()
|
||||
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
const values = rows.map((r) => [
|
||||
r.key,
|
||||
r.family || 'body',
|
||||
r.sha256,
|
||||
r.bytes ?? 0,
|
||||
r.width ?? 0,
|
||||
r.height ?? 0,
|
||||
r.body ?? null,
|
||||
r.action ?? null,
|
||||
r.direction ?? null,
|
||||
r.file ?? null,
|
||||
r.catalog ?? meta?.catalog ?? null,
|
||||
])
|
||||
|
||||
await batched(
|
||||
conn,
|
||||
'INSERT INTO shard_assets ' +
|
||||
'(asset_key, family, sha256, bytes, width, height, body, action, direction, file, catalog) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?) ' +
|
||||
'ON DUPLICATE KEY UPDATE family = VALUES(family), sha256 = VALUES(sha256), ' +
|
||||
'bytes = VALUES(bytes), width = VALUES(width), height = VALUES(height), ' +
|
||||
'body = VALUES(body), action = VALUES(action), direction = VALUES(direction), ' +
|
||||
'file = VALUES(file), catalog = VALUES(catalog), imported_at = CURRENT_TIMESTAMP',
|
||||
values,
|
||||
)
|
||||
|
||||
if (remove.length > 0) {
|
||||
for (let i = 0; i < remove.length; i += BATCH) {
|
||||
const slice = remove.slice(i, i + BATCH)
|
||||
await conn.query(
|
||||
`DELETE FROM shard_assets WHERE asset_key IN (${slice.map(() => '?').join(',')})`,
|
||||
slice,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
await conn.query(
|
||||
'INSERT INTO shard_asset_meta (id, payload) VALUES (1, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||
[JSON.stringify(meta)],
|
||||
)
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
|
||||
return values.length
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
// ── the on-demand side (§11, phase 5) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pictures we hold for an explicit list of keys, as a Map of key → filename.
|
||||
*
|
||||
* This is the read on the hot path — every marketplace page and every character
|
||||
* sheet runs it — so it is one statement over the primary key and it returns only
|
||||
* what it is asked for. It deliberately does NOT check staleness: a page renders
|
||||
* the picture it has, and deciding whether that picture is out of date is the warm
|
||||
* pass's job, off the request.
|
||||
*/
|
||||
async function filesForKeys(keys) {
|
||||
const list = [...new Set(keys.filter((k) => typeof k === 'string' && k !== ''))]
|
||||
|
||||
if (list.length === 0) return new Map()
|
||||
|
||||
const rows = await query(
|
||||
`SELECT asset_key, file FROM shard_assets WHERE file IS NOT NULL AND asset_key IN (${list
|
||||
.map(() => '?')
|
||||
.join(',')})`,
|
||||
list,
|
||||
)
|
||||
|
||||
const map = new Map()
|
||||
|
||||
for (const row of rows) map.set(row.asset_key, row.file)
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of these keys we already hold under the shard's CURRENT catalogue.
|
||||
*
|
||||
* The warm pass subtracts this from what it wants, so everything it does not
|
||||
* return gets fetched: a key we have never seen, and a key whose row was written
|
||||
* against a catalogue the shard has since moved past (§7 — an operator patched
|
||||
* their client). A row with no file is not held either, because the database and
|
||||
* the uploads volume can disagree and a broken image is worse than a re-fetch.
|
||||
*/
|
||||
async function freshKeys(keys, catalog) {
|
||||
const list = [...new Set(keys.filter((k) => typeof k === 'string' && k !== ''))]
|
||||
|
||||
if (list.length === 0) return new Set()
|
||||
|
||||
const rows = await query(
|
||||
`SELECT asset_key FROM shard_assets WHERE file IS NOT NULL AND catalog <=> ? ` +
|
||||
`AND asset_key IN (${list.map(() => '?').join(',')})`,
|
||||
[catalog ?? null, ...list],
|
||||
)
|
||||
|
||||
return new Set(rows.map((r) => r.asset_key))
|
||||
}
|
||||
|
||||
/** Counts for the admin surface, split by family. */
|
||||
async function countByFamily() {
|
||||
const rows = await query(
|
||||
'SELECT family, COUNT(*) AS total, SUM(file IS NOT NULL) AS stored FROM shard_assets GROUP BY family',
|
||||
)
|
||||
|
||||
const out = {}
|
||||
|
||||
for (const row of rows) {
|
||||
out[row.family] = { total: Number(row.total) || 0, stored: Number(row.stored) || 0 }
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the import that just finished actually did (§6, phase 8).
|
||||
*
|
||||
* **A second write, deliberately.** The interesting half of that summary — how
|
||||
* many atlas creatures resolved to a body id, how many portraits were applied —
|
||||
* does not exist when `saveAssets` commits: producing it takes another round trip
|
||||
* to the shard, and widening the rows-and-meta transaction to cover a network
|
||||
* call is how an import ends up holding a write lock for the length of a timeout.
|
||||
*
|
||||
* `JSON_SET` rather than a read-modify-write for the same reason the rest of this
|
||||
* file is one statement per operation: the payload is the gate an Update compares
|
||||
* against, and re-serialising it from the outside is how a concurrent import
|
||||
* loses a field nobody notices for a month.
|
||||
*
|
||||
* It is cosmetic by design — nothing reads `last` to make a decision, the panel
|
||||
* only renders it — so a failure here is logged and swallowed by the caller
|
||||
* rather than failing an import that has already applied.
|
||||
*/
|
||||
async function recordLastImport(last) {
|
||||
await query('UPDATE shard_asset_meta SET payload = JSON_SET(payload, ?, JSON_COMPACT(?)) WHERE id = 1', [
|
||||
'$.last',
|
||||
JSON.stringify(last),
|
||||
])
|
||||
}
|
||||
|
||||
async function getMeta() {
|
||||
const rows = await query('SELECT payload, imported_at FROM shard_asset_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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* How many assets we hold, optionally in one family.
|
||||
*
|
||||
* **The family argument is not optional in spirit.** Phase 5 put item and land
|
||||
* art in this table beside the body catalogue, and they are counted differently
|
||||
* by nature: the catalogue is a SET with a known size, while item art is however
|
||||
* much of an unbounded space the site has happened to ask for. A whole-table
|
||||
* count answers neither question — it reported the creature catalogue as 1,408
|
||||
* rows on an install holding 1,095 portraits and 313 item pictures, which is a
|
||||
* confident wrong number in the one place an operator checks whether the import
|
||||
* worked.
|
||||
*/
|
||||
async function countAssets(family = null) {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n, SUM(file IS NOT NULL) AS stored FROM shard_assets' +
|
||||
(family ? ' WHERE family = ?' : ''),
|
||||
family ? [family] : [],
|
||||
)
|
||||
return { total: Number(rows[0]?.n) || 0, stored: Number(rows[0]?.stored) || 0 }
|
||||
}
|
||||
|
||||
// ── the body resolution side (§8) ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace the whole slug → body map.
|
||||
*
|
||||
* This one IS a replace, and for the opposite reason to the assets above: it is
|
||||
* derived from the atlas's creature list, so a slug that has left the atlas has
|
||||
* no meaning any more and keeping its row would leave the map growing forever
|
||||
* across map changes. The pass that produces it is cheap to redo — a shard round
|
||||
* trip, no files — which is what makes replacing safe here and not there.
|
||||
*/
|
||||
async function replaceBodies(rows) {
|
||||
const conn = await core.pool.getConnection()
|
||||
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query('DELETE FROM shard_creature_bodies')
|
||||
|
||||
const values = rows.map((r) => [r.slug, r.typeName, r.body ?? null, r.status || 'ok'])
|
||||
|
||||
await batched(
|
||||
conn,
|
||||
'INSERT INTO shard_creature_bodies (slug, type_name, body, status) VALUES (?,?,?,?)',
|
||||
values,
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
return values.length
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function allBodies() {
|
||||
return query(
|
||||
'SELECT slug, type_name, body, status, resolved_at FROM shard_creature_bodies ORDER BY slug',
|
||||
)
|
||||
}
|
||||
|
||||
async function countBodies() {
|
||||
const rows = await query(
|
||||
"SELECT COUNT(*) AS n, SUM(status = 'ok') AS resolved FROM shard_creature_bodies",
|
||||
)
|
||||
return { total: Number(rows[0]?.n) || 0, resolved: Number(rows[0]?.resolved) || 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* The derivation `replaceAtlas` applies on the way past: slug → uploaded filename.
|
||||
*
|
||||
* One join rather than two reads, because it runs inside the atlas transaction —
|
||||
* the atlas rows are being inserted at that moment and every extra round trip is
|
||||
* time the site's creature list does not exist.
|
||||
*
|
||||
* Rows with no body, no asset or an asset whose bytes were never fetched are
|
||||
* simply absent from the result, which is what leaves `art` NULL. That is a
|
||||
* first-class state everywhere it is consumed and the expected one for two thirds
|
||||
* of the player bodies (§5.2).
|
||||
*
|
||||
* **The join is pinned to the catalogue key, not merely to the body id** — and as
|
||||
* of phase 6 that key is no longer always `a0`. 73 of this client's bodies have
|
||||
* no art at action 0 and are catalogued at the first action that does (§11.2), so
|
||||
* a join hardcoding `a0` would silently drop exactly the creatures this phase
|
||||
* added — a horse among them. It reads the row's own `action` instead, which
|
||||
* still excludes any deeper key a later phase adds (`body/400/a2/f0` does not
|
||||
* equal `body/400/a2`), so one slug still matches at most one row.
|
||||
*
|
||||
* `COALESCE(a.action, 0)` because a row written before this column existed has
|
||||
* NULL there and a NULL inside `CONCAT` makes the whole comparison NULL — which
|
||||
* would have dropped every portrait on the site until the next import, with the
|
||||
* database perfectly correct.
|
||||
*/
|
||||
async function artBySlug() {
|
||||
const rows = await query(
|
||||
'SELECT b.slug, a.file FROM shard_creature_bodies b ' +
|
||||
"JOIN shard_assets a ON a.body = b.body AND a.family = 'body' " +
|
||||
"AND a.asset_key = CONCAT('body/', b.body, '/a', COALESCE(a.action, 0)) " +
|
||||
"WHERE b.status = 'ok' AND b.body IS NOT NULL AND a.file IS NOT NULL",
|
||||
)
|
||||
|
||||
const map = {}
|
||||
|
||||
for (const row of rows) map[row.slug] = row.file
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
allAssets,
|
||||
saveAssets,
|
||||
recordLastImport,
|
||||
getMeta,
|
||||
countAssets,
|
||||
replaceBodies,
|
||||
allBodies,
|
||||
countBodies,
|
||||
artBySlug,
|
||||
filesForKeys,
|
||||
freshKeys,
|
||||
countByFamily,
|
||||
}
|
||||
588
server/model/shardAssets/shardAssets.model.js
Normal file
588
server/model/shardAssets/shardAssets.model.js
Normal file
@@ -0,0 +1,588 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const db = require('./shardAssets.db')
|
||||
const atlasDb = require('../shardAtlas/shardAtlas.db')
|
||||
const core = require('../../core')
|
||||
const bridge = require('../../utils/assetBridge')
|
||||
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
|
||||
const log = require('../../core').logger('shardAssets')
|
||||
|
||||
// Client artwork, over the bridge (docs/link/v8.md — protocol 8, phase 3).
|
||||
//
|
||||
// What this replaces: until now the only way a creature got a picture on this
|
||||
// site was for an operator to open UOFiddler on a desktop, export sprites by
|
||||
// hand, copy them to the web host and write a `spawnAtlas.art.json` naming each
|
||||
// one. Almost nobody did, so `shard_spawn_creatures.art` was NULL on every
|
||||
// install and the atlas rendered as text.
|
||||
//
|
||||
// The shard has had those files the whole time — a ServUO server cannot boot
|
||||
// without a UO client — so as of protocol 8 it decodes them itself and hands the
|
||||
// pictures over the same request/reply path as every other shard read.
|
||||
//
|
||||
// ── Two passes, and they answer different questions ───────────────────
|
||||
//
|
||||
// **The catalogue** (§4.8, §11) is one thumbnail per creature body: the shard
|
||||
// walks bodies 0–2047, validates each index entry, decodes the ones that are real
|
||||
// and hands back `{ key, sha256 }` first and the PNG second. On a stock client
|
||||
// that is **1,095 sprites** — 787 out of the legacy anim files, 235 more out of
|
||||
// the UOP packages (phase 4), and 73 more since phase 6, which have no art at
|
||||
// action 0 and real art at a later one. Never the 1,144 the decoder claims.
|
||||
//
|
||||
// A key therefore names its action — `body/820/a23` is a horse whose action 0 is
|
||||
// empty — and the key is still one per body. Nothing here treats `a0` as the
|
||||
// shape of a body key; the atlas join reads the row's own action (§11.2).
|
||||
//
|
||||
// **Body resolution** (§8) is the join. The atlas knows a creature by the class
|
||||
// name in `Spawns/*.xml`; the client knows it by a body id; nothing in the ServUO
|
||||
// tree declares the mapping. Only code inside ServUO can answer it, by
|
||||
// constructing the creature and reading `Body.BodyID`, and that is the whole
|
||||
// reason this could not be done off the shard.
|
||||
//
|
||||
// ── The 357, and why nothing here trusts a success ────────────────────
|
||||
//
|
||||
// 357 of the bodies ServUO's decoder returns a bitmap for **have no art**. Their
|
||||
// index entry reads `length 0`, the library's stream buffer still holds the
|
||||
// previous creature, and what comes back is whichever body was decoded before —
|
||||
// a real, plausible, correctly-sized picture of the wrong animal. The shard now
|
||||
// validates every index entry before it decodes, which is what cut the catalogue
|
||||
// from 1,144 to 787, and the count going down is the point.
|
||||
//
|
||||
// The consequence for this file is a rule: **a missing asset is a normal
|
||||
// outcome, never an error.** Two thirds of the player bodies have no art on a
|
||||
// stock client (§5.2), so an import that reported eight failures every time would
|
||||
// teach an operator to ignore the panel.
|
||||
//
|
||||
// ── Where the pictures go, and what still wins ────────────────────────
|
||||
//
|
||||
// Into `<uploads>/atlas/`, through the same door the operator's own artwork uses,
|
||||
// and `shard_spawn_creatures.art` is DERIVED from them rather than written by
|
||||
// them. **The operator's `spawnAtlas.art.json` still wins outright**: someone who
|
||||
// has drawn their own creature portraits must not have them replaced by a sprite
|
||||
// rip on the next Update.
|
||||
//
|
||||
// ── Why the resolution does not live on the atlas row ─────────────────
|
||||
//
|
||||
// `shard_spawn_creatures` is emptied and refilled on every atlas refresh. A body
|
||||
// id or a filename stored there would be destroyed by an ordinary re-parse of the
|
||||
// ServUO tree, and the next asset Update would find the client files unchanged,
|
||||
// report "nothing to do" and never restore it. So both live in their own tables
|
||||
// and the atlas import reads them on the way past.
|
||||
|
||||
/** Where imported sprites land, under core's upload directory. */
|
||||
const ART_SUBDIR = 'atlas'
|
||||
|
||||
// ── configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Is there a shard to ask?
|
||||
*
|
||||
* Both halves matter, exactly as in `shardClilocs.model`: a `baseUrl` on a
|
||||
* disabled config is an install that was set up and then switched off, and
|
||||
* calling it would spend a 12 s timeout to learn what the row already says.
|
||||
*/
|
||||
async function shardLinked() {
|
||||
try {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
return Boolean(config?.enabled && config?.baseUrl)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function artDir() {
|
||||
return path.join(core.uploads.UPLOAD_DIR, ART_SUBDIR)
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator's own art map, which wins over anything imported.
|
||||
*
|
||||
* Read through the atlas model rather than re-implemented, so there is one
|
||||
* definition of where that file lives and what an absent one means.
|
||||
*/
|
||||
function operatorArt() {
|
||||
// eslint-disable-next-line global-require
|
||||
return require('../shardAtlas/shardAtlas.model').loadArtMap()
|
||||
}
|
||||
|
||||
// ── writing a sprite ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The filename one asset gets on disk.
|
||||
*
|
||||
* **Content-addressed on purpose.** A stable name per key (`uo-body-34.png`)
|
||||
* would be overwritten in place by an Update, and every browser and CDN that had
|
||||
* already cached it would keep serving last month's client's sprite — with
|
||||
* nothing anywhere to notice, because the database row would be correct. Putting
|
||||
* eight bytes of the hash in the name makes a changed sprite a changed URL.
|
||||
*
|
||||
* The old file is removed when a key's hash moves, so the directory tracks the
|
||||
* catalogue rather than accumulating one file per import forever.
|
||||
*/
|
||||
function fileNameFor(key, sha256) {
|
||||
const stem = key.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return `uo-${stem}-${String(sha256).slice(0, 8)}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one sprite and return its filename, or null if it could not be written.
|
||||
*
|
||||
* Never throws. A full disk or a read-only volume must degrade to "this creature
|
||||
* has no picture" — which the whole site already renders correctly, because it is
|
||||
* the state every install was in until this phase — rather than failing an import
|
||||
* that has already fetched hundreds of others.
|
||||
*/
|
||||
function writeSprite(key, sha256, png) {
|
||||
const name = fileNameFor(key, sha256)
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artDir(), { recursive: true })
|
||||
fs.writeFileSync(path.join(artDir(), name), png)
|
||||
return name
|
||||
} catch (err) {
|
||||
log.warn('could not write an imported sprite', { key, error: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort removal of a sprite a key no longer points at. */
|
||||
function removeSprite(name) {
|
||||
if (!name) return
|
||||
|
||||
try {
|
||||
fs.unlinkSync(path.join(artDir(), name))
|
||||
} catch {
|
||||
// Already gone, or never written. Either way there is nothing to do, and an
|
||||
// import must not fail because a file it was tidying up was tidied already.
|
||||
}
|
||||
}
|
||||
|
||||
// ── the import ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Import (or update) the body catalogue and the slug → body map.
|
||||
*
|
||||
* Returns a result rather than throwing, so a controller can render it and an
|
||||
* operator can read it:
|
||||
*
|
||||
* `skipped` no shard configured — the file era had no equivalent here
|
||||
* `unavailable` the shard could not answer (down, plane off, no libgdiplus)
|
||||
* `unchanged` the client files match what was imported; nothing fetched
|
||||
* `imported` fetched and applied
|
||||
* `needsReview` a key we hold has vanished from the shard's manifest
|
||||
* `failed` something went wrong mid-import
|
||||
*
|
||||
* `force` re-imports even when the client files are unchanged (which is also how
|
||||
* an operator recovers from a deleted uploads directory — the database still
|
||||
* holds the hashes, but the files behind them are gone). `approve` accepts a
|
||||
* catalogue that no longer offers keys we hold.
|
||||
*
|
||||
* `by` is who pressed the button, carried through only so the panel can say what
|
||||
* the last import did and who ran it without reading the audit log (phase 8). It
|
||||
* decides nothing.
|
||||
*/
|
||||
async function importAssets({ force = false, approve = false, by = null } = {}) {
|
||||
if (!(await shardLinked())) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
reason: 'uo-link is not configured, so there is no shard to read client files from',
|
||||
}
|
||||
}
|
||||
|
||||
let sources
|
||||
|
||||
try {
|
||||
sources = await bridge.sourceFingerprint()
|
||||
} catch (err) {
|
||||
return failure(err, 'client file manifest')
|
||||
}
|
||||
|
||||
// §4.4: a Linux shard host without libgdiplus cannot render a sprite at all.
|
||||
// It is reported on the source gate precisely so an operator meets it while
|
||||
// setting the shard up rather than from an empty bestiary weeks later.
|
||||
if (sources.imaging && sources.imaging.ok === false) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
code: 'NO_IMAGING',
|
||||
reason: sources.imaging.reason || 'the shard host cannot render images',
|
||||
}
|
||||
}
|
||||
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
|
||||
if (!force && bridge.sameSources(sources, meta?.sources)) {
|
||||
const counts = await db.countAssets(bridge.FAMILY)
|
||||
const bodies = await db.countBodies()
|
||||
|
||||
return {
|
||||
status: 'unchanged',
|
||||
assets: counts.total,
|
||||
stored: counts.stored,
|
||||
bodies: bodies.resolved,
|
||||
hashing: sources.hashing,
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
let manifest
|
||||
|
||||
try {
|
||||
manifest = await bridge.readManifest({ family: bridge.FAMILY })
|
||||
} catch (err) {
|
||||
return failure(err, 'asset manifest')
|
||||
}
|
||||
|
||||
// The body family only. This diff decides what gets DELETED, and the manifest
|
||||
// it is diffed against is of one family by construction — so reading the whole
|
||||
// table here stages every item picture phase 5 warmed as a vanished key.
|
||||
const held = await db.allAssets(bridge.FAMILY)
|
||||
const offered = new Set(manifest.rows.map((r) => r.key))
|
||||
|
||||
// A key we hold that the shard no longer offers. An unmounted client volume and
|
||||
// a deliberate downgrade look identical from here, and the wrong guess deletes
|
||||
// artwork, so it is staged rather than applied — the same rule, and the same
|
||||
// reasoning, as a vanished cliloc overlay or a disappearing atlas facet.
|
||||
const vanished = [...held.keys()].filter((key) => !offered.has(key))
|
||||
|
||||
if (vanished.length > 0 && !approve) {
|
||||
return {
|
||||
status: 'needsReview',
|
||||
reason:
|
||||
`${vanished.length} asset(s) this site holds are no longer offered by the shard; ` +
|
||||
'nothing was changed',
|
||||
// Each one carries the picture it currently has, because the decision the
|
||||
// operator is being asked for is "is it right that these disappear?" and a
|
||||
// list of keys cannot be looked at. `body/820/a23` names nothing a human
|
||||
// recognises; the horse it is a picture of does.
|
||||
vanished: vanished.slice(0, 50).map((key) => ({ key, file: held.get(key)?.file ?? null })),
|
||||
vanishedCount: vanished.length,
|
||||
}
|
||||
}
|
||||
|
||||
// The diff, and the whole reason stage 2 carries hashes and not pixels. An
|
||||
// unchanged key is skipped ONLY if its file is actually still on disk: the row
|
||||
// and the file can disagree (a wiped uploads volume, a restore from a database
|
||||
// dump), and re-fetching a sprite is far cheaper than a creature page with a
|
||||
// broken image on it.
|
||||
const wanted = manifest.rows.filter((row) => {
|
||||
const existing = held.get(row.key)
|
||||
if (!existing || existing.sha256 !== row.sha256) return true
|
||||
if (!existing.file) return true
|
||||
return !fs.existsSync(path.join(artDir(), existing.file))
|
||||
})
|
||||
|
||||
let fetched = { assets: new Map(), missing: { absent: 0, unsupported: 0 } }
|
||||
|
||||
if (wanted.length > 0) {
|
||||
try {
|
||||
fetched = await bridge.fetchAssets({
|
||||
keys: wanted.map((r) => r.key),
|
||||
catalog: manifest.catalog,
|
||||
})
|
||||
} catch (err) {
|
||||
return failure(err, 'asset content')
|
||||
}
|
||||
}
|
||||
|
||||
const rows = []
|
||||
let written = 0
|
||||
|
||||
for (const row of manifest.rows) {
|
||||
const existing = held.get(row.key)
|
||||
const got = fetched.assets.get(row.key)
|
||||
|
||||
if (!got) {
|
||||
// Either it was unchanged and skipped, or the shard could not serve it. The
|
||||
// row is kept either way, with whatever file it already had — a key the
|
||||
// shard suddenly cannot render must not lose the picture we already hold.
|
||||
rows.push({ ...row, file: existing?.file ?? null })
|
||||
continue
|
||||
}
|
||||
|
||||
const name = writeSprite(row.key, got.sha256, got.png)
|
||||
|
||||
if (name) {
|
||||
written++
|
||||
if (existing?.file && existing.file !== name) removeSprite(existing.file)
|
||||
}
|
||||
|
||||
rows.push({
|
||||
...row,
|
||||
sha256: got.sha256 || row.sha256,
|
||||
bytes: got.bytes || row.bytes,
|
||||
width: got.width || row.width,
|
||||
height: got.height || row.height,
|
||||
body: got.body ?? row.body,
|
||||
action: got.action ?? row.action ?? 0,
|
||||
direction: got.direction ?? row.direction,
|
||||
file: name ?? existing?.file ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const removed = []
|
||||
|
||||
if (vanished.length > 0) {
|
||||
for (const key of vanished) {
|
||||
removeSprite(held.get(key)?.file)
|
||||
removed.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await db.saveAssets(
|
||||
rows,
|
||||
{
|
||||
catalog: manifest.catalog,
|
||||
extractorVersion: manifest.extractorVersion,
|
||||
family: bridge.FAMILY,
|
||||
playerBodies: manifest.playerBodies,
|
||||
sources: { files: sources.files, extractorVersion: sources.extractorVersion },
|
||||
count: rows.length,
|
||||
},
|
||||
// The approved removals go in with the write. The sprite is already
|
||||
// unlinked above; leaving the row behind would keep counting a picture
|
||||
// that is gone and re-offer the same key for review on every import.
|
||||
removed,
|
||||
)
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
|
||||
const bodies = await resolveAtlasBodies()
|
||||
const art = await applyArt()
|
||||
|
||||
// What this run did, kept beside the catalogue it produced (phase 8). The admin
|
||||
// panel renders it as "the last import", which is the question an operator has
|
||||
// straight after pressing a button that takes a minute and prints nothing:
|
||||
// what changed, and did the body pass find drift. Core's activity log records
|
||||
// the same action, but it is one unfiltered list of every admin action on the
|
||||
// site, so an import from three client patches ago is not findable there.
|
||||
//
|
||||
// Best-effort on purpose: the import has already applied, and losing a cosmetic
|
||||
// summary must not turn a successful import into a failure.
|
||||
const last = {
|
||||
at: new Date().toISOString(),
|
||||
by,
|
||||
force,
|
||||
approve,
|
||||
assets: rows.length,
|
||||
fetched: fetched.assets.size,
|
||||
written,
|
||||
removed: removed.length,
|
||||
absent: fetched.missing.absent,
|
||||
unsupported: fetched.missing.unsupported,
|
||||
bodies: bodies.tally ?? null,
|
||||
art: art.applied ?? 0,
|
||||
}
|
||||
|
||||
try {
|
||||
await db.recordLastImport(last)
|
||||
} catch (err) {
|
||||
log.warn('could not record the import summary', { error: err.message })
|
||||
}
|
||||
|
||||
log.info('asset import applied', {
|
||||
assets: rows.length,
|
||||
fetched: fetched.assets.size,
|
||||
written,
|
||||
absent: fetched.missing.absent,
|
||||
bodies: bodies.resolved,
|
||||
art: art.applied,
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'imported',
|
||||
catalog: manifest.catalog,
|
||||
extractorVersion: manifest.extractorVersion,
|
||||
assets: rows.length,
|
||||
fetched: fetched.assets.size,
|
||||
written,
|
||||
absent: fetched.missing.absent,
|
||||
unsupported: fetched.missing.unsupported,
|
||||
removed: removed.length,
|
||||
scanned: manifest.scanned,
|
||||
pages: manifest.pages,
|
||||
playerBodies: manifest.playerBodies,
|
||||
bodies,
|
||||
art,
|
||||
}
|
||||
}
|
||||
|
||||
function failure(err, what) {
|
||||
if (err instanceof bridge.AssetBridgeError) {
|
||||
return { status: 'unavailable', code: err.code, reason: err.message }
|
||||
}
|
||||
|
||||
log.warn(`asset import failed reading the ${what}`, { error: err.message })
|
||||
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
|
||||
// ── the body pass (§8) ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ask the shard for a body id for every creature the atlas knows.
|
||||
*
|
||||
* `shard_spawn_creatures.name` is the ServUO class name — the atlas build picks
|
||||
* the winning spelling of the spawn TYPE token rather than inventing a display
|
||||
* name — so this needs no new column to ask its question.
|
||||
*
|
||||
* Never throws: a shard that goes down between the asset fetch and this pass
|
||||
* leaves the assets imported and the map as it was, which is a strictly better
|
||||
* state than failing the whole import back to nothing.
|
||||
*/
|
||||
async function resolveAtlasBodies() {
|
||||
let creatures = []
|
||||
|
||||
try {
|
||||
creatures = await atlasDb.allCreatureTypes()
|
||||
} catch (err) {
|
||||
return { resolved: 0, asked: 0, reason: err.message }
|
||||
}
|
||||
|
||||
if (creatures.length === 0) {
|
||||
return { resolved: 0, asked: 0, reason: 'the spawn atlas has no creatures loaded' }
|
||||
}
|
||||
|
||||
let rows
|
||||
|
||||
try {
|
||||
rows = await bridge.resolveBodies({ creatures })
|
||||
} catch (err) {
|
||||
return { resolved: 0, asked: creatures.length, reason: err.message }
|
||||
}
|
||||
|
||||
try {
|
||||
await db.replaceBodies(rows)
|
||||
} catch (err) {
|
||||
return { resolved: 0, asked: creatures.length, reason: err.message }
|
||||
}
|
||||
|
||||
const tally = { ok: 0, unknown: 0, notCreature: 0, failed: 0 }
|
||||
|
||||
for (const row of rows) {
|
||||
if (tally[row.status] === undefined) tally.failed++
|
||||
else tally[row.status]++
|
||||
}
|
||||
|
||||
return { asked: creatures.length, answered: rows.length, resolved: tally.ok, tally }
|
||||
}
|
||||
|
||||
// ── the derivation (§12) ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Point every atlas creature at its imported portrait.
|
||||
*
|
||||
* Two rules, and the second is the one worth stating:
|
||||
*
|
||||
* 1. The operator's `spawnAtlas.art.json` wins. Someone who drew their own
|
||||
* creature portraits must not have them replaced by a sprite rip.
|
||||
* 2. A slug with neither is set back to NULL rather than left alone. A creature
|
||||
* whose body stopped resolving — the operator removed a script package, say
|
||||
* — would otherwise keep pointing at a file that is about to be deleted, and
|
||||
* a broken image is worse than no image.
|
||||
*/
|
||||
async function applyArt() {
|
||||
const derived = await db.artBySlug()
|
||||
const operator = operatorArt()
|
||||
|
||||
const map = { ...derived, ...operator }
|
||||
|
||||
try {
|
||||
const applied = await atlasDb.setCreatureArt(map)
|
||||
return { applied, derived: Object.keys(derived).length, operator: Object.keys(operator).length }
|
||||
} catch (err) {
|
||||
log.warn('could not apply imported creature art', { error: err.message })
|
||||
return { applied: 0, derived: Object.keys(derived).length, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ── status ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* What the admin panel renders: what is loaded, what the shard says, and whether
|
||||
* the two agree.
|
||||
*
|
||||
* Never throws and never fails a page: every branch that could — no shard, a
|
||||
* shard that is down, an asset plane the operator switched off — is a reported
|
||||
* state with a reason an operator can act on.
|
||||
*/
|
||||
async function getStatus() {
|
||||
// The BODY family, not the whole table: item and land art live here too and
|
||||
// are reported separately below, because they are a working set rather than a
|
||||
// catalogue with a size (§11).
|
||||
const counts = await db.countAssets(bridge.FAMILY).catch(() => ({ total: 0, stored: 0 }))
|
||||
const bodies = await db.countBodies().catch(() => ({ total: 0, resolved: 0 }))
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const families = await db.countByFamily().catch(() => ({}))
|
||||
|
||||
const status = {
|
||||
// Is there a shard to ask at all? Stated rather than left to be inferred:
|
||||
// the panel disables its import buttons on it, and the alternative — reading
|
||||
// it out of `reason`'s wording, or out of `shard` being null, which is also
|
||||
// what a shard that is merely DOWN looks like — is a sentence that decides
|
||||
// behaviour.
|
||||
linked: await shardLinked(),
|
||||
loaded: {
|
||||
assets: counts.total,
|
||||
stored: counts.stored,
|
||||
creatures: bodies.total,
|
||||
resolved: bodies.resolved,
|
||||
catalog: meta?.catalog ?? null,
|
||||
extractorVersion: meta?.extractorVersion ?? null,
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
// Item and land pictures, counted separately because they are a different
|
||||
// KIND of thing (§11, phase 5): no manifest, no set, and no "how many are
|
||||
// there" to compare against. `items` is how many the site has been asked
|
||||
// for and holds, which is the only number that means anything here.
|
||||
items: families.static?.stored ?? 0,
|
||||
land: families.land?.stored ?? 0,
|
||||
// What the last import did, and who ran it (phase 8). Null on an install
|
||||
// that has never imported, and on one whose last import predates this
|
||||
// field — both of which render as "no import recorded" rather than as
|
||||
// zeroes, because an import that fetched nothing is a real and different
|
||||
// answer from one that never happened.
|
||||
last: meta?.last ?? null,
|
||||
},
|
||||
shard: null,
|
||||
drift: null,
|
||||
}
|
||||
|
||||
if (!status.linked) {
|
||||
status.reason = 'uo-link is not configured'
|
||||
return status
|
||||
}
|
||||
|
||||
try {
|
||||
const sources = await bridge.sourceFingerprint()
|
||||
|
||||
status.shard = {
|
||||
files: Object.keys(sources.files).length,
|
||||
extractorVersion: sources.extractorVersion,
|
||||
hashing: sources.hashing,
|
||||
complete: sources.complete,
|
||||
imaging: sources.imaging,
|
||||
// Which §5 families this overlay serves. A phase-3 or phase-4 overlay says
|
||||
// `['body']`, which is what an admin panel needs in order to say "update
|
||||
// your plugin" rather than showing an item-art pipeline that cannot work.
|
||||
families: sources.families,
|
||||
}
|
||||
|
||||
status.drift = meta ? !bridge.sameSources(sources, meta.sources) : true
|
||||
} catch (err) {
|
||||
status.reason = err.message
|
||||
status.code = err instanceof bridge.AssetBridgeError ? err.code : 'UNAVAILABLE'
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ART_SUBDIR,
|
||||
artDir,
|
||||
fileNameFor,
|
||||
importAssets,
|
||||
resolveAtlasBodies,
|
||||
applyArt,
|
||||
getStatus,
|
||||
}
|
||||
510
server/model/shardAssets/shardItemArt.model.js
Normal file
510
server/model/shardAssets/shardItemArt.model.js
Normal file
@@ -0,0 +1,510 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const db = require('./shardAssets.db')
|
||||
const core = require('../../core')
|
||||
const bridge = require('../../utils/assetBridge')
|
||||
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
|
||||
const log = require('../../core').logger('shardItemArt')
|
||||
|
||||
// Item and land pictures, fetched because something on this site names them
|
||||
// (docs/link/v8.md §5, §11 — protocol 8, phase 5).
|
||||
//
|
||||
// ── Why this is not the body catalogue with a different prefix ─────────
|
||||
//
|
||||
// The bestiary wants every creature, so phase 3 imports a SET: walk a manifest,
|
||||
// diff the hashes, fetch what moved. That works because the set is 1,095 rows
|
||||
// and one megabyte.
|
||||
//
|
||||
// This side has no set. The shard's client addresses 49,152 item graphics and
|
||||
// has art for 39,189 of them; multiply by three thousand hues and there is
|
||||
// nothing to enumerate, no manifest worth building and nothing worth importing
|
||||
// ahead of time. What there IS, at any moment, is a few hundred keys that the
|
||||
// site's own rows actually name: the items on a vendor, the things a character
|
||||
// is wearing. That is the working set, and it is what this fetches.
|
||||
//
|
||||
// ── Who is allowed to make the shard do work ──────────────────────────
|
||||
//
|
||||
// **Ingest warms; the route only serves** (org lead, 2026-09-11). A page never
|
||||
// waits on the shard and never causes a fetch: it renders the pictures already
|
||||
// on disk and leaves out the ones that are not, which is exactly the state every
|
||||
// install was in before this phase and which every surface already handles.
|
||||
// Fetching happens behind that, from the keys the site has stored.
|
||||
//
|
||||
// The alternative — fetch on the first request for a key — was rejected on one
|
||||
// number. The shard's asset plane serves **one request at a time** by design
|
||||
// (§3.2), so any anonymous visitor able to name a key could walk 49,152 ids
|
||||
// times 3,000 hues through that single slot and keep an operator's own import
|
||||
// waiting behind it, from a URL with nothing to authenticate. Warming from the
|
||||
// site's own data has no such surface: the ceiling is the number of distinct
|
||||
// (item, hue) pairs the shard itself has told us about.
|
||||
//
|
||||
// ── Why the wanted set is DERIVED and not a queue ─────────────────────
|
||||
//
|
||||
// A queue table would need writing on the ingest path, draining, retrying,
|
||||
// pruning and reconciling after a restart. The same answer falls out of a
|
||||
// `SELECT DISTINCT` over the rows that name the items — which is self-healing by
|
||||
// construction: a key lost to a restart comes back the next time the pass runs,
|
||||
// and a key for a vendor that has gone stops being wanted the moment its row is
|
||||
// deleted. The in-memory set below is an optimisation on top of that, never the
|
||||
// record: it exists so a picture seen on a LIVE character sheet — which is
|
||||
// fetched from the shard per request and stored nowhere — is not forgotten.
|
||||
//
|
||||
// ── Staleness, without a manifest (§7) ────────────────────────────────
|
||||
//
|
||||
// Every fetched row records the shard's `catalog` id, which is a hash of the
|
||||
// files that decide the bytes. A client patch changes it, a restart does not. So
|
||||
// "is this picture out of date?" is a per-row comparison rather than a manifest
|
||||
// diff, and the answer costs nothing for the pictures nobody is looking at any
|
||||
// more: they are simply never re-fetched.
|
||||
|
||||
/** Where item and land pictures land, under core's upload directory. */
|
||||
const ART_SUBDIR = 'items'
|
||||
|
||||
/**
|
||||
* How many keys one warm pass will fetch.
|
||||
*
|
||||
* A bound rather than a target. The shard serves one asset request at a time, so
|
||||
* a pass that asked for everything at once would hold that slot for as long as it
|
||||
* took — against an operator who might be trying to run an import. Passes are
|
||||
* cheap and repeat; a backlog drains over several of them and nothing waits.
|
||||
*/
|
||||
const WARM_BATCH = 400
|
||||
|
||||
/**
|
||||
* How many live-observed keys are remembered between passes.
|
||||
*
|
||||
* Bounded because this is a set fed by page views. It is an optimisation over the
|
||||
* derived set, so dropping from it costs a picture appearing one pass later, and
|
||||
* never a picture that is lost.
|
||||
*/
|
||||
const SEEN_CAP = 5000
|
||||
|
||||
const seen = new Set()
|
||||
|
||||
// ── keys (§5) ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The one place an item key is spelled.
|
||||
*
|
||||
* Hue 0 means "not hued" on the wire, so it produces the plain key rather than a
|
||||
* `/h0` one — the shard refuses `/h0` outright for the same reason, and the two
|
||||
* agreeing is what stops the same PNG being stored twice under two names.
|
||||
*/
|
||||
function staticKey(itemId, hue = 0) {
|
||||
const id = Number(itemId)
|
||||
|
||||
if (!Number.isInteger(id) || id < 0) return null
|
||||
|
||||
const h = Number(hue)
|
||||
|
||||
return Number.isInteger(h) && h > 0 ? `static/${id}/h${h}` : `static/${id}`
|
||||
}
|
||||
|
||||
function landKey(tileId) {
|
||||
const id = Number(tileId)
|
||||
|
||||
return Number.isInteger(id) && id >= 0 && id < 0x4000 ? `land/${id}` : null
|
||||
}
|
||||
|
||||
function artDir() {
|
||||
return path.join(core.uploads.UPLOAD_DIR, ART_SUBDIR)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-addressed, exactly as the body catalogue's names are and for the same
|
||||
* reason: a stable name overwritten in place leaves every browser and CDN serving
|
||||
* last month's client's sprite while the database row stays perfectly correct.
|
||||
*/
|
||||
function fileNameFor(key, sha256) {
|
||||
const stem = key.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return `uo-${stem}-${String(sha256).slice(0, 8)}.png`
|
||||
}
|
||||
|
||||
// ── noticing ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Remember that something on this site showed these (itemId, hue) pairs.
|
||||
*
|
||||
* Called from the market ingest and from the character sheet, and deliberately
|
||||
* synchronous and allocation-light: it is on a request path and a page must never
|
||||
* pay for a picture it is not going to get anyway.
|
||||
*/
|
||||
function notice(items) {
|
||||
if (!Array.isArray(items)) return 0
|
||||
|
||||
let added = 0
|
||||
|
||||
for (const item of items) {
|
||||
const key = staticKey(item?.itemId ?? item?.item_id, item?.hue)
|
||||
|
||||
if (!key || seen.has(key)) continue
|
||||
|
||||
// Oldest-first, and only when full. The derived set is the record; this is a
|
||||
// cache of hints, so forgetting one costs a pass, not a picture.
|
||||
if (seen.size >= SEEN_CAP) seen.delete(seen.values().next().value)
|
||||
|
||||
seen.add(key)
|
||||
added++
|
||||
}
|
||||
|
||||
return added
|
||||
}
|
||||
|
||||
/** For tests and the admin surface: how many hints are waiting. */
|
||||
function noticedCount() {
|
||||
return seen.size
|
||||
}
|
||||
|
||||
// ── serving ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attach `art` to rows that name an item, in place, and notice what is missing.
|
||||
*
|
||||
* `art` is a FILENAME under `uploads/items/`, never a path or a URL — the same
|
||||
* shape `shard_spawn_creatures.art` uses, so the client builds one URL the same
|
||||
* way everywhere and the API never hard-codes a mount point.
|
||||
*
|
||||
* A row with no stored picture gets `art: null` rather than being changed in any
|
||||
* other way. That is a first-class state: it is what every row looked like before
|
||||
* this phase, every surface renders it, and it is what an item this client has no
|
||||
* art for looks like permanently.
|
||||
*/
|
||||
async function decorate(rows, { itemIdField = 'itemId', hueField = 'hue' } = {}) {
|
||||
const list = Array.isArray(rows) ? rows.filter((r) => r && typeof r === 'object') : []
|
||||
|
||||
if (list.length === 0) return list
|
||||
|
||||
const keys = list.map((row) => staticKey(row[itemIdField], row[hueField]))
|
||||
|
||||
let files = new Map()
|
||||
|
||||
try {
|
||||
files = await db.filesForKeys(keys.filter(Boolean))
|
||||
} catch (err) {
|
||||
// Decoration, not the page. A picture lookup that fails must not fail a
|
||||
// marketplace search.
|
||||
log.warn('could not read item art', { error: err.message })
|
||||
return list
|
||||
}
|
||||
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
list[i].art = (keys[i] && files.get(keys[i])) || null
|
||||
}
|
||||
|
||||
// Everything this page WANTED is worth warming, whether or not we had it: the
|
||||
// ones we had may be stale, and the ones we did not are the point.
|
||||
notice(list.map((row) => ({ itemId: row[itemIdField], hue: row[hueField] })))
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
// ── warming ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function shardLinked() {
|
||||
try {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
return Boolean(config?.enabled && config?.baseUrl)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every item key the site's own rows name, newest-priced first.
|
||||
*
|
||||
* `shard_vendor_items` is the only stored table that carries (item_id, hue)
|
||||
* today. The character sheet's equipment is fetched live from the shard per
|
||||
* request and stored nowhere, which is precisely what the in-memory hint set is
|
||||
* for.
|
||||
*/
|
||||
async function wantedKeys() {
|
||||
const keys = []
|
||||
|
||||
try {
|
||||
const rows = await core.query(
|
||||
'SELECT DISTINCT item_id, hue FROM shard_vendor_items WHERE item_id > 0 LIMIT 20000',
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
const key = staticKey(row.item_id, row.hue)
|
||||
if (key) keys.push(key)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('could not read the marketplace for item art', { error: err.message })
|
||||
}
|
||||
|
||||
// Hints last, so a backlog of stored rows is never starved by page traffic.
|
||||
for (const key of seen) keys.push(key)
|
||||
|
||||
return [...new Set(keys)]
|
||||
}
|
||||
|
||||
function writePicture(key, sha256, png) {
|
||||
const name = fileNameFor(key, sha256)
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artDir(), { recursive: true })
|
||||
fs.writeFileSync(path.join(artDir(), name), png)
|
||||
return name
|
||||
} catch (err) {
|
||||
log.warn('could not write an item picture', { key, error: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function removePicture(name) {
|
||||
if (!name) return
|
||||
|
||||
try {
|
||||
fs.unlinkSync(path.join(artDir(), name))
|
||||
} catch {
|
||||
// Already gone, or never written. A warm pass must not fail because a file it
|
||||
// was tidying up was tidied already.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One warm pass: fetch the wanted keys we do not already hold, and store them.
|
||||
*
|
||||
* Returns a result rather than throwing, with the same vocabulary the body import
|
||||
* uses — `skipped`, `unavailable`, `unchanged`, `imported`, `failed` — so the
|
||||
* admin surface reports one set of words for both halves of this protocol.
|
||||
*
|
||||
* `limit` bounds one pass. `force` re-fetches keys we hold, which is how an
|
||||
* operator recovers from a wiped uploads volume without waiting for a client
|
||||
* patch to invalidate every row.
|
||||
*/
|
||||
async function warm({ limit = WARM_BATCH, force = false } = {}) {
|
||||
if (!(await shardLinked())) {
|
||||
return { status: 'skipped', reason: 'uo-link is not configured, so there is no shard to ask' }
|
||||
}
|
||||
|
||||
let sources
|
||||
|
||||
try {
|
||||
sources = await bridge.sourceFingerprint()
|
||||
} catch (err) {
|
||||
return failure(err, 'client file manifest')
|
||||
}
|
||||
|
||||
if (sources.imaging && sources.imaging.ok === false) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
code: 'NO_IMAGING',
|
||||
reason: sources.imaging.reason || 'the shard host cannot render images',
|
||||
}
|
||||
}
|
||||
|
||||
// A phase-3 or phase-4 overlay serves bodies and nothing else. Asking it for a
|
||||
// static is refused per request, which would be a warn on every pass forever —
|
||||
// so it is checked once, here, and reported as the ordinary state it is.
|
||||
// Defensive default rather than a trusted field: an older sidecar, an older
|
||||
// overlay or a stubbed fingerprint can all leave it off, and `['body']` is the
|
||||
// truthful reading of its absence (§6 — the families field arrived in phase 5).
|
||||
const families = Array.isArray(sources.families) ? sources.families : ['body']
|
||||
|
||||
if (!families.includes('static')) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
code: 'UNSUPPORTED',
|
||||
reason:
|
||||
"this shard's overlay does not serve item art; it offers " +
|
||||
`${families.join(', ')}. Update the plugin overlay to get it.`,
|
||||
}
|
||||
}
|
||||
|
||||
const wanted = await wantedKeys()
|
||||
|
||||
if (wanted.length === 0) {
|
||||
return { status: 'unchanged', wanted: 0, fetched: 0, written: 0 }
|
||||
}
|
||||
|
||||
// The catalogue is learned from the first reply rather than asked for, so this
|
||||
// pass cannot be the thing that decides what is stale. `catalog: null` on the
|
||||
// request means "whatever you have"; the mid-walk guard in `fetchAssets` is what
|
||||
// catches a client that moves underneath it.
|
||||
let held = new Set()
|
||||
|
||||
if (!force) {
|
||||
const current = await currentCatalog()
|
||||
|
||||
try {
|
||||
held = await db.freshKeys(wanted, current)
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const todo = wanted.filter((key) => !held.has(key)).slice(0, Math.max(1, limit))
|
||||
|
||||
if (todo.length === 0) {
|
||||
forget(wanted)
|
||||
return { status: 'unchanged', wanted: wanted.length, held: held.size, fetched: 0, written: 0 }
|
||||
}
|
||||
|
||||
let fetched
|
||||
|
||||
try {
|
||||
fetched = await bridge.fetchAssets({ keys: todo })
|
||||
} catch (err) {
|
||||
return failure(err, 'item art')
|
||||
}
|
||||
|
||||
// Only the filenames, and only for the keys in hand: the old file is removed
|
||||
// when a key's hash moves, so `uploads/items/` tracks the working set instead of
|
||||
// accumulating one file per client patch forever.
|
||||
const existing = await db.filesForKeys(todo).catch(() => new Map())
|
||||
const rows = []
|
||||
let written = 0
|
||||
|
||||
for (const key of todo) {
|
||||
const got = fetched.assets.get(key)
|
||||
|
||||
// A key the shard has no art for is not a failure and not a row: writing an
|
||||
// empty row would make it "held" and stop it ever being asked again, which is
|
||||
// wrong the moment an operator patches in the missing graphic.
|
||||
if (!got) continue
|
||||
|
||||
const name = writePicture(key, got.sha256, got.png)
|
||||
|
||||
if (!name) continue
|
||||
|
||||
written++
|
||||
|
||||
const before = existing.get(key)
|
||||
|
||||
if (before && before !== name) removePicture(before)
|
||||
|
||||
rows.push({
|
||||
key,
|
||||
family: key.startsWith('land/') ? 'land' : 'static',
|
||||
sha256: got.sha256,
|
||||
bytes: got.bytes,
|
||||
width: got.width,
|
||||
height: got.height,
|
||||
body: null,
|
||||
direction: null,
|
||||
file: name,
|
||||
catalog: fetched.catalog,
|
||||
})
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
try {
|
||||
// No meta: `shard_asset_meta` is the BODY catalogue's singleton — what an
|
||||
// Update compares a manifest against — and this family has no manifest. A
|
||||
// warm pass writing there would tell the body import that a client it never
|
||||
// looked at is unchanged.
|
||||
await db.saveAssets(rows, null)
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
forget(todo)
|
||||
|
||||
const result = {
|
||||
status: 'imported',
|
||||
catalog: fetched.catalog,
|
||||
wanted: wanted.length,
|
||||
held: held.size,
|
||||
asked: todo.length,
|
||||
fetched: fetched.assets.size,
|
||||
written,
|
||||
absent: fetched.missing.absent,
|
||||
unsupported: fetched.missing.unsupported,
|
||||
remaining: Math.max(0, wanted.length - held.size - todo.length),
|
||||
}
|
||||
|
||||
log.info('item art warmed', result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Drop hints a pass has dealt with, so the set does not grow without bound. */
|
||||
function forget(keys) {
|
||||
for (const key of keys) seen.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalogue id the shard would answer under right now.
|
||||
*
|
||||
* Read from a one-key probe rather than from a dedicated command: the shard puts
|
||||
* `catalog` on every fetch reply, so the cheapest honest way to ask is to fetch
|
||||
* something. `static/0` is the smallest such question and its answer is thrown
|
||||
* away — what is wanted is the id beside it.
|
||||
*
|
||||
* A shard that cannot answer returns null, and null compares unequal to every
|
||||
* stored catalogue, so the pass falls back to "everything is stale" — which costs
|
||||
* a re-fetch and never serves a wrong picture. That is the right way round.
|
||||
*/
|
||||
async function currentCatalog() {
|
||||
try {
|
||||
const probe = await bridge.fetchAssets({ keys: ['static/0'] })
|
||||
return probe.catalog ?? null
|
||||
} catch (err) {
|
||||
log.warn('could not read the shard art catalogue', { error: err.message })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function failure(err, what) {
|
||||
if (err instanceof bridge.AssetBridgeError) {
|
||||
return { status: 'unavailable', code: err.code, reason: err.message }
|
||||
}
|
||||
|
||||
log.warn(`item art failed reading the ${what}`, { error: err.message })
|
||||
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
|
||||
// ── the background pass ────────────────────────────────────────────────────
|
||||
|
||||
let timer = null
|
||||
|
||||
/**
|
||||
* Run a warm pass every few minutes, forever, while the process lives.
|
||||
*
|
||||
* Deliberately a plain interval and not a debounce on ingest. A market sweep
|
||||
* delivers dozens of `vendor.listing` frames in a burst and debouncing each of
|
||||
* them would either fire once per frame or need its own state machine; a pass is
|
||||
* cheap when there is nothing to do (one `SELECT DISTINCT` and one probe) and the
|
||||
* work it exists for is not urgent — a picture appearing a few minutes after the
|
||||
* listing that wants it is invisible to everyone.
|
||||
*
|
||||
* `unref()` so this never holds the process open at shutdown.
|
||||
*/
|
||||
function startWarming({ everyMs = 5 * 60 * 1000 } = {}) {
|
||||
if (timer) return
|
||||
|
||||
timer = setInterval(() => {
|
||||
warm().catch((err) => log.warn('item art warm pass failed', { error: err.message }))
|
||||
}, everyMs)
|
||||
|
||||
if (typeof timer.unref === 'function') timer.unref()
|
||||
}
|
||||
|
||||
function stopWarming() {
|
||||
if (!timer) return
|
||||
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ART_SUBDIR,
|
||||
WARM_BATCH,
|
||||
artDir,
|
||||
fileNameFor,
|
||||
staticKey,
|
||||
landKey,
|
||||
notice,
|
||||
noticedCount,
|
||||
decorate,
|
||||
wantedKeys,
|
||||
warm,
|
||||
currentCatalog,
|
||||
startWarming,
|
||||
stopWarming,
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const ATLAS_TABLES = [
|
||||
'shard_regions',
|
||||
'shard_landmarks',
|
||||
'shard_champion_spawns',
|
||||
'shard_decor_types',
|
||||
]
|
||||
|
||||
async function insertBatched(conn, sql, rows) {
|
||||
@@ -103,6 +104,16 @@ async function replaceAtlas(atlas, art = {}) {
|
||||
]),
|
||||
)
|
||||
|
||||
// Optional: a tree with no Data/Decoration leaves this empty rather than
|
||||
// failing the import, and the decoration verb then simply has nothing to
|
||||
// offer. `?? []` rather than a guard, so an atlas built by an older parser
|
||||
// (no `decor` key at all) reloads cleanly instead of throwing here.
|
||||
counts.decor = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_decor_types (type, item_id, uses) VALUES (?,?,?)',
|
||||
(atlas.decor ?? []).map((d) => [d.type, d.itemId ?? 0, d.uses ?? 0]),
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -110,13 +121,14 @@ async function replaceAtlas(atlas, art = {}) {
|
||||
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, ' +
|
||||
'(id, facet, name, unique_id, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
||||
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
atlas.points.map((p, i) => [
|
||||
i + 1,
|
||||
p.facet,
|
||||
p.name,
|
||||
p.uniqueId || null,
|
||||
p.x,
|
||||
p.y,
|
||||
p.width ?? 0,
|
||||
@@ -357,6 +369,73 @@ function listLandmarks({ facet = '', q = '' } = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every decoration type this shard uses, most-used first.
|
||||
*
|
||||
* Ordered by `uses` because a dropdown of 313 types needs the ones the shard
|
||||
* actually reaches for at the top; the alphabetical tiebreak keeps the order
|
||||
* stable across imports, which matters for a form an author scrolls.
|
||||
*/
|
||||
function listDecorTypes({ q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (q) {
|
||||
where.push('type LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT type, item_id, uses
|
||||
FROM shard_decor_types
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY uses DESC, type ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawners an author can name, searched by name and bounded (Phase 12b).
|
||||
*
|
||||
* **A search rather than a list, and the numbers are why.** This tree has 6,707
|
||||
* spawn points against a 2,000-entry dropdown bound, so a flat read would drop
|
||||
* two thirds of the world and say nothing about which two thirds — the failure
|
||||
* Phase 12a named for decoration, arriving for real. `resolveOptionSource` grew
|
||||
* a `q` for this.
|
||||
*
|
||||
* Only rows with a `unique_id` are offered: that is the only name for a spawner
|
||||
* that exists off the shard, and a row without one cannot be targeted from a
|
||||
* form however it is labelled. A shard's own in-world spawners have none and are
|
||||
* addressed by serial, which an author types rather than picks.
|
||||
*
|
||||
* Ordered by `max_count DESC` so the spawners worth an event's attention come
|
||||
* first, with a stable alphabetical tiebreak for a form somebody scrolls.
|
||||
*/
|
||||
function listSpawners({ q = '', limit = 200 } = {}) {
|
||||
const where = ['unique_id IS NOT NULL', "unique_id <> ''"]
|
||||
const params = []
|
||||
if (q) {
|
||||
where.push('(name LIKE ? OR region LIKE ? OR landmark LIKE ?)')
|
||||
params.push(`%${q}%`, `%${q}%`, `%${q}%`)
|
||||
}
|
||||
params.push(Number(limit) || 200)
|
||||
return query(
|
||||
`SELECT unique_id, name, facet, region, landmark, max_count
|
||||
FROM shard_spawn_points
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY max_count DESC, name ASC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** One decoration type, or nothing when this shard's files never name it. */
|
||||
async function getDecorType(type) {
|
||||
const rows = await query(
|
||||
'SELECT type, item_id, uses FROM shard_decor_types WHERE type = ?',
|
||||
[type],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
function listChampions({ facet = '' } = {}) {
|
||||
const params = []
|
||||
let where = ''
|
||||
@@ -373,8 +452,64 @@ function listChampions({ facet = '' } = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every creature the atlas knows, as `{ slug, name }` (docs/link/v8.md §8).
|
||||
*
|
||||
* `name` is the ServUO CLASS NAME, not a display string invented here: the atlas
|
||||
* build picks the winning spelling of the spawn type token, so "GiantSpider" is
|
||||
* what the column holds and what `ScriptCompiler.FindTypeByName` will resolve.
|
||||
* That is the one property that lets the asset import ask its question without a
|
||||
* new column, and it is worth knowing before anyone "tidies" this into a
|
||||
* prettified label.
|
||||
*/
|
||||
async function allCreatureTypes() {
|
||||
return query('SELECT slug, name FROM shard_spawn_creatures ORDER BY slug')
|
||||
}
|
||||
|
||||
/**
|
||||
* Point creatures at their artwork, from a `{ slug: filename }` map.
|
||||
*
|
||||
* Everything NOT in the map is set back to NULL, which is deliberate: a creature
|
||||
* whose body stopped resolving must lose its portrait rather than keep pointing
|
||||
* at a file that is about to be deleted. A broken image is worse than no image,
|
||||
* and no image is the state the whole atlas UI was designed around.
|
||||
*
|
||||
* One transaction, and a single `CASE` update rather than a statement per slug —
|
||||
* at ~800 creatures the round trips are the cost, not the work.
|
||||
*/
|
||||
async function setCreatureArt(map) {
|
||||
const entries = Object.entries(map ?? {}).filter(
|
||||
([slug, file]) => typeof slug === 'string' && slug !== '' && typeof file === 'string' && file !== '',
|
||||
)
|
||||
|
||||
const conn = await core.pool.getConnection()
|
||||
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query('UPDATE shard_spawn_creatures SET art = NULL WHERE art IS NOT NULL')
|
||||
|
||||
for (let i = 0; i < entries.length; i += BATCH) {
|
||||
await conn.batch(
|
||||
'UPDATE shard_spawn_creatures SET art = ? WHERE slug = ?',
|
||||
entries.slice(i, i + BATCH).map(([slug, file]) => [file, slug]),
|
||||
)
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
|
||||
return entries.length
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceAtlas,
|
||||
allCreatureTypes,
|
||||
setCreatureArt,
|
||||
getMeta,
|
||||
getFacets,
|
||||
getPending,
|
||||
@@ -388,5 +523,8 @@ module.exports = {
|
||||
listCreatureCompanions,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listDecorTypes,
|
||||
listSpawners,
|
||||
getDecorType,
|
||||
listChampions,
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ 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 { AtlasSourceError, PARSER_VERSION, sameSources } = require('../../utils/spawnAtlasSource')
|
||||
// The two readers are reached through the namespace rather than destructured,
|
||||
// because a test stubs them ON the module object and a binding taken at require
|
||||
// time would keep calling the real one — quietly, and while reporting success.
|
||||
const spawnAtlasSource = require('../../utils/spawnAtlasSource')
|
||||
const { TreeBridgeError } = require('../../utils/treeBridge')
|
||||
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
|
||||
const log = require('../../core').logger('shardAtlas')
|
||||
|
||||
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||
@@ -37,6 +37,45 @@ const log = require('../../core').logger('shardAtlas')
|
||||
|
||||
const SETTING_KEY = 'spawn_atlas_servuo_path'
|
||||
|
||||
/**
|
||||
* Is there a shard to ask?
|
||||
*
|
||||
* Both halves matter. `baseUrl` alone is an install that has been configured and
|
||||
* then switched off, and calling it would spend a 12 s timeout to learn what the
|
||||
* row already says. Never throws: an unreadable config means "no shard", and a
|
||||
* local tree is a working answer.
|
||||
*/
|
||||
async function shardLinked() {
|
||||
try {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
return Boolean(config?.enabled && config?.baseUrl)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which end this atlas is built from (docs/link/v8.md §10, §17.7).
|
||||
*
|
||||
* **The bridge wins whenever uo-link is configured and enabled**, the same rule
|
||||
* the cliloc table follows and for the same reason: there is no version of "which
|
||||
* source?" an operator benefits from answering, so there is no setting asking it.
|
||||
* A local tree remains the source where there is no shard link — development,
|
||||
* same-host installs — plus the one-off explicit path an admin can type, which is
|
||||
* an instruction rather than a default and therefore overrules this.
|
||||
*/
|
||||
async function sourceFor(pathOverride = '') {
|
||||
const explicit = String(pathOverride || '').trim()
|
||||
if (explicit !== '') return { kind: 'fs', root: explicit }
|
||||
|
||||
if (await shardLinked()) return { kind: 'bridge', root: '' }
|
||||
|
||||
return { kind: 'fs', root: await getServuoPath() }
|
||||
}
|
||||
|
||||
/** How a source reads in a log line or an admin panel. */
|
||||
const describe = (source) => (source.kind === 'bridge' ? 'the shard bridge' : source.root)
|
||||
|
||||
/**
|
||||
* Where the ServUO tree lives.
|
||||
*
|
||||
@@ -107,8 +146,45 @@ function pointTypeRows(points) {
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* The art each creature gets when the atlas is rebuilt.
|
||||
*
|
||||
* **`replaceAtlas` empties `shard_spawn_creatures` and refills it**, so anything
|
||||
* on that row is destroyed on every refresh — and a refresh happens on every
|
||||
* boot. Before protocol 8 that cost nothing: `art` came from a file on disk and
|
||||
* was simply re-read. As of phase 3 it can also come from an IMPORT, which is
|
||||
* expensive to obtain and whose gate (the shard's client-file hashes) would say
|
||||
* "unchanged" for weeks afterwards. So the imported values are re-derived here,
|
||||
* on the way past, rather than being restored by an import that has no reason to
|
||||
* run again.
|
||||
*
|
||||
* **The operator's map is spread last and therefore wins.** Someone who drew
|
||||
* their own creature portraits must not have them replaced by a sprite rip on the
|
||||
* next Update — the one property §12 states outright.
|
||||
*
|
||||
* Never throws: the asset tables are the newer half of this pair, and an atlas
|
||||
* refresh must not start failing because an asset query did. Losing the imported
|
||||
* art for one boot is recoverable by pressing Import; a boot that cannot rebuild
|
||||
* the atlas is not.
|
||||
*/
|
||||
async function artForAtlas() {
|
||||
const operator = loadArtMap()
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const assetsDb = require('../shardAssets/shardAssets.db')
|
||||
const derived = await assetsDb.artBySlug()
|
||||
return { ...derived, ...operator }
|
||||
} catch (err) {
|
||||
log.warn('imported creature art could not be read; using the operator map alone', {
|
||||
error: err.message,
|
||||
})
|
||||
return operator
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAtlas(atlas) {
|
||||
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap())
|
||||
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, await artForAtlas())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,18 +214,29 @@ 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' }
|
||||
// would be, nor by the bridge.
|
||||
const source = await sourceFor(pathOverride)
|
||||
const root = source.root
|
||||
const where = describe(source)
|
||||
|
||||
if (source.kind === 'fs' && root === '') {
|
||||
return { status: 'skipped', reason: 'no ServUO path configured' }
|
||||
}
|
||||
|
||||
let hashes
|
||||
try {
|
||||
hashes = hashSources(root)
|
||||
hashes = await spawnAtlasSource.hashFrom(source)
|
||||
} catch (err) {
|
||||
if (err instanceof AtlasSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: root }
|
||||
if (err instanceof AtlasSourceError || err instanceof TreeBridgeError) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
source: source.kind,
|
||||
reason: err.message,
|
||||
code: err.code,
|
||||
path: where,
|
||||
}
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
return { status: 'failed', source: source.kind, reason: err.message, path: where }
|
||||
}
|
||||
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
@@ -162,7 +249,7 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
// 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 }
|
||||
return { status: 'unchanged', source: source.kind, path: where }
|
||||
}
|
||||
|
||||
// A rejected refresh must not re-prompt on every boot. It stays rejected until
|
||||
@@ -170,14 +257,28 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
// 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' }
|
||||
return {
|
||||
status: 'unchanged',
|
||||
source: source.kind,
|
||||
path: where,
|
||||
reason: 'refresh previously rejected',
|
||||
}
|
||||
}
|
||||
|
||||
let atlas
|
||||
try {
|
||||
atlas = buildAtlas(root)
|
||||
atlas = await spawnAtlasSource.buildFrom(source)
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
if (err instanceof AtlasSourceError || err instanceof TreeBridgeError) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
source: source.kind,
|
||||
reason: err.message,
|
||||
code: err.code,
|
||||
path: where,
|
||||
}
|
||||
}
|
||||
return { status: 'failed', source: source.kind, reason: err.message, path: where }
|
||||
}
|
||||
|
||||
const currentFacets = await db.getFacets().catch(() => [])
|
||||
@@ -190,7 +291,8 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
if (removedFacets.length > 0 && !approve) {
|
||||
const summary = {
|
||||
hashes,
|
||||
path: root,
|
||||
source: source.kind,
|
||||
path: where,
|
||||
currentFacets,
|
||||
incomingFacets,
|
||||
removedFacets,
|
||||
@@ -205,9 +307,16 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
|
||||
try {
|
||||
const counts = await applyAtlas(atlas)
|
||||
return { status: 'imported', path: root, counts, addedFacets, removedFacets }
|
||||
return {
|
||||
status: 'imported',
|
||||
source: source.kind,
|
||||
path: where,
|
||||
counts,
|
||||
addedFacets,
|
||||
removedFacets,
|
||||
}
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
return { status: 'failed', source: source.kind, reason: err.message, path: where }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +339,9 @@ async function rejectPending() {
|
||||
|
||||
/** Everything the admin panel needs to describe atlas state. */
|
||||
async function status({ path: pathOverride = '' } = {}) {
|
||||
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||
const source = await sourceFor(pathOverride)
|
||||
const root = source.root
|
||||
const configured = source.kind === 'bridge' || root !== ''
|
||||
const [meta, pending, facets] = await Promise.all([
|
||||
db.getMeta().catch(() => null),
|
||||
db.getPending().catch(() => null),
|
||||
@@ -239,9 +350,13 @@ async function status({ path: pathOverride = '' } = {}) {
|
||||
|
||||
let treeReadable = false
|
||||
let drift = null
|
||||
if (root !== '') {
|
||||
if (configured) {
|
||||
try {
|
||||
const hashes = hashSources(root)
|
||||
// On the bridge this is the MANIFEST, not the tree: 141 rows and ~32 KB,
|
||||
// with no file bytes crossing the wire to answer "has anything changed".
|
||||
// It is still a shard round trip on an admin page load, which is why it is
|
||||
// here and not on the boot path (§17.7).
|
||||
const hashes = await spawnAtlasSource.hashFrom(source)
|
||||
treeReadable = true
|
||||
const loaded = meta?.source
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
|
||||
@@ -255,8 +370,9 @@ async function status({ path: pathOverride = '' } = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
configured: root !== '',
|
||||
path: root,
|
||||
configured,
|
||||
source: source.kind,
|
||||
path: describe(source),
|
||||
treeReadable,
|
||||
drift,
|
||||
facets,
|
||||
@@ -272,6 +388,18 @@ async function status({ path: pathOverride = '' } = {}) {
|
||||
*/
|
||||
async function refreshOnBoot() {
|
||||
try {
|
||||
// **On the bridge it imports nothing**, deliberately, and by the same
|
||||
// reasoning as the cliloc table (§17.7). A local tree hashes in ~120 ms and
|
||||
// skips; asking the shard would put a sidecar round trip in the boot sequence
|
||||
// to answer a question whose answer is "no" on every restart that did not
|
||||
// follow a map edit — and editing spawn files is an operator action, so
|
||||
// importing became one: Admin → Shard → Import. Whatever atlas is loaded
|
||||
// keeps serving until then.
|
||||
if ((await sourceFor()).kind === 'bridge') {
|
||||
log.info('spawn atlas comes from the shard; import is admin-triggered (Admin → Shard)')
|
||||
return { status: 'skipped', source: 'bridge', reason: 'the shard is the atlas source' }
|
||||
}
|
||||
|
||||
const result = await refresh()
|
||||
switch (result.status) {
|
||||
case 'imported':
|
||||
@@ -403,6 +531,59 @@ async function getCreature(slug, { facet = '', points = 200 } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoration types, shaped for a dropdown.
|
||||
*
|
||||
* `type` is both the value and the label: it is the ServUO class name and it is
|
||||
* what the plugin constructs from, so showing the author anything else would
|
||||
* put a name on the screen that does not appear in the refusal if the shard
|
||||
* declines it.
|
||||
*/
|
||||
async function listDecorTypes(opts = {}) {
|
||||
const rows = await db.listDecorTypes(opts)
|
||||
return rows.map((r) => ({
|
||||
type: r.type,
|
||||
itemId: Number(r.item_id) || 0,
|
||||
uses: Number(r.uses) || 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawners an author can name, searched (Phase 12b).
|
||||
*
|
||||
* The value is the `UniqueId` because that is what the shard resolves a target
|
||||
* by; the label is the spawner's own name, which is what an author recognises
|
||||
* ("fel bulbous putrification" is a place they know). A row with no name still
|
||||
* answers, labelled by its id, rather than being dropped: a nameless spawner is
|
||||
* still a spawner somebody may need to turn down.
|
||||
*/
|
||||
async function listSpawners(opts = {}) {
|
||||
const rows = await db.listSpawners(opts)
|
||||
return rows.map((r) => ({
|
||||
uniqueId: r.unique_id,
|
||||
name: r.name || null,
|
||||
facet: r.facet,
|
||||
region: r.region || null,
|
||||
landmark: r.landmark || null,
|
||||
maxCount: Number(r.max_count) || 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* One decoration type, or null.
|
||||
*
|
||||
* The events decoration verb resolves through this rather than passing a type
|
||||
* name straight through, which does two things at once: it fetches the item id
|
||||
* the graphic-holder classes need, and it keeps the verb to the vocabulary this
|
||||
* shard's own decoration files use. A type the atlas has never seen is refused
|
||||
* here rather than constructed there.
|
||||
*/
|
||||
async function getDecorType(type) {
|
||||
const row = await db.getDecorType(String(type == null ? '' : type).trim())
|
||||
if (!row) return null
|
||||
return { type: row.type, itemId: Number(row.item_id) || 0, uses: Number(row.uses) || 0 }
|
||||
}
|
||||
|
||||
async function listRegions(opts = {}) {
|
||||
const rows = await db.listRegions(opts)
|
||||
return rows.map((r) => ({
|
||||
@@ -485,6 +666,9 @@ module.exports = {
|
||||
getCreature,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listDecorTypes,
|
||||
listSpawners,
|
||||
getDecorType,
|
||||
listChampions,
|
||||
listFacets,
|
||||
publicMeta,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const db = require('./shardClilocs.db')
|
||||
const { settings } = require('../../core')
|
||||
const { displayText } = require('../../utils/clilocParse')
|
||||
const { displayText, parseCliloc } = require('../../utils/clilocParse')
|
||||
const {
|
||||
ClilocFormatError,
|
||||
ClilocSourceError,
|
||||
@@ -8,8 +8,12 @@ const {
|
||||
hashSources,
|
||||
sameSources,
|
||||
missingSources,
|
||||
missingOverlays,
|
||||
readOverlays,
|
||||
readCliloc,
|
||||
} = require('../../utils/clilocSource')
|
||||
const bridge = require('../../utils/clilocBridge')
|
||||
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
|
||||
const log = require('../../core').logger('shardClilocs')
|
||||
|
||||
// The cliloc table — UO's id → display-string map, refreshed from a file the
|
||||
@@ -29,11 +33,35 @@ const log = require('../../core').logger('shardClilocs')
|
||||
// 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.
|
||||
// The table is built from a SET of sources — a base 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. Later sources win,
|
||||
// so an overlay both adds ids the client never had and overrides stock ones.
|
||||
//
|
||||
// ── Where the base comes from (protocol 8, docs/link/v8.md §9) ─────────
|
||||
//
|
||||
// **The shard**, on any install with uo-link configured. It has the operator's
|
||||
// client files already — a ServUO server cannot boot without them — and since
|
||||
// phase 2 it has the decompressor too, so `GET /cliloc` returns the table and
|
||||
// nobody installs UOFiddler or copies a 5 MB file anywhere.
|
||||
//
|
||||
// **A file on disk** otherwise. That is the pipeline this replaces, kept for
|
||||
// installs with no shard link and for development, and deprecated rather than
|
||||
// removed: an operator who has one keeps working, and an operator who has a shard
|
||||
// never builds one. Passing an explicit `path` to `refresh()` still selects it,
|
||||
// which is the escape hatch for "import from this file, this once".
|
||||
//
|
||||
// **Overlays are always the filesystem's**, either way. There is nothing on the
|
||||
// shard to ask for: ServUO has no server-side notion of a custom cliloc, so the
|
||||
// `custom/` directory is the only place those ids exist.
|
||||
//
|
||||
// ── What that changed about WHEN this runs ───────────────────────
|
||||
//
|
||||
// Boot no longer imports on the shard path. The file path could hash 5 MB locally
|
||||
// on every restart and skip; the shard path would mean a sidecar round trip in the
|
||||
// boot sequence, for a table that changes when an operator patches their client —
|
||||
// an event they know about and we do not. So on the bridge, importing is an admin
|
||||
// action (Admin → Shard), and boot leaves whatever is loaded serving.
|
||||
//
|
||||
// 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
|
||||
@@ -96,8 +124,224 @@ const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||
*
|
||||
* `force` skips the hash check (an admin asking for a reimport). `approve`
|
||||
* additionally accepts a vanished source.
|
||||
*
|
||||
* Which SOURCE it reads is decided here and nowhere else: the shard when uo-link
|
||||
* is configured and enabled, a file otherwise, and always a file when the caller
|
||||
* named one.
|
||||
*/
|
||||
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||
const override = String(pathOverride ?? '').trim()
|
||||
|
||||
if (override === '' && (await shardLinked())) {
|
||||
return refreshFromShard({ force, approve })
|
||||
}
|
||||
|
||||
return refreshFromFile({ force, approve, path: override })
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there a shard to ask?
|
||||
*
|
||||
* Both halves matter. `baseUrl` alone is an install that has been configured and
|
||||
* then switched off, and calling it would spend a 12 s timeout to learn what the
|
||||
* row already says. Never throws: an unreadable config means "no shard", and the
|
||||
* file path is a working answer.
|
||||
*/
|
||||
async function shardLinked() {
|
||||
try {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
return Boolean(config?.enabled && config?.baseUrl)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge parsed sources in order, later winning.
|
||||
*
|
||||
* Shared by both paths, because the merge is the same question whichever end the
|
||||
* base arrived from: what did each source contribute, and what did it override.
|
||||
* The per-source breakdown is for the admin panel — 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.
|
||||
*/
|
||||
function mergeSources(groups) {
|
||||
const merged = new Map()
|
||||
const sources = []
|
||||
|
||||
for (const group of groups) {
|
||||
let added = 0
|
||||
let overrode = 0
|
||||
|
||||
for (const entry of group.entries) {
|
||||
if (!Number.isInteger(entry.number)) continue
|
||||
if (merged.has(entry.number)) overrode++
|
||||
else added++
|
||||
merged.set(entry.number, entry)
|
||||
}
|
||||
|
||||
sources.push({
|
||||
label: group.label,
|
||||
kind: group.kind,
|
||||
entries: group.entries.length,
|
||||
added,
|
||||
overrode,
|
||||
})
|
||||
}
|
||||
|
||||
return { entries: [...merged.values()], sources }
|
||||
}
|
||||
|
||||
/** Overlay hashes as a `{ label: sha256 }` map, in merge order. */
|
||||
function overlayHashes(files) {
|
||||
const hashes = {}
|
||||
for (const file of files) hashes[file.label] = file.sha256
|
||||
return hashes
|
||||
}
|
||||
|
||||
/** The overlay half of a stored fingerprint — everything under `custom/`. */
|
||||
function onlyOverlays(hashes) {
|
||||
if (!hashes) return null
|
||||
const out = {}
|
||||
for (const [label, sha] of Object.entries(hashes)) {
|
||||
if (label.startsWith('custom/')) out[label] = sha
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Import with the shard as the base source.
|
||||
*
|
||||
* The gate is two-part, and neither part is something the shard can answer for
|
||||
* us: has the client file changed (size/mtime/sha256, plus the shard's own
|
||||
* `EXTRACTOR_VERSION`), and has any overlay beside the configured path changed.
|
||||
* Either is drift; neither is the normal case.
|
||||
*/
|
||||
async function refreshFromShard({ force = false, approve = false } = {}) {
|
||||
let fingerprint
|
||||
|
||||
try {
|
||||
fingerprint = await bridge.fingerprint()
|
||||
} catch (err) {
|
||||
if (err instanceof bridge.ClilocBridgeError) {
|
||||
return { status: 'unavailable', source: 'bridge', reason: err.message, code: err.code }
|
||||
}
|
||||
return { status: 'failed', source: 'bridge', reason: err.message }
|
||||
}
|
||||
|
||||
const configured = await getClientPath()
|
||||
const overlays = readOverlays(configured)
|
||||
const hashes = overlayHashes(overlays.files)
|
||||
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
|
||||
if (
|
||||
!force &&
|
||||
bridge.sameSource(fingerprint, meta?.base) &&
|
||||
sameSources(hashes, onlyOverlays(meta?.hashes)) &&
|
||||
currentParser(meta)
|
||||
) {
|
||||
return {
|
||||
status: 'unchanged',
|
||||
source: 'bridge',
|
||||
file: fingerprint.file,
|
||||
count: meta.count ?? null,
|
||||
customCount: overlays.files.length,
|
||||
hashing: fingerprint.hashing,
|
||||
}
|
||||
}
|
||||
|
||||
// An overlay that was loaded last time and is not there now is refused rather
|
||||
// than applied — an unmounted volume and a deliberate deletion look identical
|
||||
// from here, and the wrong guess silently drops every name that file gave.
|
||||
// The BASE is deliberately not part of this question: an install upgraded from
|
||||
// the file pipeline is *supposed* to stop having one.
|
||||
const gone = missingOverlays(hashes, meta?.hashes)
|
||||
|
||||
if (gone.length > 0 && !approve) {
|
||||
return {
|
||||
status: 'needsReview',
|
||||
source: 'bridge',
|
||||
reason: `${gone.length} previously-loaded cliloc overlay(s) are missing; the existing table is unchanged`,
|
||||
missingSources: gone,
|
||||
file: fingerprint.file,
|
||||
}
|
||||
}
|
||||
|
||||
let base
|
||||
|
||||
try {
|
||||
base = await bridge.readCliloc({ lang: bridge.DEFAULT_LANGUAGE })
|
||||
} catch (err) {
|
||||
if (err instanceof bridge.ClilocBridgeError) {
|
||||
return { status: 'unavailable', source: 'bridge', reason: err.message, code: err.code }
|
||||
}
|
||||
return { status: 'failed', source: 'bridge', reason: err.message }
|
||||
}
|
||||
|
||||
const groups = [{ label: base.source.file, kind: 'shard', entries: base.entries }]
|
||||
|
||||
for (const file of overlays.files) {
|
||||
try {
|
||||
groups.push({ label: file.label, kind: 'custom', entries: parseCliloc(file.buffer) })
|
||||
} catch (err) {
|
||||
if (err instanceof ClilocFormatError) {
|
||||
// Named, because "which of my six overlay files is malformed" is
|
||||
// otherwise a guessing game.
|
||||
return {
|
||||
status: 'unavailable',
|
||||
source: 'bridge',
|
||||
reason: `${file.label}: ${err.message}`,
|
||||
code: err.code,
|
||||
}
|
||||
}
|
||||
return { status: 'failed', source: 'bridge', reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeSources(groups)
|
||||
|
||||
try {
|
||||
const applied = await db.replaceAll(merged.entries, {
|
||||
source: 'bridge',
|
||||
base: fingerprint,
|
||||
hashes,
|
||||
parserVersion: PARSER_VERSION,
|
||||
sources: merged.sources,
|
||||
file: base.source.file,
|
||||
bytes: fingerprint.size,
|
||||
})
|
||||
|
||||
invalidate()
|
||||
|
||||
return {
|
||||
status: 'imported',
|
||||
source: 'bridge',
|
||||
file: base.source.file,
|
||||
count: applied.count,
|
||||
parsed: merged.entries.length,
|
||||
blank: applied.blank,
|
||||
pages: base.source.pages,
|
||||
sources: merged.sources,
|
||||
// The shard says how many rows it holds; this is how many arrived. They
|
||||
// agree, or the walk is wrong in a way no count on its own would show.
|
||||
reported: base.source.reported,
|
||||
received: base.source.received,
|
||||
overlayProblem: overlays.problem ?? undefined,
|
||||
acceptedMissing: gone.length > 0 ? gone : undefined,
|
||||
}
|
||||
} catch (err) {
|
||||
return { status: 'failed', source: 'bridge', reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import from a converted file on disk — the pre-protocol-8 pipeline, unchanged.
|
||||
*
|
||||
* Deprecated but supported: an install with no shard link has no other way to get
|
||||
* a table, and development without a running ServUO is the same case.
|
||||
*/
|
||||
async function refreshFromFile({ 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()
|
||||
@@ -153,7 +397,10 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
}
|
||||
|
||||
try {
|
||||
const applied = await db.replaceAll(parsed.entries, parsed.source)
|
||||
// `source: 'file'` is what lets the NEXT refresh — and `status()` — tell a
|
||||
// table built from a converted file from one built over the bridge. Without
|
||||
// it an install that gains a shard link looks like it already imported.
|
||||
const applied = await db.replaceAll(parsed.entries, { ...parsed.source, source: 'file' })
|
||||
invalidate()
|
||||
return {
|
||||
status: 'imported',
|
||||
@@ -177,9 +424,22 @@ async function refresh({ force = false, approve = false, path: pathOverride = ''
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* **On the bridge it imports nothing**, deliberately. The file path can hash a
|
||||
* local 5 MB file on every restart and skip in 14 ms; asking the shard would put
|
||||
* a sidecar round trip in the boot sequence to answer a question whose answer is
|
||||
* "no" every time except after a client patch — which is an operator action, and
|
||||
* therefore something an operator can press a button for. Whatever table is
|
||||
* loaded keeps serving, which is exactly what happens today when a restart finds
|
||||
* nothing changed.
|
||||
*/
|
||||
async function refreshOnBoot() {
|
||||
try {
|
||||
if (await shardLinked()) {
|
||||
log.info('cliloc table comes from the shard; import is admin-triggered (Admin → Shard)')
|
||||
return { status: 'skipped', source: 'bridge', reason: 'the shard is the cliloc source' }
|
||||
}
|
||||
|
||||
const result = await refresh()
|
||||
switch (result.status) {
|
||||
case 'imported':
|
||||
@@ -220,8 +480,18 @@ async function refreshOnBoot() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything the admin panel needs to describe cliloc state. */
|
||||
/**
|
||||
* Everything the admin panel needs to describe cliloc state.
|
||||
*
|
||||
* Two shapes, one per source, sharing every field a panel actually renders
|
||||
* (`count`, `drift`, `problem`, `sources`, `missingSources`, `importedAt`). What
|
||||
* differs is what `file` means and what a problem with it looks like: on the
|
||||
* bridge it is the shard's own client file and the problems are transport ones,
|
||||
* on disk it is a path an operator typed.
|
||||
*/
|
||||
async function status({ path: pathOverride = '' } = {}) {
|
||||
if (pathOverride.trim() === '' && (await shardLinked())) return shardStatus()
|
||||
|
||||
const configured = pathOverride.trim() !== '' ? pathOverride.trim() : await getClientPath()
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const loaded = await db.count().catch(() => 0)
|
||||
@@ -280,6 +550,72 @@ async function status({ path: pathOverride = '' } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Status when the shard is the source.
|
||||
*
|
||||
* The one thing worth knowing here that the file path has no equivalent of:
|
||||
* `hashing`. The shard reports a null `sha256` for a client file it has not
|
||||
* hashed yet — hashing the 343 MB of art and animation it also serves cannot fit
|
||||
* in a 10 s reply, so it happens on its own thread — and a null hash means "ask
|
||||
* again", never "changed". Drift falls back to (size, mtime) meanwhile, which is
|
||||
* the same gate the shard itself applies, so an operator is never blocked from
|
||||
* importing by a hash that has not landed.
|
||||
*/
|
||||
async function shardStatus() {
|
||||
const configured = await getClientPath()
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const loaded = await db.count().catch(() => 0)
|
||||
|
||||
const overlays = readOverlays(configured)
|
||||
const hashes = overlayHashes(overlays.files)
|
||||
|
||||
let fingerprint = null
|
||||
let problem = overlays.problem ?? null
|
||||
let code = null
|
||||
|
||||
try {
|
||||
fingerprint = await bridge.fingerprint()
|
||||
} catch (err) {
|
||||
problem = err.message
|
||||
code = err.code ?? null
|
||||
}
|
||||
|
||||
const drift = fingerprint
|
||||
? !bridge.sameSource(fingerprint, meta?.base) ||
|
||||
!sameSources(hashes, onlyOverlays(meta?.hashes)) ||
|
||||
!currentParser(meta)
|
||||
: null
|
||||
|
||||
return {
|
||||
source: 'bridge',
|
||||
configured: true,
|
||||
// The overlay directory, which is all the path setting still selects on this
|
||||
// source. Reported so a panel can say where `custom/` is being read from.
|
||||
path: configured,
|
||||
file: fingerprint?.file ?? bridge.SOURCE_FILE,
|
||||
fileReadable: Boolean(fingerprint),
|
||||
problem,
|
||||
code,
|
||||
drift,
|
||||
count: loaded,
|
||||
shard: fingerprint
|
||||
? {
|
||||
size: fingerprint.size,
|
||||
mtime: fingerprint.mtime,
|
||||
sha256: fingerprint.sha256,
|
||||
extractorVersion: fingerprint.extractorVersion,
|
||||
hashing: fingerprint.hashing,
|
||||
complete: fingerprint.complete,
|
||||
}
|
||||
: null,
|
||||
sources: Object.keys(hashes),
|
||||
loadedSources: meta?.sources ?? null,
|
||||
missingSources: missingOverlays(hashes, meta?.hashes),
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
sourceBytes: meta?.base?.size ?? meta?.bytes ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lookup ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolution happens SERVER-SIDE, not in the browser. Two reasons: the table is
|
||||
@@ -359,6 +695,7 @@ module.exports = {
|
||||
SETTING_KEY,
|
||||
getClientPath,
|
||||
setClientPath,
|
||||
shardLinked,
|
||||
refresh,
|
||||
refreshOnBoot,
|
||||
status,
|
||||
|
||||
@@ -39,4 +39,56 @@ const remove = (account, userId) =>
|
||||
const removeByAccount = (account) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||
|
||||
// A bound on every "resolve a set of people" read below. It mirrors core's own
|
||||
// `MAX_AUDIENCE` (engagementRecipients.db.js) rather than importing it: a module
|
||||
// cannot reach into core's models, and the number this file has to respect is
|
||||
// "no more ids than core will accept" whatever core calls it.
|
||||
const MAX_AUDIENCE = 5000
|
||||
|
||||
// **Website user ids for a set of game accounts.** The bulk form of
|
||||
// `getByAccount`, and the one the engagement mapper needs: a guild event's
|
||||
// audience is its members, and turning a roster into a set of people is one join
|
||||
// rather than one query per member (Phase 11).
|
||||
//
|
||||
// DISTINCT because two characters on one guild roster can share an account, and
|
||||
// the caller wants people rather than characters.
|
||||
async function userIdsForAccounts(accounts) {
|
||||
const wanted = [...new Set((accounts || []).filter((a) => typeof a === 'string' && a))]
|
||||
if (!wanted.length) return []
|
||||
const capped = wanted.slice(0, MAX_AUDIENCE)
|
||||
const marks = capped.map(() => '?').join(', ')
|
||||
const rows = await query(
|
||||
`SELECT DISTINCT user_id FROM shard_account_links WHERE account IN (${marks})`,
|
||||
capped,
|
||||
)
|
||||
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
}
|
||||
|
||||
// **Every website user with a linked game account** — the `uo.linked.accounts`
|
||||
// audience (ENGAGEMENT.md §5.1a). The set an operator reaches for first, and the
|
||||
// one a `not` composes against ("everyone who has NOT linked").
|
||||
//
|
||||
// It returns ids and nothing else: §5.1a rule 2 is that a module's resolver
|
||||
// never sees an address, a channel or a template, and core maps ids to addresses
|
||||
// on its own side after preferences, suppression and the verification gate.
|
||||
async function allLinkedUserIds(limit = MAX_AUDIENCE) {
|
||||
const rows = await query(
|
||||
'SELECT DISTINCT user_id FROM shard_account_links ORDER BY user_id LIMIT ?',
|
||||
[limit],
|
||||
)
|
||||
return rows.map((r) => Number(r.user_id)).filter((n) => Number.isInteger(n) && n > 0)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upsert,
|
||||
getByAccount,
|
||||
listByUser,
|
||||
isOwnedBy,
|
||||
remove,
|
||||
removeByAccount,
|
||||
userIdsForAccounts,
|
||||
allLinkedUserIds,
|
||||
MAX_AUDIENCE,
|
||||
}
|
||||
|
||||
|
||||
@@ -34,4 +34,20 @@ 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 }
|
||||
// The bulk resolvers the engagement audiences and the guild mapper need
|
||||
// (Phase 11). Thin pass-throughs, like `ownsAccount` above: there is no logic to
|
||||
// put here, and a module's audience resolver returning ids and nothing else is
|
||||
// the contract (§5.1a rule 2).
|
||||
const userIdsForAccounts = (accounts) => db.userIdsForAccounts(accounts)
|
||||
const allLinkedUserIds = (limit) => db.allLinkedUserIds(limit)
|
||||
|
||||
module.exports = {
|
||||
link,
|
||||
listForUser,
|
||||
ownsAccount,
|
||||
getByAccount,
|
||||
unlink,
|
||||
removeByAccount,
|
||||
userIdsForAccounts,
|
||||
allLinkedUserIds,
|
||||
}
|
||||
|
||||
@@ -41,14 +41,25 @@ async function replaceVendor(vendor, items) {
|
||||
|
||||
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
(serial, shop_name, owner_serial, owner_name, owner_acct, map, x, y, z, region, house,
|
||||
item_count, item_total, truncated, t,
|
||||
fees_exempt, charge_per_period, funds, pay_interval_sec, next_pay_at,
|
||||
periods_remaining, dismissal_at)
|
||||
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),
|
||||
owner_name = VALUES(owner_name), owner_acct = VALUES(owner_acct),
|
||||
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),
|
||||
-- Protocol 5. Written back unconditionally, INCLUDING when they are null:
|
||||
-- a shard downgraded to a pre-v5 overlay stops sending the fees object, and
|
||||
-- leaving the last v5 values in place would leave a dismissal date standing
|
||||
-- that nothing is maintaining any more. A stale deadline is worse than none.
|
||||
fees_exempt = VALUES(fees_exempt), charge_per_period = VALUES(charge_per_period),
|
||||
funds = VALUES(funds), pay_interval_sec = VALUES(pay_interval_sec),
|
||||
next_pay_at = VALUES(next_pay_at), periods_remaining = VALUES(periods_remaining),
|
||||
dismissal_at = VALUES(dismissal_at),
|
||||
-- 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
|
||||
@@ -60,6 +71,7 @@ async function replaceVendor(vendor, items) {
|
||||
vendor.shopName ?? null,
|
||||
vendor.ownerSerial ?? null,
|
||||
vendor.ownerName ?? null,
|
||||
vendor.ownerAcct ?? null,
|
||||
vendor.map ?? null,
|
||||
Number.isFinite(vendor.x) ? vendor.x : null,
|
||||
Number.isFinite(vendor.y) ? vendor.y : null,
|
||||
@@ -70,6 +82,13 @@ async function replaceVendor(vendor, items) {
|
||||
Number.isFinite(vendor.itemTotal) ? vendor.itemTotal : items.length,
|
||||
vendor.truncated ? 1 : 0,
|
||||
Number.isFinite(vendor.t) ? vendor.t : null,
|
||||
vendor.feesExempt ? 1 : 0,
|
||||
Number.isFinite(vendor.chargePerPeriod) ? vendor.chargePerPeriod : null,
|
||||
Number.isFinite(vendor.funds) ? vendor.funds : null,
|
||||
Number.isFinite(vendor.payIntervalSec) ? vendor.payIntervalSec : null,
|
||||
vendor.nextPayAt ?? null,
|
||||
Number.isFinite(vendor.periodsRemaining) ? vendor.periodsRemaining : null,
|
||||
vendor.dismissalAt ?? null,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
const db = require('./shardMarket.db')
|
||||
const clilocs = require('../shardClilocs/shardClilocs.model')
|
||||
const itemArt = require('../shardAssets/shardItemArt.model')
|
||||
const log = require('../../core').logger('shard-market')
|
||||
|
||||
// Defense in depth on top of the shard's own MarketMaxListings cap. The shard is
|
||||
@@ -31,6 +32,7 @@ const MAX_OWNER = 64
|
||||
const MAX_MAP = 40
|
||||
const MAX_REGION = 80
|
||||
const MAX_SERIAL = 20
|
||||
const MAX_ACCT = 120
|
||||
|
||||
const clip = (value, max) => {
|
||||
if (value == null) return null
|
||||
@@ -43,6 +45,42 @@ const int = (value, fallback = 0) => {
|
||||
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||
}
|
||||
|
||||
// A wire timestamp -> a Date the DB layer can bind, or null. The shard emits ISO-8601
|
||||
// (`DateTime.ToString("o")`); anything else is a plugin we do not recognise and is
|
||||
// dropped rather than stored as an Invalid Date, which MariaDB rejects in strict mode
|
||||
// and which would fail the whole vendor over one bad field.
|
||||
const when = (value) => {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// Protocol 5. The vendor's fee state, normalised out of the frame's `fees` object.
|
||||
//
|
||||
// Two things this deliberately does NOT do. It does not recompute `dismissalAt` from
|
||||
// the parts -- the shard resolved it against ServUO's own two vendor systems (the
|
||||
// charge, the funds and the interval all differ between them) and re-deriving it here
|
||||
// would be a second implementation of a rule that lives in PlayerVendor.PayTimer. And
|
||||
// it does not treat a missing `fees` object as zero: a pre-v5 overlay simply omits it,
|
||||
// and nulls are how a v5 website says "this shard has not told me" rather than
|
||||
// "this vendor is broke", which is the difference between silence and a false alarm.
|
||||
const fees = (f) => {
|
||||
if (!f || typeof f !== 'object') return { feesExempt: false, chargePerPeriod: null, funds: null, payIntervalSec: null, nextPayAt: null, periodsRemaining: null, dismissalAt: null }
|
||||
// A commission vendor has no pay timer and is never dismissed for fees. Reporting it
|
||||
// as exempt with no schedule is not the same as reporting a very long one, and a
|
||||
// surface that renders "never" must be able to tell them apart.
|
||||
if (f.exempt === true) return { feesExempt: true, chargePerPeriod: null, funds: null, payIntervalSec: null, nextPayAt: null, periodsRemaining: null, dismissalAt: null }
|
||||
return {
|
||||
feesExempt: false,
|
||||
chargePerPeriod: Number.isFinite(f.chargePerPeriod) ? Math.trunc(f.chargePerPeriod) : null,
|
||||
funds: Number.isFinite(f.funds) ? Math.trunc(f.funds) : null,
|
||||
payIntervalSec: Number.isFinite(f.payIntervalSec) ? Math.trunc(f.payIntervalSec) : null,
|
||||
nextPayAt: when(f.nextPayAt),
|
||||
periodsRemaining: Number.isFinite(f.periodsRemaining) ? Math.trunc(f.periodsRemaining) : null,
|
||||
dismissalAt: when(f.dismissalAt),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ingest ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -64,6 +102,10 @@ function flattenFrame(ev) {
|
||||
shopName: clip(ev.shopName, MAX_SHOP),
|
||||
ownerSerial: clip(ev.ownerSerial, MAX_SERIAL),
|
||||
ownerName: clip(ev.ownerName, MAX_OWNER),
|
||||
// Protocol 5. The character name has been here since v3, but only the game
|
||||
// ACCOUNT joins to shard_account_links -- so this is the field that makes a
|
||||
// vendor row resolvable to a person at all.
|
||||
ownerAcct: clip(ev.ownerAcct, MAX_ACCT),
|
||||
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,
|
||||
@@ -76,6 +118,7 @@ function flattenFrame(ev) {
|
||||
itemTotal: int(ev.total, int(ev.count, 0)),
|
||||
truncated: ev.truncated === true,
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
...fees(ev.fees),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +174,13 @@ async function upsertVendor(ev) {
|
||||
const vendor = flattenFrame(ev)
|
||||
const items = await shapeItems(ev)
|
||||
await db.replaceVendor(vendor, items)
|
||||
|
||||
// The listings name (itemId, hue) pairs, which are §5 asset keys (phase 5).
|
||||
// Noticing them here is what makes the warm pass find a newly listed item's
|
||||
// picture before anyone looks at the shop, rather than one page view later.
|
||||
// A hint, never a queue — the pass derives its real set from this table, so a
|
||||
// hint lost to a restart costs nothing.
|
||||
itemArt.notice(items)
|
||||
}
|
||||
|
||||
/** Ingest one `vendor.listing.remove` frame. */
|
||||
@@ -231,8 +281,14 @@ async function search({
|
||||
|
||||
const info = await db.meta()
|
||||
|
||||
// Each listing gets `art`: the filename of the item's picture under
|
||||
// uploads/items/, or null where this site does not hold one (phase 5). One
|
||||
// query for the page, off the listing shape rather than the SQL, so the search
|
||||
// itself stays the search and a picture lookup that fails costs a picture.
|
||||
const listings = await itemArt.decorate(rows.map(shapeListing))
|
||||
|
||||
return {
|
||||
listings: rows.map(shapeListing),
|
||||
listings,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
@@ -250,7 +306,7 @@ 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) }
|
||||
return { ...shapeVendor(row), items: await itemArt.decorate(items.map(shapeItem)) }
|
||||
}
|
||||
|
||||
/** Index size, staleness, and the facet/region filter options. */
|
||||
|
||||
@@ -98,7 +98,12 @@ async function latestEconomy() {
|
||||
|
||||
// ── 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'
|
||||
'serial, stage, map, x, y, z, region, name, owner_serial, owner_acct, built_on, last_refreshed, is_idoc, updated_at' +
|
||||
// Protocol 5's decay schedule. Added to the BASE column list rather than to
|
||||
// HOUSE_REG_COLS because it arrives on house.decay, so a decay-only row -- one the
|
||||
// registry sweep has never seen -- carries it too, and the public IDOC page reads
|
||||
// exactly those rows.
|
||||
', next_stage, estimated_collapse, decay_period_sec, dynamic_decay'
|
||||
|
||||
const upsertHouse = (serial, fields) => upsertRow('shard_houses', 'serial', serial, fields)
|
||||
|
||||
@@ -171,6 +176,70 @@ 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`)
|
||||
|
||||
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
|
||||
// `rank` is backticked wherever it is written, like `int` on shard_online: it is a
|
||||
// reserved word in MySQL 8 and merely a keyword in MariaDB, so it parses bare here
|
||||
// and must not be relied on to.
|
||||
const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, `rank`, rank_cliloc, rank_name, t'
|
||||
|
||||
// Upsert rather than plain insert: a roster frame can be redelivered (the /history
|
||||
// backfill replays stored frames on every reconnect), and a redelivery must be a
|
||||
// no-op rather than a duplicate-key error.
|
||||
//
|
||||
// The rank columns are assigned unconditionally, NULL included. A member whose rank
|
||||
// the shard withheld — a staff account, whose GuildRank getter reports Leader
|
||||
// regardless of the truth — must go back to "not known" rather than keeping a rank
|
||||
// from before they were promoted.
|
||||
const upsertGuildMembers = (rows) => {
|
||||
if (!rows.length) return Promise.resolve()
|
||||
const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')
|
||||
const params = rows.flatMap((r) => [
|
||||
r.guild_id, r.serial, r.name, r.acct, r.web_id, r.is_player,
|
||||
r.rank, r.rank_cliloc, r.rank_name, r.t,
|
||||
])
|
||||
return query(
|
||||
`INSERT INTO shard_guild_members (${MEMBER_COLS}) VALUES ${values}
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), acct = VALUES(acct),
|
||||
web_id = VALUES(web_id), is_player = VALUES(is_player),
|
||||
\`rank\` = VALUES(\`rank\`), rank_cliloc = VALUES(rank_cliloc),
|
||||
rank_name = VALUES(rank_name), t = VALUES(t)`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
const clearGuildMembers = (guildId) =>
|
||||
query('DELETE FROM shard_guild_members WHERE guild_id = ?', [guildId])
|
||||
|
||||
const removeGuildMember = (guildId, serial) =>
|
||||
query('DELETE FROM shard_guild_members WHERE guild_id = ? AND serial = ?', [guildId, serial])
|
||||
|
||||
const clearAllGuildMembers = () => query('DELETE FROM shard_guild_members')
|
||||
|
||||
const listGuildMembers = (guildId) =>
|
||||
query(`SELECT ${MEMBER_COLS} FROM shard_guild_members WHERE guild_id = ? ORDER BY name ASC`, [
|
||||
guildId,
|
||||
])
|
||||
|
||||
|
||||
// **The game accounts on one guild's roster** — the input to
|
||||
// `shardLinks.userIdsForAccounts`, and therefore to the `members` audience a
|
||||
// guild event carries (Phase 11). Accounts rather than `web_id`, deliberately:
|
||||
// `web_id` is a value MIRRORED off the wire actor, and `shard_account_links` is
|
||||
// the authoritative map. A mirror that has drifted would mail the wrong person,
|
||||
// and a mirror that is behind would mail nobody, so the query that decides who
|
||||
// is told reads the table whose job that is.
|
||||
const listGuildMemberAccounts = (guildId) =>
|
||||
query(
|
||||
'SELECT DISTINCT acct FROM shard_guild_members WHERE guild_id = ? AND acct IS NOT NULL',
|
||||
[guildId],
|
||||
)
|
||||
|
||||
// The accounts of every sitting governor — the `uo.governors` audience.
|
||||
// `governor_acct` is NULL on a city with no governor and on one whose governor's
|
||||
// mobile has no account, and both are simply nobody.
|
||||
const listGovernorAccounts = () =>
|
||||
query('SELECT DISTINCT governor_acct FROM shard_governors WHERE governor_acct IS NOT NULL')
|
||||
|
||||
// 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.
|
||||
@@ -342,6 +411,13 @@ module.exports = {
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
upsertGuildMembers,
|
||||
clearGuildMembers,
|
||||
removeGuildMember,
|
||||
clearAllGuildMembers,
|
||||
listGuildMembers,
|
||||
listGuildMemberAccounts,
|
||||
listGovernorAccounts,
|
||||
findGuildLedByActor,
|
||||
listGuildsLedByAccounts,
|
||||
upsertGovernor,
|
||||
|
||||
@@ -124,10 +124,39 @@ async function upsertHouse(data) {
|
||||
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,
|
||||
// Protocol 5. `ownerName` is written back only when the frame carries one, and
|
||||
// that asymmetry is deliberate: house.update also writes this column, from a
|
||||
// different sweep, and a pre-v5 overlay's house.decay frame has no ownerName at
|
||||
// all. Coalescing to null here would let every decay transition ERASE a name the
|
||||
// registry had already resolved.
|
||||
...(data.ownerName ? { owner_name: String(data.ownerName).slice(0, 120) } : {}),
|
||||
...decayScheduleFields(data.schedule),
|
||||
}
|
||||
await db.upsertHouse(data.serial, fields)
|
||||
}
|
||||
|
||||
// Protocol 5's `schedule` object, flattened into its columns.
|
||||
//
|
||||
// Unlike ownerName above, these are written back UNCONDITIONALLY, including as nulls.
|
||||
// A schedule is a claim about the future and it goes stale on its own: if a shard is
|
||||
// rolled back to a pre-v5 overlay, or a house leaves IDOC so its collapse time stops
|
||||
// being knowable, the right stored value is "nothing" rather than the last thing we
|
||||
// were told. A dated promise nobody is maintaining is worse than no promise.
|
||||
function decayScheduleFields(schedule) {
|
||||
const s = schedule && typeof schedule === 'object' ? schedule : {}
|
||||
const when = (v) => {
|
||||
if (!v) return null
|
||||
const d = new Date(v)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
return {
|
||||
next_stage: when(s.nextStage),
|
||||
estimated_collapse: when(s.estimatedCollapse),
|
||||
decay_period_sec: Number.isFinite(s.decayPeriodSec) ? Math.trunc(s.decayPeriodSec) : null,
|
||||
dynamic_decay: typeof s.dynamicDecay === 'boolean' ? (s.dynamicDecay ? 1 : 0) : null,
|
||||
}
|
||||
}
|
||||
|
||||
function shapeHouse(r) {
|
||||
return {
|
||||
serial: r.serial,
|
||||
@@ -149,6 +178,16 @@ function shapeHouse(r) {
|
||||
inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry),
|
||||
builtOn: r.built_on,
|
||||
lastRefreshed: r.last_refreshed,
|
||||
// Protocol 5. Re-nested on read for the reason shardMarket re-nests `location`:
|
||||
// the visibility projection matches literal JSON keys, so the stored read model
|
||||
// and the live wire frame have to spell this the same way or the one admin rule
|
||||
// covers only one of the two paths.
|
||||
schedule: {
|
||||
dynamicDecay: r.dynamic_decay == null ? null : Boolean(r.dynamic_decay),
|
||||
nextStage: r.next_stage,
|
||||
decayPeriodSec: r.decay_period_sec,
|
||||
estimatedCollapse: r.estimated_collapse,
|
||||
},
|
||||
isIdoc: Boolean(r.is_idoc),
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
@@ -340,8 +379,101 @@ async function upsertGuild(ev) {
|
||||
})
|
||||
}
|
||||
|
||||
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
|
||||
const clearGuilds = () => db.clearGuilds()
|
||||
const removeGuild = async (id) => {
|
||||
if (id == null) return
|
||||
await db.removeGuild(id)
|
||||
await db.clearGuildMembers(id)
|
||||
}
|
||||
const clearGuilds = async () => {
|
||||
await db.clearGuilds()
|
||||
await db.clearAllGuildMembers()
|
||||
}
|
||||
|
||||
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
|
||||
// Apply one guild.roster frame.
|
||||
//
|
||||
// A roster larger than the shard's per-frame cap arrives as several frames
|
||||
// carrying seq/more/total. The sidecar reassembles them for its OWN board, but the
|
||||
// live WebSocket feed and the /history backfill both carry the individual frames,
|
||||
// so this ingest sees them unreassembled and has to cope.
|
||||
//
|
||||
// It copes without buffering, because a table can express what a single JSON column
|
||||
// could not: the frame carrying seq 0 clears the guild first and every frame then
|
||||
// upserts its own rows. Rows are keyed on (guild_id, serial), so a redelivered frame
|
||||
// — the /history backfill replays stored frames on every reconnect — is idempotent
|
||||
// rather than a duplicate-key error.
|
||||
//
|
||||
// The cost is a brief window during a multi-frame update where the table holds part
|
||||
// of a roster. That is acceptable for a projection that is already only as fresh as
|
||||
// a 60s sweep, and the frames arrive back-to-back in one burst; buffering to close
|
||||
// it would duplicate the sidecar's reassembly for a sub-second inconsistency.
|
||||
async function upsertGuildRoster(ev) {
|
||||
if (!ev || ev.id == null) return
|
||||
|
||||
const seq = Number.isFinite(ev.seq) ? ev.seq : 0
|
||||
const members = Array.isArray(ev.members) ? ev.members : []
|
||||
|
||||
// seq 0 begins a roster and supersedes whatever was held for this guild.
|
||||
if (seq === 0) await db.clearGuildMembers(ev.id)
|
||||
|
||||
const rows = members
|
||||
.filter((m) => m && m.serial)
|
||||
.map((m) => ({
|
||||
guild_id: ev.id,
|
||||
serial: m.serial,
|
||||
name: m.name ?? null,
|
||||
acct: m.acct ?? null,
|
||||
web_id: Number.isFinite(m.webId) ? m.webId : null,
|
||||
is_player: m.player ? 1 : 0,
|
||||
// Guild rank (Protocol 4). ABSENT is a real state and is stored as NULL: the
|
||||
// shard withholds the rank for a staff account, because ServUO's GuildRank
|
||||
// getter reports Leader for anyone at GameMaster or above whatever their
|
||||
// actual rank. Defaulting a missing rank to 0 here would turn "we were not
|
||||
// told" into "rank 0", which is a demotion invented by this line.
|
||||
rank: Number.isInteger(m.rank) ? m.rank : null,
|
||||
rank_cliloc: Number.isInteger(m.rankCliloc) ? m.rankCliloc : null,
|
||||
rank_name: typeof m.rankName === 'string' && m.rankName ? m.rankName.slice(0, 64) : null,
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
}))
|
||||
|
||||
await db.upsertGuildMembers(rows)
|
||||
}
|
||||
|
||||
// A single departure (guild.leave). Advisory: the shard re-emits the full roster
|
||||
// whenever the member set changes, so the table would converge on the next frame
|
||||
// even if this were dropped. Applying it makes the change visible immediately
|
||||
// instead of at the end of the sweep that produced it.
|
||||
async function removeGuildMember(ev) {
|
||||
if (!ev || ev.id == null || !ev.who) return
|
||||
await db.removeGuildMember(ev.id, ev.who)
|
||||
}
|
||||
|
||||
// The membership roster for one guild, in the wire shape the projection expects
|
||||
// (an array of actor objects), so shardVisibility strips acct/webId by the same
|
||||
// rule it applies to guild.leader.
|
||||
async function listGuildMembers(guildId) {
|
||||
const rows = await db.listGuildMembers(guildId)
|
||||
return rows.map((r) => ({
|
||||
serial: r.serial,
|
||||
name: r.name,
|
||||
...(r.acct == null ? {} : { acct: r.acct }),
|
||||
...(r.web_id == null ? {} : { webId: r.web_id }),
|
||||
player: !!r.is_player,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
// **Just the accounts, for the engagement audiences** (Phase 11). Deliberately
|
||||
// NOT `listGuildMembers().map(m => m.acct)`: that shape exists to be projected
|
||||
// through `shardVisibility`, which strips `acct` for anyone below admin, so
|
||||
// building an audience out of it would either leak the projection's job into
|
||||
// this one or silently resolve to nobody depending on who asked. These two go to
|
||||
// the database for exactly the column they need and pass nothing else on.
|
||||
const listGuildMemberAccounts = async (guildId) =>
|
||||
(await db.listGuildMemberAccounts(guildId)).map((r) => r.acct).filter(Boolean)
|
||||
|
||||
const listGovernorAccounts = async () =>
|
||||
(await db.listGovernorAccounts()).map((r) => r.governor_acct).filter(Boolean)
|
||||
|
||||
function shapeGuild(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
@@ -620,6 +752,11 @@ module.exports = {
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
upsertGuildRoster,
|
||||
removeGuildMember,
|
||||
listGuildMembers,
|
||||
listGuildMemberAccounts,
|
||||
listGovernorAccounts,
|
||||
replaceGuilds,
|
||||
findGuildForActor,
|
||||
listGuildsLedForAccounts,
|
||||
|
||||
87
server/model/teamProvider/teamProvider.db.js
Normal file
87
server/model/teamProvider/teamProvider.db.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// SQL behind the Team provider — three questions core asks, answered from the
|
||||
// guild board and the roster Protocol 4 put there.
|
||||
//
|
||||
// Every statement reads only THIS module's tables. Core's Team tables are
|
||||
// core-internal (docs/website/TEAMS.md §10.3) and this module must never name
|
||||
// one, even though it is what fills them.
|
||||
|
||||
// `query` is destructured from the core facade at require time, like every other
|
||||
// *.db.js here. The facade resolves `ctx` per call, so taking it now is safe even
|
||||
// though `ctx` does not exist yet when this file is first required.
|
||||
const { query } = require('../../core')
|
||||
|
||||
/** ServUO's `RankDefinition.Ranks[4]` is Leader, and 4 is the top of the ladder. */
|
||||
const LEADER_RANK = 4
|
||||
|
||||
/**
|
||||
* The guild board — one row per guild the shard has told us about.
|
||||
*
|
||||
* `members`/`online` here are the COUNTS `guild.update` carries; the roster is a
|
||||
* separate table (Protocol 4). Both are read, because a count is what the shard
|
||||
* asserts and a roster is what it enumerated, and they can legitimately disagree
|
||||
* for the moment between a membership change and the sweep that reports it.
|
||||
*/
|
||||
const listGuilds = () =>
|
||||
query(
|
||||
`SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
|
||||
FROM shard_guilds ORDER BY name ASC`,
|
||||
)
|
||||
|
||||
const findGuild = (id) =>
|
||||
query(
|
||||
`SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
|
||||
FROM shard_guilds WHERE id = ? LIMIT 1`,
|
||||
[id],
|
||||
)
|
||||
|
||||
/**
|
||||
* One guild's roster, with the site link and live presence folded in.
|
||||
*
|
||||
* Two LEFT JOINs, both deliberate:
|
||||
*
|
||||
* - `shard_account_links` resolves `user_id` HERE rather than in core, because
|
||||
* this module owns that table and a core that read it would be core naming a
|
||||
* module's table by name (§2.3). It is also why a freshly linked account
|
||||
* appears as linked on the next reconcile rather than needing core to know
|
||||
* anything about linking.
|
||||
* - `shard_online` is how a member's `online` is answered at all. The roster
|
||||
* frame does not carry it — the wire's member is the standard actor object
|
||||
* (`serial`, `name`, `player`, `acct?`, `webId?`), and the board's `online` is
|
||||
* a count, not a set. Presence therefore comes from the online table, which
|
||||
* is the same source the public "who's online" surface already uses.
|
||||
*
|
||||
* `web_id` on the roster row is preferred over the link table when present: it is
|
||||
* what the shard itself asserted at roster time, and the join is the fallback for
|
||||
* a member whose row predates their link.
|
||||
*/
|
||||
const listGuildMembers = (guildId) =>
|
||||
query(
|
||||
"SELECT m.serial, m.name, m.acct, m.web_id, m.is_player, m.`rank`, m.rank_cliloc, m.rank_name, " +
|
||||
` l.user_id AS linked_user_id,
|
||||
(o.serial IS NOT NULL) AS is_online
|
||||
FROM shard_guild_members m
|
||||
LEFT JOIN shard_account_links l ON l.account = m.acct
|
||||
LEFT JOIN shard_online o ON o.serial = m.serial
|
||||
WHERE m.guild_id = ?
|
||||
ORDER BY m.name ASC`,
|
||||
[guildId],
|
||||
)
|
||||
|
||||
/**
|
||||
* Every member at leader rank — rank 4, the top of ServUO's `RankDefinition.Ranks`.
|
||||
*
|
||||
* A set, not a single row, and that is the whole reason Protocol 4 grew a per-member
|
||||
* rank: the guild board carries one `leader_serial`, so before this the website could
|
||||
* only ever be told about one leader, while a UO guild routinely has several.
|
||||
*
|
||||
* A NULL rank is excluded by the comparison, which is correct — the shard withholds
|
||||
* the rank for a staff account rather than publishing the Leader its getter falsely
|
||||
* reports, and "not known" must not be read as "leads this guild".
|
||||
*/
|
||||
const listGuildLeaders = (guildId) =>
|
||||
query(
|
||||
'SELECT serial FROM shard_guild_members WHERE guild_id = ? AND `rank` >= ? ORDER BY name ASC',
|
||||
[guildId, LEADER_RANK],
|
||||
)
|
||||
|
||||
module.exports = { listGuilds, findGuild, listGuildMembers, listGuildLeaders, LEADER_RANK }
|
||||
339
server/model/teamProvider/teamProvider.model.js
Normal file
339
server/model/teamProvider/teamProvider.model.js
Normal file
@@ -0,0 +1,339 @@
|
||||
// ── module-uo's Team provider ──────────────────────────────────────────────
|
||||
//
|
||||
// The three questions core asks this module about Teams
|
||||
// (docs/website/MODULE_API.md — `api.registerTeamProvider`, and TEAMS.md §2.3).
|
||||
// A UO guild is a Team; this file is the whole of the translation.
|
||||
//
|
||||
// **Every method returns an envelope, and answering `{ ok: false }` is a normal
|
||||
// outcome, not a failure to handle.** Core's contract is that module
|
||||
// unavailability becomes staleness and never emptiness, and the only way this
|
||||
// module can say "I cannot answer" is to say so — an empty array would be read as
|
||||
// an authoritative "there are none", which during a cold start is how every
|
||||
// roster on the site gets emptied. So the guard below is the most important code
|
||||
// in the file, and it is deliberately conservative: **an unreachable or
|
||||
// never-connected sidecar refuses, rather than reporting the board it happens to
|
||||
// still hold.**
|
||||
//
|
||||
// The board IS durable and would survive a sidecar outage, which is exactly what
|
||||
// makes this tempting to get wrong. The reason to refuse anyway: core cannot tell
|
||||
// a board that is five minutes stale from one that is five days stale, and it
|
||||
// makes destructive decisions — archiving Teams, departing members — from a
|
||||
// complete answer. Reporting a stale board as authoritative would license those.
|
||||
|
||||
const core = require('../../core')
|
||||
const db = require('./teamProvider.db')
|
||||
const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model')
|
||||
const uoLinkSocket = require('../../utils/uoLinkSocket')
|
||||
const clilocs = require('../shardClilocs/shardClilocs.model')
|
||||
const visibility = require('../../utils/shardVisibility')
|
||||
|
||||
const log = core.logger('teams')
|
||||
|
||||
/**
|
||||
* ServUO's five stock rank names, by the cliloc id the game names them with.
|
||||
*
|
||||
* A fallback, not the source of truth: the operator's own cliloc table is consulted
|
||||
* first, and a shard with custom rank definitions sends a literal string that beats
|
||||
* both. This exists because the cliloc table is populated only if someone ran the
|
||||
* client-file extraction, and a roster on a shard that has not should still say
|
||||
* "Warlord" rather than nothing.
|
||||
*/
|
||||
const STANDARD_RANK_NAMES = {
|
||||
1062959: 'Leader',
|
||||
1062960: 'Warlord',
|
||||
1062961: 'Emissary',
|
||||
1062962: 'Member',
|
||||
1062963: 'Ronin',
|
||||
}
|
||||
|
||||
/** A refusal, in the shape core reads (§2.3). */
|
||||
const refuse = (reason) => ({ ok: false, reason })
|
||||
|
||||
/**
|
||||
* Is the bridge in a state where the board can be trusted as current?
|
||||
*
|
||||
* The board is only as good as the socket that fills it. Three states refuse, and
|
||||
* they are asked in this order because each is a different thing being wrong:
|
||||
*
|
||||
* - **no uo-link configured** — there is no shard behind this website at all;
|
||||
* - **the integration is disabled** — an admin turned it off, and the board is
|
||||
* frozen at whatever it held;
|
||||
* - **the socket is not connected** — the board is a snapshot of unknown age.
|
||||
*
|
||||
* The in-process socket state is preferred over the persisted status column,
|
||||
* which is written on transitions: a process that has just started has not
|
||||
* transitioned yet, so the column can still say `connected` from the last run
|
||||
* while this process has never opened a socket.
|
||||
*/
|
||||
async function boardIsCurrent() {
|
||||
const config = await uoLinkConfig.getSafe()
|
||||
if (!config || !config.baseUrl) return { ok: false, reason: 'no uo-link configured' }
|
||||
if (!config.enabled) return { ok: false, reason: 'the uo-link integration is disabled' }
|
||||
|
||||
const state = uoLinkSocket.getState()
|
||||
if (!state || !state.connected) {
|
||||
return { ok: false, reason: 'the uo-link socket is not connected; the guild board may be stale' }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* `getTeams()` — every guild on the board.
|
||||
*
|
||||
* `externalId` is the ServUO `Guild.Id`, which survives a rename: renaming a
|
||||
* guild in-game keeps the id, so core sees "an id whose name changed" and applies
|
||||
* its rename rule (archive plus create). That mapping is this module's to make —
|
||||
* only the game knows what identity survives what (§10.5).
|
||||
*
|
||||
* `meta` carries the alliance, opaquely. Core stores and displays it and never
|
||||
* branches on it, which is what lets a UO concept reach a Team page without core
|
||||
* acquiring an opinion about alliances.
|
||||
*/
|
||||
async function getTeams() {
|
||||
const ready = await boardIsCurrent()
|
||||
if (!ready.ok) return refuse(ready.reason)
|
||||
|
||||
try {
|
||||
const rows = await db.listGuilds()
|
||||
return {
|
||||
ok: true,
|
||||
complete: true,
|
||||
teams: rows.map((row) => ({
|
||||
externalId: String(row.id),
|
||||
name: row.name,
|
||||
abbr: row.abbr || null,
|
||||
meta: row.alliance ? { alliance: row.alliance } : null,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('getTeams failed', { message: err.message })
|
||||
return refuse(`guild board unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `getTeamMembers(externalId)` — one guild's roster.
|
||||
*
|
||||
* **A guild with no roster rows is refused, not reported empty**, unless the board
|
||||
* itself says the guild has no members. Protocol 4's roster arrives on its own
|
||||
* frames, separately from the `guild.update` that creates the board row, so there
|
||||
* is a real window — a fresh guild, or a website that connected between the two —
|
||||
* where core would otherwise be told authoritatively that a 155-member guild has
|
||||
* nobody in it. The board's own `members` count is what distinguishes the two,
|
||||
* and it is the only thing that can.
|
||||
*/
|
||||
async function getTeamMembers(externalId) {
|
||||
const ready = await boardIsCurrent()
|
||||
if (!ready.ok) return refuse(ready.reason)
|
||||
|
||||
try {
|
||||
const [guild] = await db.findGuild(externalId)
|
||||
if (!guild) return refuse(`guild ${externalId} is not on the board`)
|
||||
|
||||
const rows = await db.listGuildMembers(externalId)
|
||||
if (!rows.length && guild.members > 0) {
|
||||
return refuse(`roster for guild ${externalId} has not arrived yet (board says ${guild.members} members)`)
|
||||
}
|
||||
|
||||
const labels = await rankLabels(rows)
|
||||
return {
|
||||
ok: true,
|
||||
complete: true,
|
||||
members: rows.map((row) => ({
|
||||
memberKey: row.serial,
|
||||
displayName: row.name || null,
|
||||
rankLabel: labels.get(row.serial) || null,
|
||||
// Rank 4 is Leader, and several members can hold it. A NULL rank is not a
|
||||
// leader: the shard withholds the rank for a staff account rather than
|
||||
// publishing the Leader its getter falsely reports, and "not known" must
|
||||
// never be read as "leads this guild".
|
||||
leader: Number.isInteger(row.rank) && row.rank >= db.LEADER_RANK,
|
||||
online: Boolean(row.is_online),
|
||||
userId: resolveUserId(row),
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('getTeamMembers failed', { externalId, message: err.message })
|
||||
return refuse(`roster unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `getTeamLeaders(externalId)` — everyone at leader rank.
|
||||
*
|
||||
* **All of them, which is why Protocol 4 grew a per-member rank.** The guild board
|
||||
* carries one `leader_serial`, so before the rank amendment this could only ever
|
||||
* name a single member, while a UO guild routinely has several at rank 4 and
|
||||
* TEAMS.md §2.5 treats multiple leaders as the normal case.
|
||||
*
|
||||
* The board's own `leader_serial` is folded in as a floor. It is the guild's
|
||||
* founder-leader and it comes from a different frame (`guild.update`), so on a
|
||||
* shard whose roster has not been re-emitted since the amendment it is the only
|
||||
* leadership signal there is — and it should never be *lost* by moving to ranks.
|
||||
*/
|
||||
async function getTeamLeaders(externalId) {
|
||||
const ready = await boardIsCurrent()
|
||||
if (!ready.ok) return refuse(ready.reason)
|
||||
|
||||
try {
|
||||
const [guild] = await db.findGuild(externalId)
|
||||
if (!guild) return refuse(`guild ${externalId} is not on the board`)
|
||||
|
||||
const rows = await db.listGuildLeaders(externalId)
|
||||
const leaders = rows.map((r) => r.serial)
|
||||
|
||||
if (guild.leader_serial && !leaders.includes(guild.leader_serial)) {
|
||||
leaders.push(guild.leader_serial)
|
||||
}
|
||||
return { ok: true, leaders }
|
||||
} catch (err) {
|
||||
log.warn('getTeamLeaders failed', { externalId, message: err.message })
|
||||
return refuse(`leadership unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each member's rank to a display label, keyed by serial.
|
||||
*
|
||||
* The shard sends the rank's NAME as the game states it — a cliloc id for the five
|
||||
* standard ranks, or a literal string for a custom rank definition — and never a
|
||||
* resolved label, because ServUO ships no text for those clilocs. This module does
|
||||
* have a cliloc table, which is why the resolution belongs here.
|
||||
*
|
||||
* Three sources, in order: a custom string wins, then the operator's cliloc table,
|
||||
* then the five standard names. The last exists because the cliloc table is
|
||||
* populated only if someone ran the client extraction, and a shard that has not
|
||||
* should still read "Warlord" rather than nothing.
|
||||
*
|
||||
* Never throws: a rank label is decoration on a roster, and a lookup failure must
|
||||
* not turn a good roster into a refusal.
|
||||
*/
|
||||
async function rankLabels(rows) {
|
||||
const out = new Map()
|
||||
const wanted = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.rank_name) {
|
||||
out.set(row.serial, row.rank_name)
|
||||
} else if (Number.isInteger(row.rank_cliloc)) {
|
||||
wanted.push(row.rank_cliloc)
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = new Map()
|
||||
if (wanted.length) {
|
||||
try {
|
||||
resolved = await clilocs.resolveMany(wanted)
|
||||
} catch (err) {
|
||||
log.warn('rank cliloc lookup failed; falling back to the standard names', { message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
if (out.has(row.serial) || !Number.isInteger(row.rank_cliloc)) continue
|
||||
const label = resolved.get(row.rank_cliloc) || STANDARD_RANK_NAMES[row.rank_cliloc] || null
|
||||
if (label) out.set(row.serial, label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The site account behind a character, or null.
|
||||
*
|
||||
* `web_id` is what the shard itself asserted when it emitted the roster; the
|
||||
* account-link join is the fallback for a member whose roster row predates their
|
||||
* link. Both are coerced through the same check, because `web_id` arrives from
|
||||
* the wire as a string.
|
||||
*/
|
||||
function resolveUserId(row) {
|
||||
const fromRoster = Number.parseInt(row.web_id, 10)
|
||||
if (Number.isInteger(fromRoster) && fromRoster > 0) return fromRoster
|
||||
const fromLink = Number.parseInt(row.linked_user_id, 10)
|
||||
return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Which roster rows a viewer may see (TEAMS.md §3.3, MODULE_API 1.6.0).
|
||||
*
|
||||
* The optional fourth provider method, and the only one core calls on a REQUEST
|
||||
* path rather than from the reconciler. Core holds the roster and its public
|
||||
* shape; the question that is this module's is "who is allowed to look", because
|
||||
* the audience rungs and their configuration live here (`utils/shardVisibility`)
|
||||
* and core does not know what a rung is.
|
||||
*
|
||||
* **The answer is all-or-nothing, and that is correct rather than a shortcut.**
|
||||
* A rung is a property of the FEATURE, not of a member: `guilds` is either
|
||||
* visible to this viewer or it is not, and there is no configuration in which
|
||||
* some members of a guild are public and others are not. Returning every key or
|
||||
* none is the honest translation of the model this module actually has.
|
||||
*
|
||||
* **A refusal here costs visibility, not staleness.** Core fails closed on this
|
||||
* one call — an unanswered visibility question serves an empty roster rather than
|
||||
* an unprojected one — so every path below that cannot reach a confident answer
|
||||
* refuses deliberately, and the catch does too. That is the opposite of the rule
|
||||
* governing the other three methods, and it is the right way round: for a roster
|
||||
* SYNC an unanswered call must change nothing, and for a roster READ it must
|
||||
* publish nothing.
|
||||
*
|
||||
* Note what this does NOT do: strip fields. `acct` and `webId` are the leak this
|
||||
* module's projection exists to prevent on the live feed, and neither is in
|
||||
* core's roster shape at all — core withholds the member key and the site account
|
||||
* id from every public roster whatever this returns. So there is nothing here to
|
||||
* redact, only rows to withhold.
|
||||
*/
|
||||
async function projectRoster(externalId, members, viewer) {
|
||||
try {
|
||||
const config = await visibility.getConfig()
|
||||
const feature = config.guilds
|
||||
// An admin turned guilds off. Nobody sees a roster, including staff — the
|
||||
// switch means "this shard does not publish guild data", not "publish it
|
||||
// quietly".
|
||||
if (!feature || !feature.enabled) return { ok: true, members: [] }
|
||||
|
||||
// `viewerLevel` reads a REQUEST; core hands over a described viewer instead,
|
||||
// which is deliberate — it keeps the `users` row out of the contract.
|
||||
//
|
||||
// The no-viewer case is answered here rather than by handing `viewerLevel` an
|
||||
// empty object: given a request with no `req.user` it falls through to
|
||||
// `auth.getUserFromRequest`, which expects real cookies and headers and
|
||||
// throws on a synthetic one. That throw would land in the catch below and
|
||||
// become a REFUSAL, so every anonymous visitor would have been served an
|
||||
// empty roster on a shard whose guilds are public. Anonymous is a known
|
||||
// answer, not a failed lookup.
|
||||
const level = viewer
|
||||
? await visibility.viewerLevel({ user: { id: viewer.userId, role: viewer.role } })
|
||||
: 'anonymous'
|
||||
if (!visibility.meets(level, feature.audience)) return { ok: true, members: [] }
|
||||
|
||||
return { ok: true, members: members.map((m) => m.member_key).filter(Boolean) }
|
||||
} catch (err) {
|
||||
// Core reads this as "withhold the roster". Saying so is the whole point: the
|
||||
// alternative — answering with every key because the config read failed —
|
||||
// publishes a roster an operator may have gated to staff.
|
||||
log.warn('projectRoster could not resolve visibility; withholding the roster', {
|
||||
externalId, message: err.message,
|
||||
})
|
||||
return refuse(`visibility could not be resolved: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Where core should point a link at a guild (MODULE_API 1.6.0, TEAMS.md §6.4).
|
||||
//
|
||||
// **Core cannot work this out for itself, and it is not supposed to.** Teams are
|
||||
// a contract primitive with no core surface — this module owns the guild page,
|
||||
// because core does not own the word "guild" — so the one thing core needs back
|
||||
// is where the page it does not own actually lives. A notification email that
|
||||
// cannot link to the thread it is about is most of the way to useless.
|
||||
//
|
||||
// A relative path with `{externalId}` substituted, matching `Guild.jsx`'s route
|
||||
// (`/uo/guilds/:id`). Core does the substitution and nothing else with it; a
|
||||
// template naming its own host is refused at registration, which is why this is
|
||||
// data and not a callback.
|
||||
const pageUrlTemplate = '/uo/guilds/{externalId}'
|
||||
|
||||
// `resolveUserId` is exported for the `/guild` chat command, which counts linked
|
||||
// members and must decide "linked" by the same rule the roster does — a second
|
||||
// copy of that two-source check is a copy that drifts.
|
||||
module.exports = {
|
||||
getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate, resolveUserId,
|
||||
}
|
||||
@@ -10,7 +10,32 @@ 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
|
||||
//
|
||||
// This says 8 because this build speaks protocol 8: the idempotency key and the
|
||||
// participation ledger (6), the world verbs plus the targeted lease planes (7), and
|
||||
// the Asset Bridge (8) -- of which this module is the first consumer, importing the
|
||||
// cliloc table over `GET /cliloc` instead of reading a file an operator converted by
|
||||
// hand (docs/link/v8.md §9).
|
||||
//
|
||||
// It said 4 before 5, and 3 for a while after protocol 4 shipped — which is the bug this
|
||||
// constant was introduced to fix. A FRESH install pinned 3, the sidecar answered
|
||||
// `409 protocol version mismatch` to every REST call, and a new deployment read nothing
|
||||
// from its shard until an admin edited the number by hand in Admin → Shard.
|
||||
//
|
||||
// **And it happened again, twice, in Phases 11a and 12a** — this constant and the two in
|
||||
// `db/schema.sql` all sat at 5 while the wire went to 6 and then 7, so every sidecar call
|
||||
// on a real deployment would have been refused. Both live walks set the column by hand
|
||||
// while standing the rig up, which is exactly what makes a migration nobody runs
|
||||
// invisible. Phase 12b carries all three to 7.
|
||||
//
|
||||
// **Nothing in this repo can check this against the wire**, and that is worth knowing
|
||||
// before trusting the test that guards it: `schemaFragment.test.js` asserts the three
|
||||
// declarations agree WITH EACH OTHER, which is a real check — they drifted apart once —
|
||||
// but all three being equally stale passes it. The wire's version lives in `link`
|
||||
// (`PROTOCOL_VERSION`) and the overlay's in `servuo-plugins/overlay.toml`; the thing that
|
||||
// actually pairs them is the installer's bundle check, at deploy time. So bumping this in
|
||||
// the same change as the emitters is still the discipline, and no test here replaces it.
|
||||
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 8
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) {
|
||||
@@ -85,4 +110,7 @@ async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt
|
||||
return toSafe(row)
|
||||
}
|
||||
|
||||
module.exports = { getSafe, getWithToken, save, recordStatus }
|
||||
// DEFAULT_PROTOCOL is exported for the schema test, which asserts that this constant
|
||||
// and schema.sql's two declarations of the same number AGREE, rather than asserting a
|
||||
// hardcoded version at each site -- which is what let them drift apart before.
|
||||
module.exports = { getSafe, getWithToken, save, recordStatus, DEFAULT_PROTOCOL }
|
||||
|
||||
158
server/package-lock.json
generated
158
server/package-lock.json
generated
@@ -13,7 +13,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"express": "^4.19.2",
|
||||
"express-validator": "^7.2.0"
|
||||
"express-validator": "^7.2.0",
|
||||
"swagger-autogen": "^2.23.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -33,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",
|
||||
@@ -40,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",
|
||||
@@ -65,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",
|
||||
@@ -106,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",
|
||||
@@ -156,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",
|
||||
@@ -359,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",
|
||||
@@ -408,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",
|
||||
@@ -481,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",
|
||||
@@ -498,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",
|
||||
@@ -581,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",
|
||||
@@ -624,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",
|
||||
@@ -634,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",
|
||||
@@ -867,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",
|
||||
@@ -931,6 +1080,13 @@
|
||||
"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",
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "node --test --require ./test/_setup.js",
|
||||
"check:imports": "node scripts/checkImports.js"
|
||||
"check:imports": "node scripts/checkImports.js",
|
||||
"check:bundle": "node scripts/checkBundle.js",
|
||||
"swagger": "node scripts/swaggerFragment.js",
|
||||
"check:swagger": "node scripts/swaggerFragment.js --check"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -15,10 +18,11 @@
|
||||
"//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. test/_fakes.js builds a REAL express router - a fake Router would test the fake."
|
||||
"//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)."
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const shardOps = require('./shardOps.controller')
|
||||
const shardVisibility = require('./shardVisibility.controller')
|
||||
const shardAtlas = require('./shardAtlas.controller')
|
||||
const shardClilocs = require('./shardClilocs.controller')
|
||||
const shardAssets = require('./shardAssets.controller')
|
||||
const selfShard = require('../player/shard.controller')
|
||||
const { requireRole, validate } = core.middleware
|
||||
|
||||
@@ -47,8 +48,8 @@ shardRouter.post(
|
||||
// #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/ShardLinkRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||||
/* #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,
|
||||
@@ -59,7 +60,7 @@ shardRouter.get(
|
||||
// #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/ShardLink" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
|
||||
selfShard.listAccounts,
|
||||
)
|
||||
shardRouter.get(
|
||||
@@ -103,7 +104,7 @@ shardRouter.get(
|
||||
// #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/ShardVendorSale" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
|
||||
selfShard.getSales,
|
||||
)
|
||||
shardRouter.post(
|
||||
@@ -112,7 +113,7 @@ shardRouter.post(
|
||||
// #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.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" } } } } */
|
||||
@@ -223,7 +224,7 @@ shardRouter.get(
|
||||
// #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/ShardEvent" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEvent" } } } } } */
|
||||
modAccess,
|
||||
shardOps.listAudit,
|
||||
)
|
||||
@@ -233,7 +234,7 @@ shardRouter.get(
|
||||
// #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/ShardHouse" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
|
||||
modAccess,
|
||||
shardOps.listHouses,
|
||||
)
|
||||
@@ -250,10 +251,10 @@ shardRouter.get(
|
||||
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.summary = 'Spawn atlas status: source, drift, counts, pending review (admin only)'
|
||||
// #swagger.description = 'Which source the atlas is built from — the linked shard over uo-link, or a local ServUO tree — whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. On the bridge, reading drift costs one shard round trip for the file manifest (hashes, no bytes). The public /atlas/meta route reports the game world only; this detail is here.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||
/* #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,
|
||||
@@ -261,11 +262,11 @@ shardRouter.get(
|
||||
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.summary = 'Re-import the spawn atlas from its source (admin only)'
|
||||
// #swagger.description = 'Applies a map change without a restart — and on a linked shard it is the only thing that does, because boot never calls the shard for this. `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 source 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.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
body('force').optional().isBoolean(),
|
||||
validate,
|
||||
@@ -277,7 +278,7 @@ shardRouter.post(
|
||||
// #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)'
|
||||
// #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
shardAtlas.approve,
|
||||
)
|
||||
@@ -287,7 +288,7 @@ shardRouter.post(
|
||||
// #swagger.summary = 'Reject a staged atlas refresh (admin only)'
|
||||
// #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */
|
||||
/* #swagger.responses[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,
|
||||
@@ -299,7 +300,7 @@ shardRouter.put(
|
||||
// #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */
|
||||
/* #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,
|
||||
@@ -307,10 +308,17 @@ shardRouter.put(
|
||||
)
|
||||
|
||||
// ── 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.
|
||||
// UO's id → display-string map, read from the shard's own UO client over the
|
||||
// bridge (docs/link/v8.md §9, docs/website/CLILOCS.md). Sits beside the atlas for
|
||||
// the same reason: it is static content derived from the operator's own files
|
||||
// rather than anything the sidecar streams, and operating it is shard
|
||||
// administration.
|
||||
//
|
||||
// Protocol 8 changed where the base table comes from, not what these routes are:
|
||||
// the shard decompresses `Cliloc.enu` and serves it paged, so an operator no
|
||||
// longer converts anything by hand. Import stays an explicit admin action,
|
||||
// because the only thing that changes a client's table is an operator patching
|
||||
// their client.
|
||||
//
|
||||
// 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
|
||||
@@ -320,9 +328,9 @@ 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.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether they have drifted from what is loaded. `source` says which pipeline is in use: `bridge` (the shard reads its own client — the normal case once uo-link is configured) or `file` (a converted file on disk, deprecated, kept for installs with no shard link). On the bridge, `shard` carries the client file’s size, mtime, hash and the shard’s extractor version, and `shard.hashing: true` means a null hash is “not computed yet”, not “changed”. The table is always a SET: the base plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any overlay that was loaded before and is now gone; an import refuses that without `approve`. A shard with no source at all is a supported state — item names simply render as ids.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||
/* #swagger.responses[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,
|
||||
@@ -330,11 +338,11 @@ shardRouter.get(
|
||||
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.summary = 'Re-import the cliloc table from its source (admin only)'
|
||||
// #swagger.description = 'Applies a client patch, or a change to the shard’s own overlay files, without a restart. On the bridge this is the ONLY thing that imports — boot deliberately does not call the shard — so it is what an operator presses after patching their client. `force` reimports even when the sources are unchanged. `approve` accepts a refresh in which a previously-loaded overlay 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. Nothing here throws for an operator-visible problem: a shard that is down, an asset plane the operator has switched off, a client with no cliloc file, or a malformed overlay all answer 200 with status "unavailable" and a reason naming what to fix.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocRefreshResult" } } } } */
|
||||
adminOnly,
|
||||
body('force').optional().isBoolean(),
|
||||
body('approve').optional().isBoolean(),
|
||||
@@ -344,17 +352,69 @@ shardRouter.post(
|
||||
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.summary = 'Set the cliloc path the site reads overlays (and any file base) from (admin only)'
|
||||
// #swagger.description = 'On an install with uo-link configured this selects only where `custom/` overlays are read from — the base table comes from the shard. Without a shard link it is also where the converted base file is looked for, which is the deprecated pre-protocol-8 pipeline. Accepts either a file or a directory to search; overlays are read from a `custom/` directory beside it either way, so 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. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Directory holding the custom/ overlays (and, with no shard link, a converted base file). Blank clears it." } } } } } } */
|
||||
/* #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,
|
||||
)
|
||||
|
||||
// ── Client assets (admin only) ────────────────────────────────────────────
|
||||
// Creature artwork, read from the shard's own UO client over the bridge
|
||||
// (docs/link/v8.md §6, §8). Sits beside the cliloc routes for the same reason
|
||||
// they sit beside the atlas: static content derived from the operator's own
|
||||
// files, and operating it is shard administration.
|
||||
//
|
||||
// There is deliberately NO public counterpart. The pictures are served as
|
||||
// ordinary files under `/uploads`, and `shard_spawn_creatures.art` names them on
|
||||
// the atlas responses the site already returns — so nothing public needs to know
|
||||
// this pipeline exists.
|
||||
shardRouter.get(
|
||||
'/assets',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Client asset import status: what is loaded, what the shard has, whether they differ (admin only)'
|
||||
// #swagger.description = 'What the site currently holds (the imported body catalogue, how many sprites are stored, how many atlas creatures resolved to a body id) beside what the shard reports for the client files those pictures come from. `drift: true` means the client files have changed since the last import — press Import. `shard.hashing: true` means a null hash is “not computed yet”, not “changed”: the shard hashes 195 MB anim files off the request path. `shard.imaging.ok: false` is the named NO_IMAGING state — a Linux shard host without libgdiplus cannot render a sprite at all, and the reason names the package to install. A shard with no link configured, or one that is down, is a reported state with a reason rather than an error.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Asset import status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAssetStatus" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
shardAssets.getStatus,
|
||||
)
|
||||
shardRouter.post(
|
||||
'/assets/import',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Import creature artwork from the shard’s UO client (admin only)'
|
||||
// #swagger.description = 'Walks the shard’s asset manifest, fetches only the sprites whose hash changed, stores them under uploads/atlas/, re-resolves every atlas creature to a body id and points each creature at its picture. This is the ONLY thing that imports — boot deliberately never calls the shard — so it is what an operator presses after patching their client. `force` re-imports even when the client files are unchanged. `approve` accepts a catalogue that no longer offers assets this site holds; refused by default, because an unmounted client volume and a deliberate downgrade are indistinguishable from the server and the wrong guess deletes artwork. An operator-supplied `spawnAtlas.art.json` always wins over an imported sprite. Nothing here throws for an operator-visible problem: a shard that is down, an asset plane switched off, or a host that cannot render images all answer 200 with status "unavailable" and a reason naming what to fix. Assets a client simply does not have are NOT failures — two thirds of the playable ghost and gargoyle bodies have no art on a stock client.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Import even if the shard’s client files are unchanged." }, approve: { type: "boolean", description: "Accept a catalogue that no longer offers assets this site holds." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAssetImportResult" } } } } */
|
||||
adminOnly,
|
||||
body('force').optional().isBoolean(),
|
||||
body('approve').optional().isBoolean(),
|
||||
validate,
|
||||
shardAssets.importAssets,
|
||||
)
|
||||
|
||||
shardRouter.post(
|
||||
'/assets/warm',
|
||||
// #swagger.tags = ['Admin · Shard']
|
||||
// #swagger.summary = 'Fetch item and land artwork the site is missing, now (admin only)'
|
||||
// #swagger.description = 'Runs one pass of the item-art warm loop instead of waiting for its timer. The pass works out which item pictures this site's own rows name — every distinct (ItemID, hue) on a player vendor, plus anything a character sheet has shown since the last pass — and fetches the ones it does not already hold from the shard, hued and stored under uploads/items/. There is deliberately NO manifest and no bulk import here: the client addresses 49,152 item graphics times three thousand hues, so the working set is defined by what the site actually displays. `force` re-fetches pictures the site already holds, which is how an operator recovers a wiped uploads volume. `limit` bounds one pass; the default is 400, because the shard serves one asset request at a time and a pass must not hold that slot against an import. Nothing throws for an operator-visible problem: no shard configured, a shard that is down, an asset plane switched off, a host with no libgdiplus, or a plugin overlay too old to serve item art all answer 200 with status "unavailable"/"skipped" and a reason naming what to fix.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Re-fetch pictures this site already holds." }, limit: { type: "integer", description: "How many keys this pass may fetch (1-2000)." } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'What the pass did', content: { "application/json": { schema: { $ref: "#/components/schemas/UoItemArtWarmResult" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('force').optional().isBoolean(),
|
||||
body('limit').optional().isInt({ min: 1, max: 2000 }),
|
||||
validate,
|
||||
shardAssets.warmItemArt,
|
||||
)
|
||||
|
||||
// ── 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.
|
||||
@@ -364,7 +424,7 @@ shardRouter.get(
|
||||
// #swagger.summary = 'Get per-feature shard visibility config (admin only)'
|
||||
// #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
|
||||
/* #swagger.responses[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,
|
||||
@@ -375,8 +435,8 @@ shardRouter.put(
|
||||
// #swagger.summary = 'Update per-feature shard visibility config (admin only)'
|
||||
// #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */
|
||||
/* #swagger.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(),
|
||||
|
||||
143
server/router/admin/shardAssets.controller.js
Normal file
143
server/router/admin/shardAssets.controller.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// ── Admin · Client assets ──────────────────────────────────────────────────
|
||||
//
|
||||
// Operating the asset import: what the site holds, what the shard's client files
|
||||
// currently are, and a re-import after a client patch (docs/link/v8.md §6, §8,
|
||||
// §14, protocol 8 phase 3).
|
||||
//
|
||||
// The policy lives in the model. This controller does three things and no more:
|
||||
// it validates input, it maps an import RESULT onto an HTTP status, and it
|
||||
// records the action in the admin activity log.
|
||||
//
|
||||
// **An import result is not an exception**, exactly as for clilocs. A shard that
|
||||
// is down, an asset plane the operator has switched off, a Linux host with no
|
||||
// libgdiplus, a client patched halfway through the walk — each is a 200 carrying
|
||||
// `status: 'unavailable'` and a reason naming what to fix, not a 500 that says
|
||||
// only "something broke". The one thing that DOES 500 is this file having a bug.
|
||||
//
|
||||
// **This is the only thing that imports.** Boot never calls the shard for assets,
|
||||
// for the same reason it stopped calling it for clilocs: the files change when an
|
||||
// operator patches their client, which is an event they know about and the site
|
||||
// does not. So this endpoint is what an operator presses afterwards.
|
||||
//
|
||||
// Phase 8 built the panel these serve (`Admin → Client Files`) and added one
|
||||
// thing to this pair: the import records a summary of what it did, and the
|
||||
// vanished keys it refuses to apply come back with the pictures they currently
|
||||
// have. Both exist because an operator pressing Update needs to see an answer,
|
||||
// and the audit log — which still receives every action here — is one unfiltered
|
||||
// list of every admin action on the site, so an import from three client patches
|
||||
// ago cannot be found in it (org lead, 2026-09-14).
|
||||
|
||||
const assets = require('../../model/shardAssets/shardAssets.model')
|
||||
const itemArt = require('../../model/shardAssets/shardItemArt.model')
|
||||
const { activity } = require('../../core')
|
||||
|
||||
const log = require('../../core').logger('admin-shard-assets')
|
||||
|
||||
// GET /admin/shard/assets — what is loaded, what the shard says, whether they
|
||||
// disagree. No public counterpart: the assets themselves are served as ordinary
|
||||
// files under /uploads, and this is the operating view of the import.
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
return res.json(await assets.getStatus())
|
||||
} catch (err) {
|
||||
log.error('getStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/assets/import — import or update the body catalogue, then
|
||||
// re-resolve the atlas's creatures and re-derive their artwork.
|
||||
//
|
||||
// `force` re-imports even when the client files are unchanged. It is also how an
|
||||
// operator recovers a wiped uploads volume: the database still holds every hash,
|
||||
// so the ordinary gate would report "unchanged" while every picture is missing.
|
||||
// (The import checks for the file on disk per key as well, so that case usually
|
||||
// heals itself — `force` is the answer when it does not.)
|
||||
//
|
||||
// `approve` accepts a catalogue that no longer offers keys this site holds.
|
||||
// Refused by default because an unmounted client volume and a deliberate
|
||||
// downgrade look identical from the server, and the wrong guess deletes artwork.
|
||||
async function importAssets(req, res) {
|
||||
try {
|
||||
const force = !!req.body?.force
|
||||
const approve = !!req.body?.approve
|
||||
// From the session, never the body — the same rule the in-game ops routes
|
||||
// apply, and for the same reason: this is recorded as who did it.
|
||||
const result = await assets.importAssets({ force, approve, by: req.user?.username ?? null })
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'shard.assets.import',
|
||||
detail: {
|
||||
force,
|
||||
approve,
|
||||
status: result.status,
|
||||
code: result.code ?? null,
|
||||
assets: result.assets ?? null,
|
||||
fetched: result.fetched ?? null,
|
||||
written: result.written ?? null,
|
||||
removed: result.removed ?? null,
|
||||
// The body pass is logged as its own tally rather than as a single
|
||||
// number: `unknown` means the spawn files name a type this shard's
|
||||
// scripts do not define, which is real drift an operator should see, and
|
||||
// it reads identically to `failed` if both are summed into "not resolved".
|
||||
bodies: result.bodies?.tally ?? null,
|
||||
vanished: result.vanishedCount ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('importAssets', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/assets/warm — run one item-art warm pass now.
|
||||
//
|
||||
// The pass runs on its own timer and needs no operator, so this exists for the
|
||||
// two moments where waiting for the interval is the wrong answer: an operator who
|
||||
// has just configured the bridge and wants to see it work, and one who has just
|
||||
// patched their client and would rather not wait for pictures to refresh.
|
||||
//
|
||||
// `force` re-fetches keys the site already holds. The body import's `force` means
|
||||
// the same thing for the same reason — a wiped uploads volume leaves every
|
||||
// database row correct and every picture missing, and only an explicit re-fetch
|
||||
// recovers it.
|
||||
//
|
||||
// It is bounded: one pass asks for at most `limit` keys, because the shard's
|
||||
// asset plane serves one request at a time and a pass must not hold that slot
|
||||
// against the operator's own import.
|
||||
async function warmItemArt(req, res) {
|
||||
try {
|
||||
const force = !!req.body?.force
|
||||
const limit = Number.isFinite(Number(req.body?.limit)) ? Number(req.body.limit) : undefined
|
||||
const result = await itemArt.warm({ force, ...(limit ? { limit } : {}) })
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'shard.assets.warm',
|
||||
detail: {
|
||||
force,
|
||||
limit: limit ?? null,
|
||||
status: result.status,
|
||||
code: result.code ?? null,
|
||||
wanted: result.wanted ?? null,
|
||||
asked: result.asked ?? null,
|
||||
written: result.written ?? null,
|
||||
remaining: result.remaining ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('warmItemArt', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStatus,
|
||||
importAssets,
|
||||
warmItemArt,
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// ── 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).
|
||||
// Operating the cliloc import: which source the table comes from, whether it has
|
||||
// drifted from what is loaded, and a reimport after a client patch
|
||||
// (docs/link/v8.md §9, 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
|
||||
@@ -10,11 +10,16 @@
|
||||
//
|
||||
// **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.
|
||||
// be stopped by a bad source. That contract is preserved here, and protocol 8
|
||||
// widened the set of things it covers: a shard that is down, an asset plane the
|
||||
// operator has switched off, a client with no cliloc file, a client patched
|
||||
// halfway through the import — plus everything the file pipeline could already
|
||||
// report. Each is a 200 carrying `status: 'unavailable'` and a reason naming what
|
||||
// to fix, not a 500 that says only "something broke".
|
||||
//
|
||||
// **Import matters more than it used to.** On the bridge, boot deliberately does
|
||||
// not call the shard, so this endpoint is the only thing that refreshes the
|
||||
// table — the operator presses it after patching their client.
|
||||
|
||||
const clilocs = require('../../model/shardClilocs/shardClilocs.model')
|
||||
const market = require('../../model/shardMarket/shardMarket.model')
|
||||
@@ -69,6 +74,10 @@ async function importClilocs(req, res) {
|
||||
force,
|
||||
approve,
|
||||
status: result.status,
|
||||
// Which pipeline actually ran. Worth having in the audit log for the
|
||||
// same reason it is in the status: an operator debugging a stale table
|
||||
// needs to know whether the site asked the shard or read a file.
|
||||
source: result.source ?? null,
|
||||
count: result.count ?? null,
|
||||
missingSources: result.missingSources ?? result.acceptedMissing ?? null,
|
||||
},
|
||||
@@ -80,12 +89,16 @@ async function importClilocs(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /admin/shard/clilocs/path — point the site at a different cliloc file.
|
||||
// PUT /admin/shard/clilocs/path — point the site at a different cliloc path.
|
||||
//
|
||||
// On an install with uo-link configured this selects where `custom/` OVERLAYS are
|
||||
// read from; the base table comes from the shard either way. Without a shard link
|
||||
// it is also where the converted base file is looked for.
|
||||
//
|
||||
// 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.
|
||||
// overlay resolution off (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
|
||||
|
||||
@@ -70,7 +70,7 @@ 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.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" } } } } */
|
||||
@@ -100,7 +100,7 @@ uoLinkRouter.post(
|
||||
// #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/TownCrierRequest" } } } } */
|
||||
/* #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" } } } } */
|
||||
|
||||
@@ -39,7 +39,7 @@ shardRouter.get(
|
||||
// #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/ShardLink" } } } } } */
|
||||
/* #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,
|
||||
@@ -51,7 +51,7 @@ shardRouter.get(
|
||||
// #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/ShardVendorSale" } } } } } */
|
||||
/* #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,
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 itemArt = require('../../model/shardAssets/shardItemArt.model')
|
||||
const { activity } = require('../../core')
|
||||
const gameSignup = require('../../utils/gameSignup')
|
||||
const { salesForAccounts } = require('../../utils/shardSales')
|
||||
@@ -71,6 +72,28 @@ async function resolveProfileClilocs(profile) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a picture to each equipped item (docs/link/v8.md §5, §11 — phase 5).
|
||||
*
|
||||
* The equipment list is the other place on this site that carries (itemId, hue),
|
||||
* and unlike the marketplace it is LIVE: the profile is fetched from the shard per
|
||||
* request and stored nowhere, so there is no table a warm pass could derive these
|
||||
* keys from. That is what `notice` is for, and `decorate` does both — it fills in
|
||||
* every picture we already hold and remembers the ones we do not, so a character
|
||||
* whose sheet renders without art once renders with it a few minutes later.
|
||||
*
|
||||
* It never asks the shard. §17.11: the page serves what is stored and the warm
|
||||
* pass does the fetching, because a route that fetched would let any visitor drive
|
||||
* the shard's single-slot asset plane from a URL.
|
||||
*/
|
||||
async function resolveProfileArt(profile) {
|
||||
const equipment = Array.isArray(profile?.equipment) ? profile.equipment : []
|
||||
|
||||
if (equipment.length === 0) return
|
||||
|
||||
await itemArt.decorate(equipment)
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -85,6 +108,7 @@ async function enrichCharProfile(profile) {
|
||||
if (govs.length) profile.governorOf = govs.map((g) => g.city)
|
||||
}
|
||||
await resolveProfileClilocs(profile)
|
||||
await resolveProfileArt(profile)
|
||||
} catch (err) {
|
||||
log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message })
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ shardRouter.post(
|
||||
// #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/ShardLinkRequest" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||||
/* #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 }),
|
||||
@@ -44,7 +44,7 @@ shardRouter.post(
|
||||
// #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.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" } } } } */
|
||||
@@ -62,7 +62,7 @@ shardRouter.get(
|
||||
// #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/ShardLink" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */
|
||||
shard.listAccounts,
|
||||
)
|
||||
shardRouter.get(
|
||||
@@ -109,7 +109,7 @@ shardRouter.get(
|
||||
// #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/ShardVendorSale" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */
|
||||
shard.getSales,
|
||||
)
|
||||
shardRouter.get(
|
||||
@@ -118,7 +118,7 @@ shardRouter.get(
|
||||
// #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/ShardHouse" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */
|
||||
shard.getHouses,
|
||||
)
|
||||
|
||||
|
||||
@@ -38,12 +38,12 @@ atlasRouter.get(
|
||||
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.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['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard’s own files; an unknown one returns an empty page.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */
|
||||
/* #swagger.responses[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 }),
|
||||
@@ -59,11 +59,11 @@ atlasRouter.get(
|
||||
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.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard’s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' }
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' }
|
||||
// #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' }
|
||||
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */
|
||||
/* #swagger.responses[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,
|
||||
@@ -77,10 +77,10 @@ atlasRouter.get(
|
||||
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.description = 'Flattened out of the shard’s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' }
|
||||
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */
|
||||
/* #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,
|
||||
@@ -92,10 +92,10 @@ atlasRouter.get(
|
||||
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.description = 'From the shard’s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' }
|
||||
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */
|
||||
/* #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,
|
||||
@@ -109,7 +109,7 @@ atlasRouter.get(
|
||||
// #swagger.summary = 'Configured champion altars (the roster, not the live board)'
|
||||
// #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoAtlasChampion" } } } } } */
|
||||
facetParam,
|
||||
validate,
|
||||
siteMode,
|
||||
@@ -120,8 +120,8 @@ atlasRouter.get(
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported'
|
||||
// #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on <date>" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.'
|
||||
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */
|
||||
// #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,
|
||||
)
|
||||
|
||||
@@ -177,6 +177,31 @@ async function getGuilds(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/shard/guilds/:id — one guild and its roster.
|
||||
//
|
||||
// The board endpoint above returns every guild WITHOUT its roster; this is the
|
||||
// detail view, and it is the page that hosts core's Team activity feed through
|
||||
// the `uo.guild.detail` slot (docs/website/TEAMS.md Part 3).
|
||||
//
|
||||
// Projected through the same `guilds` feature as the board, so an operator who
|
||||
// gates guilds to staff gates this too, and `acct`/`webId` on the roster rows
|
||||
// never survive below admin — those are LOCKED fields, and a roster is where they
|
||||
// actually appear in bulk.
|
||||
async function getGuild(req, res) {
|
||||
try {
|
||||
const guilds = await shardState.listGuilds()
|
||||
const guild = guilds.find((g) => String(g.id) === String(req.params.id))
|
||||
// 404 rather than an empty object: a guild that disbanded is gone, and the
|
||||
// page needs to say so rather than render an empty shell.
|
||||
if (!guild) return res.status(404).json({ message: 'Not Found' })
|
||||
const members = await shardState.listGuildMembers(guild.id)
|
||||
return res.json(await visibility.project('guilds', { ...guild, roster: members }, req))
|
||||
} catch (err) {
|
||||
log.error('shard.getGuild', 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.
|
||||
@@ -421,6 +446,7 @@ module.exports = {
|
||||
getIdoc,
|
||||
getChamps,
|
||||
getGuilds,
|
||||
getGuild,
|
||||
getGovernors,
|
||||
getGovernorHistory,
|
||||
getPresence,
|
||||
|
||||
@@ -37,7 +37,7 @@ shardRouter.get(
|
||||
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/ShardStatus" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardStatus" } } } } */
|
||||
shard.getStatus,
|
||||
)
|
||||
shardRouter.get(
|
||||
@@ -45,10 +45,10 @@ shardRouter.get(
|
||||
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.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/ShardEvent" } } } } } */
|
||||
/* #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,
|
||||
@@ -60,7 +60,7 @@ shardRouter.get(
|
||||
// #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/ShardEconomyPoint" } } } } } */
|
||||
/* #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,
|
||||
@@ -70,7 +70,7 @@ shardRouter.get(
|
||||
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/ShardOnlinePlayer" } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardOnlinePlayer" } } } } } */
|
||||
shard.getOnline,
|
||||
)
|
||||
shardRouter.get(
|
||||
@@ -78,8 +78,8 @@ shardRouter.get(
|
||||
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/ShardHouse" } } } } } */
|
||||
// #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(
|
||||
@@ -100,6 +100,17 @@ shardRouter.get(
|
||||
/* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||
shard.getGuilds,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/guilds/:id',
|
||||
requireFeature('guilds'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'One guild and its roster'
|
||||
// #swagger.description = 'The detail view behind the board. Gated and projected through the same `guilds` feature, so an operator who raises that audience raises this too, and the locked acct/webId fields never survive below admin — a roster is where they appear in bulk. This page is also where core renders the Team activity feed, through the `uo.guild.detail` extension slot.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The guild id.' }
|
||||
/* #swagger.responses[200] = { description: 'The guild, with its roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such guild', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
shard.getGuild,
|
||||
)
|
||||
shardRouter.get(
|
||||
'/governors',
|
||||
requireFeature('governors'),
|
||||
@@ -137,14 +148,14 @@ shardRouter.get(
|
||||
// #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/ShardHouse" } } } } } */
|
||||
/* #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.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,
|
||||
@@ -154,18 +165,18 @@ shardRouter.get(
|
||||
requireFeature('leaderboards'),
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Points / loyalty leaderboards, one board per point system'
|
||||
// #swagger.description = 'Every points/loyalty leaderboard the shard publishes (Queen\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.'
|
||||
/* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardPointsBoard" } } } } } */
|
||||
// #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.summary = 'One points system’s leaderboard'
|
||||
// #swagger.description = 'A single board by the shard’s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.'
|
||||
/* #swagger.parameters['system'] = { in: 'path', required: true, description: 'PointsType name, e.g. QueensLoyalty', schema: { type: 'string' } } */
|
||||
/* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardPointsBoard" } } } } */
|
||||
/* #swagger.responses[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,
|
||||
@@ -181,17 +192,17 @@ shardRouter.get(
|
||||
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.description = 'Every priced listing on every player vendor the shard publishes — the same index the in-game Vendor Search gump reads, and it honours the same per-vendor opt-out, so a player who hid their shop in game is hidden here too. Results are LISTINGS, each carrying enough of its shop to be actionable. Served from the site’s own tables (the sidecar is not touched), so it renders while the shard is down; `staleAt` is the oldest vendor row and the page must say how far behind the index can be — the shard sweeps vendors round-robin, so prices are inherently up to one full cycle old. Item names are resolved server-side against the cliloc table (docs/website/CLILOCS.md); on a shard that has not configured one, `displayName` is null and clients render the item id.'
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the resolved item name or the item’s own literal name (max 60 chars).' }
|
||||
// #swagger.parameters['minPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Lowest price to include.' }
|
||||
// #swagger.parameters['maxPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Highest price to include.' }
|
||||
// #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match, for "more like this".' }
|
||||
// #swagger.parameters['map'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet. Facet names come from the shard\'s own data; an unknown one returns an empty page.' }
|
||||
// #swagger.parameters['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['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. recent orders by when the shop was last seen.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
/* #swagger.responses[200] = { description: 'A page of listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketPage" } } } } */
|
||||
/* #swagger.responses[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" } } } } */
|
||||
@@ -213,7 +224,7 @@ shardRouter.get(
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Marketplace size, staleness and filter options'
|
||||
// #swagger.description = 'How many vendors and listings the index holds, how stale it may be (`staleAt` = the oldest vendor row, `freshAt` = the newest), and which facets and regions actually hold vendors — so a client can build its filters without running a search it will discard.'
|
||||
/* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketMeta" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardMarketMeta" } } } } */
|
||||
shard.getMarketMeta,
|
||||
)
|
||||
shardRouter.get(
|
||||
@@ -226,7 +237,7 @@ shardRouter.get(
|
||||
/* #swagger.parameters['serial'] = { in: 'path', required: true, description: 'Vendor serial, e.g. 0x40001234', schema: { type: 'string' } } */
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to return, 1..500 (default 250).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to skip (default 0).' }
|
||||
/* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketVendor" } } } } */
|
||||
/* #swagger.responses[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 }),
|
||||
@@ -239,15 +250,15 @@ shardRouter.get(
|
||||
'/features',
|
||||
// #swagger.tags = ['Public · Shard']
|
||||
// #swagger.summary = 'Shard features visible to the caller (drives client nav)'
|
||||
// #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.'
|
||||
/* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */
|
||||
// #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.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,
|
||||
)
|
||||
|
||||
248
server/scripts/checkBundle.js
Normal file
248
server/scripts/checkBundle.js
Normal file
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env node
|
||||
// ── Does the release actually ship everything the module needs? ────────────
|
||||
//
|
||||
// `ci/bundle.json` says what a release copies. `server/index.js` says what the
|
||||
// module requires. Nothing kept those two in agreement, and on 2026-08-19 they
|
||||
// disagreed in production: `server/commands/` was added by the Teams cutover,
|
||||
// the include list in release.yml was not updated, and v1.0.0 shipped without
|
||||
// it. Every boot logged
|
||||
//
|
||||
// module "uo" failed to load — {"stage":"register","reason":"Cannot find
|
||||
// module './commands/guild.command'"}
|
||||
//
|
||||
// and the module was dead on the operator's box. Nothing caught it: the PR
|
||||
// checks install the module by copying the WHOLE repo into core, so they only
|
||||
// ever exercised a tree that had the file. The release is the only place the
|
||||
// subset exists, and the release had no check that the subset was complete.
|
||||
//
|
||||
// This script asks that question in the two places it can be asked:
|
||||
//
|
||||
// --check (PR checks) Every file reachable from the entry point by a
|
||||
// relative require lives under something ci/bundle.json
|
||||
// lists. Source-tree only, so it is fast and needs no
|
||||
// assembled bundle — it fails on the PR that adds the
|
||||
// directory, which is where the fix is cheapest.
|
||||
//
|
||||
// --bundle <dir> (release) Every relative specifier inside an ASSEMBLED bundle
|
||||
// resolves to a file that is in it. Asked of the
|
||||
// artifact rather than of the source, so it also
|
||||
// catches a copy that half-failed, a list that names a
|
||||
// path that has moved, and anything else between the
|
||||
// declaration and the tarball.
|
||||
//
|
||||
// The two are deliberately not the same question. The first is about the list
|
||||
// being right; the second is about the tarball being right. A release runs both.
|
||||
//
|
||||
// ── Why reachability, and not "require the entry point" ────────────────────
|
||||
//
|
||||
// The obvious check — require the bundle's entry and see if it throws — does not
|
||||
// work here, and the reason is in index.js's own header: its requires are inside
|
||||
// `register()` because require order is load-bearing (`core.init(ctx)` has to run
|
||||
// before anything under `router/` is required). So requiring the entry evaluates
|
||||
// exactly one line, `require('./core')`, and reports success on a bundle missing
|
||||
// every router it has. Calling `register()` for real would need a fake `ctx`
|
||||
// complete enough to satisfy the whole module — which is what `test/` is for, and
|
||||
// `test/` does not ship. Walking the requires statically asks the same question
|
||||
// without needing either.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { stripCommentsAndTemplates } = require('./checkImports')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||
|
||||
// Only relative specifiers. A bare one is checkImports.js's question, not this
|
||||
// one, and the two failures want different advice.
|
||||
const RELATIVE = /(?:require\(|from\s+|import\()\s*['"](\.[^'"]+)['"]/g
|
||||
|
||||
/**
|
||||
* Resolve a relative specifier the way Node would, for the file cases that can
|
||||
* appear here: an exact path, `+.js`/`+.json`, or a directory's `index.js`.
|
||||
*
|
||||
* Returns null when nothing exists — which is the finding, not an error.
|
||||
*/
|
||||
function resolveFile(fromDir, specifier) {
|
||||
const base = path.resolve(fromDir, specifier)
|
||||
const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')]
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file reachable from `entry` by following relative requires, plus every
|
||||
* specifier that resolved to nothing.
|
||||
*
|
||||
* Exported so the test can point it at fixtures — the same reason checkImports.js
|
||||
* exports `scan`. A check that has never been shown to fail is a check nobody
|
||||
* knows the state of, and this one is now load-bearing for every release.
|
||||
*/
|
||||
function reachable(entry) {
|
||||
const seen = new Set()
|
||||
const missing = []
|
||||
const queue = [entry]
|
||||
|
||||
while (queue.length) {
|
||||
const file = queue.shift()
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
|
||||
// A .json dependency is a leaf: it is reached, it ships, and it has no
|
||||
// requires of its own to follow.
|
||||
if (file.endsWith('.json')) continue
|
||||
|
||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||
for (const [, specifier] of source.matchAll(RELATIVE)) {
|
||||
const target = resolveFile(path.dirname(file), specifier)
|
||||
if (target) queue.push(target)
|
||||
else missing.push({ file, specifier })
|
||||
}
|
||||
}
|
||||
|
||||
return { files: [...seen], missing }
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything ci/bundle.json says ends up in the bundle, as absolute paths:
|
||||
* `server[]` relative to server/, `root[]` and `generated[]` relative to the
|
||||
* module root. All three are equally "in the tarball" as far as a require is
|
||||
* concerned — the only difference is how they get there.
|
||||
*/
|
||||
function declaredServerPaths(moduleRoot = MODULE_ROOT) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(moduleRoot, 'ci', 'bundle.json'), 'utf8'))
|
||||
return [
|
||||
...manifest.server.map((p) => path.join(moduleRoot, 'server', p)),
|
||||
...(manifest.root || []).map((p) => path.join(moduleRoot, p)),
|
||||
...(manifest.generated || []).map((p) => path.join(moduleRoot, p))
|
||||
]
|
||||
}
|
||||
|
||||
const covers = (declared, file) =>
|
||||
declared.some((d) => file === d || file.startsWith(d + path.sep))
|
||||
|
||||
/**
|
||||
* --check: is ci/bundle.json's list sufficient for what the entry point reaches?
|
||||
*
|
||||
* Reports the top-level entry to ADD rather than the individual files, because
|
||||
* that is the edit: the list is stated in top-level paths, and a new directory
|
||||
* arrives with a dozen files in it.
|
||||
*/
|
||||
function checkDeclaration(moduleRoot = MODULE_ROOT) {
|
||||
const serverRoot = path.join(moduleRoot, 'server')
|
||||
const entry = path.join(serverRoot, 'index.js')
|
||||
const { files, missing } = reachable(entry)
|
||||
const declared = declaredServerPaths(moduleRoot)
|
||||
|
||||
// Grouped by the entry that would have to be added, which is the top-level
|
||||
// path under server/ — or, for the rare reachable file outside it, the path
|
||||
// itself, since that one belongs in root[] instead.
|
||||
const uncovered = new Map()
|
||||
for (const file of files) {
|
||||
if (covers(declared, file)) continue
|
||||
const inServer = file.startsWith(serverRoot + path.sep)
|
||||
const key = inServer
|
||||
? `server/${path.relative(serverRoot, file).split(path.sep)[0]}`
|
||||
: path.relative(moduleRoot, file).split(path.sep).join('/')
|
||||
if (!uncovered.has(key)) uncovered.set(key, [])
|
||||
uncovered.get(key).push(file)
|
||||
}
|
||||
|
||||
return { uncovered, missing, reached: files.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* --bundle: does every relative specifier inside an assembled bundle resolve?
|
||||
*
|
||||
* Walks the bundle's own server tree rather than starting from the entry point,
|
||||
* so a file that ships but is broken is caught too.
|
||||
*/
|
||||
function checkBundle(bundleRoot) {
|
||||
const serverRoot = path.join(bundleRoot, 'server')
|
||||
const missing = []
|
||||
const files = []
|
||||
|
||||
const walk = (dir) => {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
// The installed dependency tree is npm's business, not this check's.
|
||||
if (e.name !== 'node_modules') walk(p)
|
||||
} else if (/\.(js|mjs|cjs)$/.test(e.name)) {
|
||||
files.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(serverRoot)
|
||||
|
||||
for (const file of files) {
|
||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||
for (const [, specifier] of source.matchAll(RELATIVE)) {
|
||||
if (!resolveFile(path.dirname(file), specifier)) missing.push({ file, specifier })
|
||||
}
|
||||
}
|
||||
|
||||
return { missing, scanned: files.length }
|
||||
}
|
||||
|
||||
module.exports = { reachable, resolveFile, checkDeclaration, checkBundle, declaredServerPaths }
|
||||
|
||||
// Required by a test, or run as the check? Only the second one exits.
|
||||
if (require.main !== module) return
|
||||
|
||||
const bundleFlag = process.argv.indexOf('--bundle')
|
||||
|
||||
if (bundleFlag !== -1) {
|
||||
const root = process.argv[bundleFlag + 1]
|
||||
if (!root) {
|
||||
console.error('--bundle needs the path to an assembled bundle')
|
||||
process.exit(2)
|
||||
}
|
||||
const { missing, scanned } = checkBundle(path.resolve(root))
|
||||
if (missing.length) {
|
||||
console.error(`\nThe assembled bundle is incomplete — ${missing.length} require(s) resolve to nothing:\n`)
|
||||
for (const m of missing) {
|
||||
console.error(` ${path.relative(root, m.file)}\n requires "${m.specifier}" — not in the bundle`)
|
||||
}
|
||||
console.error('\nAdd the missing path to ci/bundle.json.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`OK — every relative require in the bundle resolves (${scanned} files scanned).`)
|
||||
} else {
|
||||
const { uncovered, missing, reached } = checkDeclaration()
|
||||
|
||||
if (missing.length) {
|
||||
console.error(`\n${missing.length} require(s) resolve to nothing in the source tree:\n`)
|
||||
for (const m of missing) {
|
||||
console.error(` ${path.relative(MODULE_ROOT, m.file)}\n requires "${m.specifier}"`)
|
||||
}
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (uncovered.size) {
|
||||
console.error(`\nci/bundle.json does not ship everything server/index.js reaches.\n`)
|
||||
console.error('A release built from this list would install and then fail at the')
|
||||
console.error('register stage with "Cannot find module", on the operator\'s box.\n')
|
||||
for (const [key, files] of uncovered) {
|
||||
console.error(` ${key} (${files.length} file${files.length === 1 ? '' : 's'} reachable)`)
|
||||
for (const f of files.slice(0, 5)) console.error(` ${path.relative(MODULE_ROOT, f)}`)
|
||||
if (files.length > 5) console.error(` … and ${files.length - 5} more`)
|
||||
}
|
||||
// server[] is written relative to server/, so name the entry to add rather
|
||||
// than the path just displayed — they differ by exactly that prefix.
|
||||
const toServer = [...uncovered.keys()].filter((k) => k.startsWith('server/'))
|
||||
const toRoot = [...uncovered.keys()].filter((k) => !k.startsWith('server/'))
|
||||
if (toServer.length) {
|
||||
console.error(`\nAdd ${toServer.map((k) => `"${k.slice('server/'.length)}"`).join(', ')} to ci/bundle.json's server[].`)
|
||||
}
|
||||
if (toRoot.length) {
|
||||
console.error(`\nAdd ${toRoot.map((k) => `"${k}"`).join(', ')} to ci/bundle.json's root[].`)
|
||||
}
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`OK — ci/bundle.json ships every file server/index.js reaches (${reached} files).`)
|
||||
}
|
||||
189
server/scripts/frozenManifest.js
Normal file
189
server/scripts/frozenManifest.js
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §5.3 — this module's frozen route manifest ─────────────────────────────
|
||||
//
|
||||
// Core freezes its URL surface in `server/routes.manifest.json` by walking the
|
||||
// live Express stack and committing the result; a PR that moves a URL has to
|
||||
// commit the new manifest, which puts the change in front of a reviewer. After
|
||||
// phase 3 the seventy URLs this module serves are no longer in that file. They
|
||||
// are here, frozen the same way and by the same generator.
|
||||
//
|
||||
// **The module's routes are DERIVED, never listed.** This script is handed two
|
||||
// manifests generated from the SAME core at the pinned ref — one without this
|
||||
// module on the volume, one with — and the difference is what this module serves.
|
||||
// Nothing here says "/api/v1/public/shard/*"; a mount prefix appears in exactly
|
||||
// one place, `server/index.js`'s `registerRoutes` call, which is where an operator's
|
||||
// core reads it from too.
|
||||
//
|
||||
// Taking the difference rather than filtering by prefix buys the other half of
|
||||
// §5.3 for free, and it is the half that matters most: **no core URL may move.**
|
||||
// A module that shadowed a core route, or whose mount displaced one, shows up
|
||||
// here as a removal or a change, not merely as an addition somewhere else. That
|
||||
// is the promise §1.2 makes to the shipped Android app and the Discord bot.
|
||||
//
|
||||
// The third thing it checks is the OpenAPI fragment (§2.8). `swagger-fragment.json`
|
||||
// is generated from the module's own registrations against §2.4's stated tier
|
||||
// bases — the one place a constant could be wrong. Here there is ground truth: a
|
||||
// real core with this module loaded, reporting the URLs it actually serves. Every
|
||||
// route must have a documented operation and every documented operation must be a
|
||||
// route. That is the per-module form of core's standing rule, never ship a route
|
||||
// that isn't in the spec — and it is what stops a wrong constant in the generator
|
||||
// from producing a fragment that is internally consistent and describes nothing
|
||||
// core will ever serve.
|
||||
//
|
||||
// Usage (the workflow does the cloning; see .gitea/workflows/frozen-manifest.yml):
|
||||
// node scripts/frozenManifest.js --before core-only.json --after core-plus-uo.json
|
||||
// node scripts/frozenManifest.js --before … --after … --check
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const MANIFEST = path.join(MODULE_ROOT, 'routes.manifest.json')
|
||||
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
||||
|
||||
const 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.'
|
||||
|
||||
const key = (r) => `${r.method} ${r.path}`
|
||||
|
||||
/**
|
||||
* The module's routes, plus proof that core's own surface did not move.
|
||||
*
|
||||
* @param {object} before routes.manifest.json from core alone
|
||||
* @param {object} after routes.manifest.json from the same core with this module
|
||||
* @returns {{ added: object[], removed: string[] }}
|
||||
*/
|
||||
function diffManifests(before, after) {
|
||||
const added = []
|
||||
const removed = []
|
||||
|
||||
for (const tier of ['public', 'internal']) {
|
||||
const was = new Set((before[tier] || []).map(key))
|
||||
for (const route of after[tier] || []) {
|
||||
if (!was.has(key(route))) added.push({ ...route, tier })
|
||||
was.delete(key(route))
|
||||
}
|
||||
for (const gone of was) removed.push(`${tier} ${gone}`)
|
||||
}
|
||||
|
||||
added.sort((a, b) => (key(a) < key(b) ? -1 : 1))
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the module's routes the fragment fails to document, and vice versa.
|
||||
*
|
||||
* Express `:id` is OpenAPI `{id}`; the fragment is already in OpenAPI's spelling
|
||||
* because that is what core merges, so the manifest's paths are converted here
|
||||
* rather than the other way round.
|
||||
*/
|
||||
function coverage(added, fragment) {
|
||||
const documented = new Set()
|
||||
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||
for (const method of Object.keys(item)) documented.add(`${method.toUpperCase()} ${p}`)
|
||||
}
|
||||
|
||||
const undocumented = []
|
||||
for (const route of added) {
|
||||
const oas = `${route.method} ${route.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}`
|
||||
if (documented.has(oas)) documented.delete(oas)
|
||||
else undocumented.push(oas)
|
||||
}
|
||||
|
||||
// Whatever is left is documented and not served: a route that moved or was
|
||||
// deleted while its annotation stayed behind. Core's spec has no equivalent
|
||||
// check and grew four orphan tags and thirty-three orphan schemas because of it.
|
||||
return { undocumented, unserved: [...documented].sort() }
|
||||
}
|
||||
|
||||
function serialize(routes) {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
$comment: COMMENT,
|
||||
routes: routes.map(({ method, path: p, tier }) => ({ method, path: p, tier })),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`
|
||||
}
|
||||
|
||||
function main() {
|
||||
const arg = (name) => {
|
||||
const i = process.argv.indexOf(name)
|
||||
return i === -1 ? null : process.argv[i + 1]
|
||||
}
|
||||
const beforePath = arg('--before')
|
||||
const afterPath = arg('--after')
|
||||
if (!beforePath || !afterPath) {
|
||||
process.stderr.write('usage: frozenManifest.js --before <manifest> --after <manifest> [--check]\n')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const before = JSON.parse(fs.readFileSync(beforePath, 'utf8'))
|
||||
const after = JSON.parse(fs.readFileSync(afterPath, 'utf8'))
|
||||
const { added, removed } = diffManifests(before, after)
|
||||
|
||||
let failed = false
|
||||
|
||||
if (removed.length > 0) {
|
||||
process.stderr.write(
|
||||
`\nLoading this module REMOVED or CHANGED ${removed.length} of core's own route(s):\n` +
|
||||
`${removed.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'A module may only add. This is the frozen-URL promise (MODULE_SYSTEM.md §1.2) breaking.\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if (added.length === 0) {
|
||||
process.stderr.write(
|
||||
'\nLoading this module added NO routes. Either it failed to load in the core checkout\n' +
|
||||
'(check the boot log for a startup_failed line) or the two manifests are the same file.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const fragment = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
const { undocumented, unserved } = coverage(added, fragment)
|
||||
if (undocumented.length > 0) {
|
||||
process.stderr.write(
|
||||
`\n${undocumented.length} route(s) this module serves have no operation in swagger-fragment.json:\n` +
|
||||
`${undocumented.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'Run `npm run swagger --prefix server` and commit the result (MODULE_API.md §2.8).\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
if (unserved.length > 0) {
|
||||
process.stderr.write(
|
||||
`\n${unserved.length} operation(s) in swagger-fragment.json are not routes this module serves:\n` +
|
||||
`${unserved.map((r) => ` - ${r}`).join('\n')}\n` +
|
||||
'A documented URL nobody serves is a client following the docs into a 404.\n',
|
||||
)
|
||||
failed = true
|
||||
}
|
||||
|
||||
if (failed) process.exit(1)
|
||||
|
||||
const contents = serialize(added)
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = fs.existsSync(MANIFEST) ? fs.readFileSync(MANIFEST, 'utf8').replace(/\r\n/g, '\n') : null
|
||||
if (current !== contents) {
|
||||
process.stderr.write(
|
||||
'\nroutes.manifest.json is stale. The URLs this module serves changed — regenerate it and\n' +
|
||||
'commit the result so the move is reviewed rather than merged as mechanical.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(`routes.manifest.json is current — ${added.length} routes, all documented\n`)
|
||||
return
|
||||
}
|
||||
|
||||
fs.writeFileSync(MANIFEST, contents)
|
||||
process.stdout.write(`wrote routes.manifest.json — ${added.length} routes, all documented\n`)
|
||||
}
|
||||
|
||||
if (require.main === module) main()
|
||||
|
||||
module.exports = { diffManifests, coverage, serialize, MANIFEST, FRAGMENT }
|
||||
@@ -49,11 +49,14 @@ function describe(result) {
|
||||
switch (result.status) {
|
||||
case 'skipped':
|
||||
return (
|
||||
'No ServUO path configured — nothing to import.\n' +
|
||||
'Set one with SERVUO_PATH, the admin panel, or --servuo <path>.\n'
|
||||
'No atlas source — nothing to import.\n' +
|
||||
'Either link a shard (Admin → Shard) or set a tree path with SERVUO_PATH, ' +
|
||||
'the admin panel, or --servuo <path>.\n'
|
||||
)
|
||||
case 'unavailable':
|
||||
return `ServUO tree unavailable: ${result.reason}\n`
|
||||
return result.source === 'bridge'
|
||||
? `The shard could not serve its configuration tree: ${result.reason}\n`
|
||||
: `ServUO tree unavailable: ${result.reason}\n`
|
||||
case 'unchanged':
|
||||
return `Atlas is already up to date${result.reason ? ` (${result.reason})` : ''}.\n`
|
||||
case 'needsReview': {
|
||||
|
||||
281
server/scripts/swaggerFragment.js
Normal file
281
server/scripts/swaggerFragment.js
Normal file
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §2.8 — the OpenAPI fragment ────────────────────────────────────────────
|
||||
//
|
||||
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
|
||||
// tags and schemas describing every route this module registers. Core merges the
|
||||
// fragments of *started* modules over its own committed spec at request time and
|
||||
// serves the result at `/api/docs.json` (docs/website/MODULE_API.md §6.1a).
|
||||
//
|
||||
// **Why a module ships a fragment at all.** Core's `npm run swagger` is STATIC
|
||||
// analysis — swagger-autogen parses `src/app.js` as text and follows the literal
|
||||
// `app.use(...)` chain. A module arrives on a volume after core was built, is
|
||||
// required by a filesystem loop, and mounts through `api.registerRoutes()`. There
|
||||
// is no literal mount for a parser to follow and core does not have our sources
|
||||
// anyway, so nothing core can run will ever describe these routes. The failure
|
||||
// mode is the dangerous one: swagger-autogen reports success and emits a spec
|
||||
// with the routes simply absent (§6.1, and core hit it twice — the spike's atlas
|
||||
// paths and PR 4's 407 deleted lines).
|
||||
//
|
||||
// ── Where the prefixes come from ───────────────────────────────────────────
|
||||
//
|
||||
// swagger-autogen is pointed at one router file at a time, so its paths come out
|
||||
// relative to that router (`/status`, not `/api/v1/public/shard/status`) — nothing
|
||||
// in the file says where it hangs. §6.1a requires fully-qualified paths, because
|
||||
// core merges the fragment verbatim and never re-derives a prefix.
|
||||
//
|
||||
// So this script **runs the module's own `register()`** against a recording `api`
|
||||
// and reads the mounts back out of it. The prefix of every router is therefore the
|
||||
// prefix that router is actually registered under — the same call an operator's
|
||||
// core will make, not a table beside it that drifts the first time a mount moves.
|
||||
// Which router a recorded object came from is answered by `require.cache`: the
|
||||
// file whose `module.exports` IS this router.
|
||||
//
|
||||
// The two things that cannot be derived here are the tier base paths and the
|
||||
// extension slot's mount, because they are core's, not ours. They are §2.4's
|
||||
// normative table, quoted below — and they are not taken on trust: the frozen
|
||||
// route manifest (`scripts/frozenManifest.js`) generates the real URLs from a real
|
||||
// core with this module loaded, and fails if a fragment path is not among them.
|
||||
// That check is where a wrong constant here dies.
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
|
||||
|
||||
const { fakeCtx, fakeApi } = require('../test/_fakes')
|
||||
const doc = require('../swagger/doc')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
||||
|
||||
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
|
||||
// router in core, behind its gate; the tier's own base path is core's and fixed
|
||||
// by §1.2's frozen URL surface.
|
||||
const TIER_BASE = {
|
||||
public: '/api/v1/public',
|
||||
admin: '/api/v1/admin',
|
||||
player: '/api/v1/player',
|
||||
}
|
||||
|
||||
// MODULE_API.md §2.4's slot table. Exactly one slot exists in v1, and only core
|
||||
// may declare one — so a module filling it has to be told where it landed.
|
||||
const SLOT_MOUNT = {
|
||||
'admin.users.detail': '/api/v1/admin/users/:id',
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `register()` with a recording api and return `[{ file, prefix }]`.
|
||||
*
|
||||
* The ctx is the test fakes' — the same one the suite proves the module runs
|
||||
* against — because registration must not touch a database (§2.2 rule 1) and this
|
||||
* script is exactly the kind of no-database caller that rule exists for.
|
||||
*/
|
||||
function mountedRouters() {
|
||||
const register = require('../index')
|
||||
const api = fakeApi()
|
||||
register(fakeCtx(), api)
|
||||
|
||||
const fileOf = (router) => {
|
||||
for (const mod of Object.values(require.cache)) {
|
||||
if (mod && mod.exports === router) return mod.filename
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const mounts = []
|
||||
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
|
||||
const base = TIER_BASE[tier]
|
||||
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
|
||||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
|
||||
}
|
||||
}
|
||||
for (const { slot, router } of api.record.extensions) {
|
||||
const mount = SLOT_MOUNT[slot]
|
||||
if (!mount) throw new Error(`swagger: filled slot "${slot}", which §2.4's table does not list`)
|
||||
mounts.push({ router, prefix: mount, what: `slot ${slot}` })
|
||||
}
|
||||
|
||||
return mounts.map(({ router, prefix, what }) => {
|
||||
const file = fileOf(router)
|
||||
if (!file) {
|
||||
// A router built inline in index.js rather than required from its own file.
|
||||
// swagger-autogen needs a file to read, so there is nothing to generate from.
|
||||
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
|
||||
}
|
||||
return { file, prefix, what }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run swagger-autogen over one router file. Paths come out router-relative.
|
||||
*
|
||||
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
|
||||
* `console.error`s "Syntax error" or "out of structure", drops that one
|
||||
* annotation, and prints `Success` in green. Four of the annotations that came
|
||||
* across in slice 1 were broken that way and had been for as long as they had
|
||||
* existed in core: two `requestBody` literals a brace short, and two descriptions
|
||||
* whose inner quoting the tool cannot survive (it re-quotes `"` and a backtick to
|
||||
* `'` before evaluating, so either inside a single-quoted description ends the
|
||||
* string early). The visible result was a documented route missing its body, or a
|
||||
* typed query parameter demoted to an untyped one.
|
||||
*
|
||||
* So its diagnostics are captured and made fatal. This is the same class as every
|
||||
* other failure in this seam — a generator that reports success while silently
|
||||
* dropping what it was asked to describe (§6.1) — and the only difference is that
|
||||
* here the tool does say something. Nothing was listening.
|
||||
*/
|
||||
async function fragmentFor(file) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'uo-swagger-'))
|
||||
const out = path.join(dir, 'fragment.json')
|
||||
|
||||
const complaints = []
|
||||
const realError = console.error
|
||||
console.error = (...args) => {
|
||||
const line = args.map(String).join(' ')
|
||||
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
|
||||
else realError(...args)
|
||||
}
|
||||
try {
|
||||
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
|
||||
// renders `components.schemas` from an EXAMPLE object rather than treating it
|
||||
// as OpenAPI — `{ type: 'object' }` comes back as `{ type: 'object',
|
||||
// properties: { type: { type: 'string', example: 'object' } } }`, a
|
||||
// meta-description of itself. That shape is uniform across core's committed
|
||||
// spec and is the house shape, so it is matched rather than fought. What is
|
||||
// NOT survivable is that it writes the result back into the object it was
|
||||
// handed: reusing one `doc` across six routers re-wraps the previous pass's
|
||||
// output five more times, and the fragment came out at 484 MB.
|
||||
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
|
||||
...JSON.parse(JSON.stringify(doc)),
|
||||
info: { title: 'module-uo fragment', version: '0' },
|
||||
})
|
||||
} finally {
|
||||
console.error = realError
|
||||
}
|
||||
if (complaints.length > 0) {
|
||||
throw new Error(
|
||||
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
|
||||
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
return fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-root a router-relative fragment under the prefix it is mounted at.
|
||||
*
|
||||
* Express path params (`:id`) become OpenAPI's (`{id}`), and the prefix's own
|
||||
* params are moved to the FRONT of each operation's parameter list: swagger-autogen
|
||||
* orders parameters by where they appeared in the path it saw, which was only the
|
||||
* tail, so `/{id}/shard/link/{account}` would otherwise document (account, id).
|
||||
*/
|
||||
function prefixPaths(fragment, prefix) {
|
||||
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
|
||||
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
|
||||
const paths = {}
|
||||
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||
for (const operation of Object.values(item)) {
|
||||
const params = operation && operation.parameters
|
||||
if (!Array.isArray(params)) continue
|
||||
const rank = (q) => {
|
||||
const i = outer.indexOf(q && q.name)
|
||||
return i === -1 ? outer.length : i
|
||||
}
|
||||
operation.parameters = params
|
||||
.map((q, i) => ({ q, i }))
|
||||
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
|
||||
.map(({ q }) => q)
|
||||
}
|
||||
// `router.get('/')` under a prefix concatenates to `/api/v1/public/shard/`,
|
||||
// a URL no client calls. Core's swagger.js normalizes the same way.
|
||||
paths[`${oas}${p}`.replace(/\/$/, '')] = item
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the whole fragment: every mounted router, re-rooted and merged.
|
||||
*
|
||||
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a allows
|
||||
* a fragment to carry. `info`, `servers` and the security schemes are the merged
|
||||
* document's, which is to say core's.
|
||||
*/
|
||||
async function build() {
|
||||
const spec = { paths: {}, tags: [], components: { schemas: {} } }
|
||||
let shared = false
|
||||
|
||||
for (const { file, prefix, what } of mountedRouters()) {
|
||||
const generated = await fragmentFor(file)
|
||||
// The tags and schemas are the SAME on every pass — each was handed the same
|
||||
// `doc` — so they are taken from whichever ran first rather than from `doc`
|
||||
// itself. What lands in the fragment has to be what swagger-autogen produced,
|
||||
// not what it was given: those two differ (see fragmentFor), and core merges
|
||||
// this file verbatim into a spec whose own schemas went through the same mill.
|
||||
if (!shared) {
|
||||
spec.tags = generated.tags || []
|
||||
spec.components.schemas = (generated.components || {}).schemas || {}
|
||||
shared = true
|
||||
}
|
||||
const paths = prefixPaths(generated, prefix)
|
||||
const count = Object.keys(paths).length
|
||||
if (count === 0) {
|
||||
// An empty fragment is precisely what the silent drop looks like, so it is
|
||||
// a hard failure rather than a router that happens to declare no routes.
|
||||
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
|
||||
}
|
||||
for (const [p, item] of Object.entries(paths)) {
|
||||
if (spec.paths[p]) {
|
||||
throw new Error(`swagger: two of this module's routers both document ${p}`)
|
||||
}
|
||||
spec.paths[p] = item
|
||||
}
|
||||
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix} ← ${what}\n`)
|
||||
}
|
||||
|
||||
// Sorted, for the reason core sorts: swagger-autogen emits router-traversal
|
||||
// order, so moving a route between files would rewrite most of this committed
|
||||
// artifact even when the API is provably unchanged.
|
||||
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
|
||||
return spec
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const check = process.argv.includes('--check')
|
||||
const spec = await build()
|
||||
const json = `${JSON.stringify(spec, null, 2)}\n`
|
||||
|
||||
if (!check) {
|
||||
fs.writeFileSync(FRAGMENT, json)
|
||||
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)} — ${Object.keys(spec.paths).length} paths\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(FRAGMENT)) {
|
||||
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
if (fs.readFileSync(FRAGMENT, 'utf8') !== json) {
|
||||
process.stderr.write(
|
||||
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
|
||||
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
|
||||
'so a stale one documents a URL surface this module does not serve.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`${err.stack}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, SLOT_MOUNT, FRAGMENT }
|
||||
885
server/swagger/doc.js
Normal file
885
server/swagger/doc.js
Normal file
@@ -0,0 +1,885 @@
|
||||
// ── module-uo's OpenAPI fragment: the shared half ──────────────────────────
|
||||
//
|
||||
// The tags and component schemas every `#swagger.*` annotation under
|
||||
// `server/router/**` refers to. `scripts/swaggerFragment.js` feeds this to
|
||||
// swagger-autogen; the per-endpoint detail lives beside each route, exactly as
|
||||
// it does in core.
|
||||
//
|
||||
// These 31 schemas were core's until phase 3 — they sat in
|
||||
// `website/server/swagger/swagger.js` describing routes core no longer serves,
|
||||
// which is what an extraction leaves behind if nobody looks (the inert-leaf
|
||||
// class slice 4 found in `api/client.js`). They moved with the routes.
|
||||
//
|
||||
// **Two rules about names, and both are the merged document's, not this file's**
|
||||
// (docs/website/MODULE_API.md §6.1a):
|
||||
//
|
||||
// • **What this module DEFINES is namespaced `Uo…`.** Core merges started
|
||||
// modules' fragments into one `/api/docs.json`, and core wins every key
|
||||
// collision — so an un-namespaced `ShardStatus` from a second game's module
|
||||
// would silently lose to, or clobber, this one. The prefix is what makes two
|
||||
// modules able to describe the same idea.
|
||||
// • **What core defines is referenced by CORE's name.** The annotations point
|
||||
// at `#/components/schemas/Error` and `ValidationError` and this file does
|
||||
// not redefine them: they resolve in the merged spec, where core's
|
||||
// definitions are. Shipping our own copy would be a collision core drops,
|
||||
// which is the correct outcome arrived at the expensive way.
|
||||
//
|
||||
// Tag NAMES are core's originals (`Public · Shard`, not `Uo · Shard`). A tag is
|
||||
// how the docs UI groups operations, and core stopped declaring these four in
|
||||
// the same slice this file started — nothing collides, and renaming them would
|
||||
// churn every reader's bookmark for no gain.
|
||||
|
||||
module.exports = {
|
||||
tags: [
|
||||
{ name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' },
|
||||
{ name: 'Public · Atlas', description: 'Spawn atlas / bestiary — static shard content parsed from the shard\'s own ServUO tree, independent of the sidecar' },
|
||||
{ name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' },
|
||||
{ name: 'Admin · Shard', description: 'uo-link sidecar connection config, live status and town crier (admin only)' },
|
||||
],
|
||||
components: {
|
||||
schemas: {
|
||||
// ── uo-link shard data ──────────────────────────────────────────────
|
||||
UoShardStatus: {
|
||||
type: 'object',
|
||||
description: 'Public shard status (GET /public/shard/status).',
|
||||
properties: {
|
||||
enabled: { type: 'boolean', example: true },
|
||||
status: { type: 'string', example: 'connected', description: 'connected | reconnecting | disconnected | error' },
|
||||
pluginConnected: { type: 'boolean', description: 'Is the shard link up right now?', example: true },
|
||||
lastEventAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
onlineCount: { type: 'integer', example: 12 },
|
||||
economy: { $ref: '#/components/schemas/UoShardEconomyPoint' },
|
||||
},
|
||||
},
|
||||
UoShardEvent: {
|
||||
type: 'object',
|
||||
description: 'A logged shard event.',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 4821 },
|
||||
kind: { type: 'string', example: 'vendor.sale' },
|
||||
t: { type: 'integer', description: 'Event time, epoch ms.', example: 1783720195626 },
|
||||
bootId: { type: 'string', nullable: true, example: 'boot-abc123' },
|
||||
payload: { type: 'object', additionalProperties: true, description: 'The full event object.' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
UoShardEconomyPoint: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'One gold-supply sample.',
|
||||
properties: {
|
||||
accounts: { type: 'integer', nullable: true, example: 240 },
|
||||
gold: { type: 'integer', nullable: true, example: 1028983421 },
|
||||
t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 },
|
||||
},
|
||||
},
|
||||
UoShardOnlinePlayer: {
|
||||
type: 'object',
|
||||
description: 'A LINKED player online now (only accounts linked to a website user are listed).',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x24C' },
|
||||
name: { type: 'string', example: 'Darrow' },
|
||||
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||
x: { type: 'integer', nullable: true, example: 1402 },
|
||||
y: { type: 'integer', nullable: true, example: 1604 },
|
||||
z: { type: 'integer', nullable: true, example: 0 },
|
||||
},
|
||||
},
|
||||
UoShardVendorSale: {
|
||||
type: 'object',
|
||||
description: 'A player-vendor sale (visible only to the linked owner).',
|
||||
properties: {
|
||||
t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 },
|
||||
itemType: { type: 'string', example: 'Longsword' },
|
||||
amount: { type: 'integer', example: 1 },
|
||||
price: { type: 'integer', example: 100 },
|
||||
commission: { type: 'integer', nullable: true, example: 5 },
|
||||
ownerAcct: { type: 'string', example: 'whitlocktech' },
|
||||
},
|
||||
},
|
||||
UoShardHouse: {
|
||||
type: 'object',
|
||||
description: 'A house at its current decay stage.',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x4004705F' },
|
||||
stage: { type: 'string', example: 'IDOC' },
|
||||
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||
x: { type: 'integer', nullable: true },
|
||||
y: { type: 'integer', nullable: true },
|
||||
z: { type: 'integer', nullable: true },
|
||||
region: { type: 'string', nullable: true },
|
||||
name: { type: 'string', nullable: true, example: 'An Unnamed House' },
|
||||
ownerSerial: { type: 'string', nullable: true },
|
||||
ownerAcct: { type: 'string', nullable: true },
|
||||
builtOn: { type: 'string', format: 'date-time', nullable: true },
|
||||
lastRefreshed: { type: 'string', format: 'date-time', nullable: true },
|
||||
isIdoc: { type: 'boolean', example: true },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
UoShardPointsBoard: {
|
||||
type: 'object',
|
||||
description:
|
||||
"One point system's leaderboard (Protocol 3.0 points.board). The shard carries ~25 separate point currencies; each publishes its own board. The display name may arrive as a literal string, a cliloc id, or both — resolve clilocs client-side.",
|
||||
properties: {
|
||||
system: { type: 'string', example: 'QueensLoyalty', description: "The shard's PointsType name; the board's stable key." },
|
||||
nameString: { type: 'string', nullable: true, example: "Queen's Loyalty" },
|
||||
nameNumber: { type: 'integer', nullable: true, example: 1114938, description: 'Cliloc id, 0 when the name is a literal.' },
|
||||
maxPoints: { type: 'integer', nullable: true, example: 30000 },
|
||||
players: { type: 'integer', nullable: true, example: 842, description: 'Players actually holding points in this system.' },
|
||||
showOnGump: { type: 'boolean', example: true, description: "The shard's own 'is this player-facing?' flag." },
|
||||
top: {
|
||||
type: 'array',
|
||||
description: 'The ranked players, best first. Capped by the shard (10 by default). Empty when nobody has scored yet.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rank: { type: 'integer', example: 1 },
|
||||
serial: { type: 'string', example: '0x1A2B' },
|
||||
name: { type: 'string', example: 'Darrow', description: 'Omitted when the leaderboards `name` field is gated above the caller.' },
|
||||
points: { type: 'integer', example: 29500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
t: { type: 'integer', nullable: true, description: 'Frame time, epoch ms.' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
UoShardMarketLocation: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description:
|
||||
"Where a vendor is standing. ONE nested object rather than flat map/x/y/region because it is one admin-configurable field (`market.location`) — the whole object is omitted when that field is gated above the caller.",
|
||||
properties: {
|
||||
map: { type: 'string', nullable: true, example: 'Trammel' },
|
||||
x: { type: 'integer', nullable: true, example: 1421 },
|
||||
y: { type: 'integer', nullable: true, example: 1699 },
|
||||
z: { type: 'integer', nullable: true, example: 0 },
|
||||
region: { type: 'string', nullable: true, example: 'Britain' },
|
||||
house: { type: 'string', nullable: true, example: "Darrow's Villa", description: "The house SIGN's name, not the house type. Null for a vendor standing outside one." },
|
||||
},
|
||||
},
|
||||
UoShardMarketListing: {
|
||||
type: 'object',
|
||||
description:
|
||||
'One priced listing on a player vendor, carrying enough of its shop to be actionable without a second request.',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x40012ABC' },
|
||||
itemId: { type: 'integer', example: 3922, description: 'ItemID (the art/graphic id).' },
|
||||
hue: { type: 'integer', example: 0 },
|
||||
amount: { type: 'integer', example: 1 },
|
||||
price: { type: 'integer', example: 25000 },
|
||||
name: { type: 'string', nullable: true, description: "The item's own literal name, set by a player. Null for most items." },
|
||||
cliloc: { type: 'integer', nullable: true, example: 1023721, description: "The item's LabelNumber." },
|
||||
displayName: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
example: 'quarter staff',
|
||||
description: 'Resolved server-side from `name` (preferred, being player-set and more specific) else `cliloc`. Null on a shard with no cliloc table configured — render the item id.',
|
||||
},
|
||||
child: { type: 'boolean', example: false, description: 'Priced by an enclosing container rather than itself, exactly as the in-game Vendor Search reports it.' },
|
||||
vendor: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x40001234' },
|
||||
shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" },
|
||||
ownerSerial: { type: 'string', nullable: true, example: '0x1A2B', description: 'Omitted when the market `ownerSerial` field is gated above the caller.' },
|
||||
ownerName: { type: 'string', nullable: true, example: 'Darrow', description: 'Omitted when the market `ownerName` field is gated above the caller.' },
|
||||
location: { $ref: '#/components/schemas/UoShardMarketLocation' },
|
||||
updatedAt: { type: 'string', format: 'date-time', description: 'When the shard last published this shop.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UoShardMarketPage: {
|
||||
type: 'object',
|
||||
description: 'A page of marketplace listings plus the unpaginated total and the staleness stamp.',
|
||||
properties: {
|
||||
listings: { type: 'array', items: { $ref: '#/components/schemas/UoShardMarketListing' } },
|
||||
total: { type: 'integer', example: 1284, description: 'Matching listings, ignoring paging.' },
|
||||
limit: { type: 'integer', example: 50 },
|
||||
offset: { type: 'integer', example: 0 },
|
||||
vendors: { type: 'integer', example: 137, description: 'Vendors in the whole index.' },
|
||||
staleAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
description: 'The OLDEST vendor row. The shard sweeps vendors round-robin, so the index can be a full cycle behind and a client must say so rather than implying live prices.',
|
||||
},
|
||||
},
|
||||
},
|
||||
UoShardMarketVendor: {
|
||||
type: 'object',
|
||||
description: 'One player vendor and its listings.',
|
||||
properties: {
|
||||
serial: { type: 'string', example: '0x40001234' },
|
||||
shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" },
|
||||
ownerSerial: { type: 'string', nullable: true },
|
||||
ownerName: { type: 'string', nullable: true, example: 'Darrow' },
|
||||
location: { $ref: '#/components/schemas/UoShardMarketLocation' },
|
||||
count: { type: 'integer', example: 250, description: 'Listings the shard published for this shop.' },
|
||||
total: { type: 'integer', example: 3104, description: 'Listings the shop actually holds.' },
|
||||
truncated: { type: 'boolean', example: true, description: '`total` exceeds `count` — the shop holds more than the shard publishes per frame.' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
items: { type: 'array', items: { $ref: '#/components/schemas/UoShardMarketListing' } },
|
||||
},
|
||||
},
|
||||
UoShardMarketMeta: {
|
||||
type: 'object',
|
||||
description: 'Marketplace size, staleness and the filter options a client needs to build its UI.',
|
||||
properties: {
|
||||
vendors: { type: 'integer', example: 137 },
|
||||
items: { type: 'integer', example: 18422 },
|
||||
staleAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
freshAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
maps: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Trammel'], description: "Facets that actually hold vendors. From the shard's own data — never a hardcoded list." },
|
||||
regions: { type: 'array', items: { type: 'string' }, example: ['Britain', 'Luna'] },
|
||||
},
|
||||
},
|
||||
UoShardFeatures: {
|
||||
type: 'object',
|
||||
description:
|
||||
"The shard features the caller may reach, plus the audience rung they resolved to. Drives client nav so it never renders a link that would 403.",
|
||||
properties: {
|
||||
level: {
|
||||
type: 'string',
|
||||
enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
|
||||
example: 'anonymous',
|
||||
},
|
||||
features: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['status', 'activity', 'champs', 'guilds', 'governors', 'houses', 'presence'],
|
||||
},
|
||||
},
|
||||
},
|
||||
UoShardFeatureVisibility: {
|
||||
type: 'object',
|
||||
description: 'Visibility settings for one shard feature.',
|
||||
properties: {
|
||||
enabled: { type: 'boolean', example: true },
|
||||
audience: {
|
||||
type: 'string',
|
||||
enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
|
||||
description: 'Minimum rung that may reach this feature. Each rung implies the ones below it.',
|
||||
example: 'anonymous',
|
||||
},
|
||||
stream: {
|
||||
type: 'boolean',
|
||||
description: "Whether this feature's event kinds fan out over SSE at all.",
|
||||
example: true,
|
||||
},
|
||||
fieldRules: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description:
|
||||
'Per-field rung overrides for the sensitive fields this feature exposes. acct / webId are admin-only always and are rejected here.',
|
||||
example: { location: 'staff' },
|
||||
},
|
||||
},
|
||||
},
|
||||
UoShardVisibilityConfig: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ladder: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
example: ['anonymous', 'logged_in', 'player', 'staff', 'admin'],
|
||||
},
|
||||
lockedFields: { type: 'array', items: { type: 'string' }, example: ['acct', 'webId'] },
|
||||
defaults: {
|
||||
type: 'object',
|
||||
additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' },
|
||||
},
|
||||
features: {
|
||||
type: 'object',
|
||||
additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' },
|
||||
},
|
||||
},
|
||||
},
|
||||
UoShardVisibilityUpdate: {
|
||||
type: 'object',
|
||||
required: ['features'],
|
||||
properties: {
|
||||
features: {
|
||||
type: 'object',
|
||||
additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' },
|
||||
example: { market: { enabled: true, audience: 'player', stream: false, fieldRules: { ownerName: 'player' } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
// ── Spawn atlas (Protocol 3.0 Part C) ────────────────────────────────
|
||||
// Static shard content, parsed from the shard's own ServUO tree. Nothing
|
||||
// here comes from the sidecar, so it stays populated while the shard is
|
||||
// down. Facet names are whatever the shard's files declare — the examples
|
||||
// below are stock ServUO, not a fixed list.
|
||||
UoAtlasCreature: {
|
||||
type: 'object',
|
||||
description: 'A creature in the bestiary. `places`/`points`/`alsoHere` are present only on the single-creature route.',
|
||||
properties: {
|
||||
slug: { type: 'string', example: 'lizardman' },
|
||||
name: { type: 'string', example: 'Lizardman' },
|
||||
total: { type: 'integer', description: 'How many can be alive at once, summed across every spawner.', example: 214 },
|
||||
points: { type: 'integer', description: 'How many spawners mention this creature.', example: 62 },
|
||||
facets: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'integer' },
|
||||
description: "This creature's share per facet.",
|
||||
example: { Felucca: 96, Trammel: 88, Tokuno: 30 },
|
||||
},
|
||||
art: { type: 'string', nullable: true, description: 'Operator-supplied art under uploads/atlas/. NULL on a fresh import — the repo ships no creature art.' },
|
||||
places: {
|
||||
type: 'array',
|
||||
description: 'Where it spawns, aggregated by resolved place. The answer the atlas exists to give.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
facet: { type: 'string', example: 'Trammel' },
|
||||
label: { type: 'string', description: 'Resolved region, else nearest landmark group, else "Wilderness".', example: 'Shrines' },
|
||||
spawners: { type: 'integer', example: 7 },
|
||||
maxAlive: { type: 'integer', example: 21 },
|
||||
},
|
||||
},
|
||||
},
|
||||
spawners: {
|
||||
type: 'array',
|
||||
description: 'The individual spawners. Named separately from `points` (the count) so one key never means two things.',
|
||||
items: { $ref: '#/components/schemas/UoAtlasSpawner' },
|
||||
},
|
||||
spawnersTruncated: { type: 'boolean', description: 'True when the spawner list was cut at the requested bound.', example: false },
|
||||
alsoHere: {
|
||||
type: 'array',
|
||||
description: 'Creatures sharing a spawner with this one.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slug: { type: 'string', example: 'lizardman-warrior' },
|
||||
name: { type: 'string', example: 'Lizardman Warrior' },
|
||||
shared: { type: 'integer', example: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UoAtlasSpawner: {
|
||||
type: 'object',
|
||||
description: 'One ServUO spawner, with the place its coordinates resolved to.',
|
||||
properties: {
|
||||
id: { type: 'integer' },
|
||||
facet: { type: 'string', example: 'Felucca' },
|
||||
name: { type: 'string', nullable: true, description: "The spawner's own name in the ServUO file." },
|
||||
x: { type: 'integer', example: 5411 },
|
||||
y: { type: 'integer', example: 1234 },
|
||||
width: { type: 'integer' },
|
||||
height: { type: 'integer' },
|
||||
range: { type: 'integer', description: 'Spawn radius.' },
|
||||
maxCount: { type: 'integer', description: 'How many of THIS creature this spawner keeps alive.', example: 3 },
|
||||
minDelay: { type: 'integer', description: 'Respawn window, in SECONDS. Normalised at parse time — the source stores minutes or seconds per record, decided by its own DelayInSec flag.', example: 300 },
|
||||
maxDelay: { type: 'integer', example: 600 },
|
||||
todStart: { type: 'integer', description: 'Meaningless unless todMode is non-zero.' },
|
||||
todEnd: { type: 'integer' },
|
||||
todMode: { type: 'integer' },
|
||||
region: { type: 'string', nullable: true, example: 'Despise' },
|
||||
landmark: { type: 'string', nullable: true, example: 'Covetous' },
|
||||
label: { type: 'string', description: 'Region, else landmark group, else "Wilderness".', example: 'Despise' },
|
||||
},
|
||||
},
|
||||
UoAtlasCreaturePage: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: { type: 'integer', description: 'Matching creatures before pagination.', example: 800 },
|
||||
limit: { type: 'integer', example: 50 },
|
||||
offset: { type: 'integer', example: 0 },
|
||||
creatures: { type: 'array', items: { $ref: '#/components/schemas/UoAtlasCreature' } },
|
||||
},
|
||||
},
|
||||
UoAtlasRegion: {
|
||||
type: 'object',
|
||||
description: 'A named region, flattened out of the shard\'s nested Regions.xml.',
|
||||
properties: {
|
||||
facet: { type: 'string', example: 'Felucca' },
|
||||
name: { type: 'string', example: 'Despise' },
|
||||
type: { type: 'string', nullable: true, description: 'ServUO region class.', example: 'DungeonRegion' },
|
||||
priority: { type: 'integer', example: 50 },
|
||||
parent: { type: 'string', nullable: true, example: 'Britain' },
|
||||
rects: {
|
||||
type: 'array',
|
||||
description: 'The rectangles that placed each spawn point.',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
UoAtlasLandmark: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
facet: { type: 'string', example: 'Trammel' },
|
||||
name: { type: 'string', example: 'Level 1' },
|
||||
group: { type: 'string', nullable: true, description: 'Innermost enclosing parent — the label worth showing.', example: 'Covetous' },
|
||||
x: { type: 'integer', example: 5411 },
|
||||
y: { type: 'integer', example: 1234 },
|
||||
z: { type: 'integer', example: 0 },
|
||||
},
|
||||
},
|
||||
UoAtlasChampion: {
|
||||
type: 'object',
|
||||
description: 'A CONFIGURED champion altar. Not the live board — see GET /public/shard/champs for that.',
|
||||
properties: {
|
||||
slug: { type: 'string', example: 'felucca-deceit' },
|
||||
name: { type: 'string', example: 'Deceit' },
|
||||
group: { type: 'string', nullable: true, description: 'Spawn group; one altar active per group.', example: 'Dungeons' },
|
||||
type: { type: 'string', nullable: true, description: 'NULL when the champion is drawn at activation.', example: 'UnholyTerror' },
|
||||
randomType: { type: 'boolean', example: false },
|
||||
facet: { type: 'string', example: 'Felucca' },
|
||||
x: { type: 'integer' },
|
||||
y: { type: 'integer' },
|
||||
z: { type: 'integer' },
|
||||
radius: { type: 'integer', example: 60 },
|
||||
label: { type: 'string', nullable: true, example: 'Deceit' },
|
||||
},
|
||||
},
|
||||
UoAtlasMeta: {
|
||||
type: 'object',
|
||||
description: 'What atlas is loaded. Game-world facts only: the ServUO path, source hashes and any pending refresh are operator detail and live on the admin status route.',
|
||||
properties: {
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
generatedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
counts: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
additionalProperties: true,
|
||||
example: { facets: 6, points: 6455, creatures: 800, regions: 387, landmarks: 558, champions: 25, unresolvedPoints: 1086 },
|
||||
},
|
||||
facets: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Ilshenar', 'Malas', 'TerMur', 'Tokuno', 'Trammel'] },
|
||||
},
|
||||
},
|
||||
UoAtlasStatus: {
|
||||
type: 'object',
|
||||
description: 'Admin view of atlas state: which source the tree comes from, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.',
|
||||
properties: {
|
||||
configured: { type: 'boolean', example: true },
|
||||
source: {
|
||||
type: 'string',
|
||||
enum: ['bridge', 'fs'],
|
||||
description: '`bridge`: the shard serves its own configuration files over uo-link (protocol 8 phase 7, the normal case once a shard is linked). `fs`: a ServUO tree the website can read directly — development and same-host installs, and the only source where boot re-imports by itself.',
|
||||
example: 'bridge',
|
||||
},
|
||||
path: { type: 'string', description: 'The local tree path, or `the shard bridge` when that is the source.', example: 'the shard bridge' },
|
||||
treeReadable: { type: 'boolean', example: true },
|
||||
drift: { type: 'boolean', nullable: true, description: 'True when the source file hashes differ from the loaded atlas. NULL when the source could not be read. On the bridge this is answered from the shard\'s file MANIFEST — hashes only, no file bytes.', example: false },
|
||||
facets: { type: 'array', items: { type: 'string' } },
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
counts: { type: 'object', nullable: true, additionalProperties: true },
|
||||
pending: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'A refresh that was parsed but NOT applied because it would remove a facet. `status` is pending or rejected.',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
UoAtlasRefreshResult: {
|
||||
type: 'object',
|
||||
description: 'Outcome of a refresh. Reported rather than thrown, so an unreadable tree is an answer and not a 500.',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed', 'rejected', 'none'],
|
||||
example: 'imported',
|
||||
},
|
||||
reason: { type: 'string', nullable: true },
|
||||
source: {
|
||||
type: 'string',
|
||||
enum: ['bridge', 'fs'],
|
||||
nullable: true,
|
||||
description: 'Which end this attempt read from. Absent only on `skipped`, where there was no source at all.',
|
||||
example: 'bridge',
|
||||
},
|
||||
path: { type: 'string', nullable: true, description: 'The local tree path, or `the shard bridge`.' },
|
||||
code: { type: 'string', nullable: true, description: 'On `unavailable`: NO_PATH, NOT_FOUND, NO_REGIONS or NO_SPAWNS from a local tree; DISABLED, SOURCE_CHANGED, INCOMPLETE, MALFORMED, BUSY, SHARD_DOWN or TOO_LARGE from the bridge.' },
|
||||
counts: { type: 'object', nullable: true, additionalProperties: true },
|
||||
addedFacets: { type: 'array', items: { type: 'string' } },
|
||||
removedFacets: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
UoClilocStatus: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Admin view of cliloc state: which source the base table comes from, whether it can be read, how many entries are loaded, and whether anything has drifted from them. Nothing configured at all is a supported state — item names then render as ids.',
|
||||
properties: {
|
||||
source: {
|
||||
type: 'string',
|
||||
enum: ['bridge', 'file'],
|
||||
description: '`bridge`: the shard reads its own UO client (protocol 8, the normal case). `file`: a converted file on disk — the pre-protocol-8 pipeline, deprecated, kept for installs with no shard link.',
|
||||
example: 'bridge',
|
||||
},
|
||||
configured: { type: 'boolean', example: true },
|
||||
path: { type: 'string', description: 'On the bridge: where `custom/` overlays are read from. On a file source: the base path too.', example: '/srv/uo-client' },
|
||||
file: { type: 'string', nullable: true, description: 'The base file in use — the shard’s own `cliloc.enu` on the bridge, the resolved local file otherwise.', example: 'cliloc.enu' },
|
||||
fileReadable: { type: 'boolean', example: true },
|
||||
problem: { type: 'string', nullable: true, description: 'Why the base cannot be used, when it cannot: a shard that is down or has assets switched off, or (on a file source) a missing or still-compressed file.', example: null },
|
||||
code: { type: 'string', nullable: true, description: 'Machine-readable cause of `problem`.', enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED', 'DISABLED', 'NO_SOURCE', 'SHARD_DOWN', 'PROTOCOL', 'BUSY', 'UNAVAILABLE'] },
|
||||
shard: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Present on the bridge: the shard’s own cliloc file as it is right now. `hashing: true` with a null `sha256` means the hash has not been computed yet — “ask again”, not “changed”.',
|
||||
properties: {
|
||||
size: { type: 'integer', example: 4989921 },
|
||||
mtime: { type: 'integer', description: 'Unix milliseconds.', example: 1757462400000 },
|
||||
sha256: { type: 'string', nullable: true },
|
||||
extractorVersion: { type: 'integer', description: 'The version of the shard’s extraction code. A bump makes everything derived from it drift.', example: 1 },
|
||||
hashing: { type: 'boolean', example: false },
|
||||
complete: { type: 'boolean', description: 'Every client file has a hash.', example: true },
|
||||
},
|
||||
},
|
||||
drift: { type: 'boolean', nullable: true, description: 'True when any source hash differs from the loaded table. NULL when the sources could not be read or are not usable.', example: false },
|
||||
count: { type: 'integer', description: 'Entries currently loaded.', example: 67496 },
|
||||
sources: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Every source found now, root-relative, base first then overlays in merge order.',
|
||||
example: ['custom/uomysticmoon.tsv'],
|
||||
},
|
||||
loadedSources: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'What each source contributed at the last import.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', example: 'custom/uomysticmoon.tsv' },
|
||||
kind: { type: 'string', enum: ['shard', 'base', 'custom'], description: '`shard` is the table read over the bridge; `base` a converted file on disk.', example: 'custom' },
|
||||
entries: { type: 'integer', example: 37 },
|
||||
added: { type: 'integer', description: 'Ids this source introduced.', example: 25 },
|
||||
overrode: { type: 'integer', description: 'Ids it replaced from an earlier source.', example: 12 },
|
||||
},
|
||||
},
|
||||
},
|
||||
missingSources: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Sources loaded previously and now absent. An import refuses these without `approve`.',
|
||||
example: [],
|
||||
},
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
sourceBytes: { type: 'integer', nullable: true, example: 4973525 },
|
||||
},
|
||||
},
|
||||
UoClilocRefreshResult: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Outcome of a cliloc refresh. Reported rather than thrown, so a missing or compressed file is an answer and not a 500.',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed'],
|
||||
description: '`needsReview` means a previously-loaded source has vanished and nothing was applied; re-run with `approve` to accept it.',
|
||||
example: 'imported',
|
||||
},
|
||||
reason: { type: 'string', nullable: true },
|
||||
source: { type: 'string', nullable: true, enum: ['bridge', 'file'], description: 'Which source this refresh read.', example: 'bridge' },
|
||||
code: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Machine-readable cause. Bridge codes describe the shard (`DISABLED`: the operator switched the asset plane off; `NO_SOURCE`: its client has no cliloc file; `SHARD_DOWN`; `SOURCE_CHANGED`: the client was patched mid-import, so nothing was applied). File codes describe the path — `COMPRESSED` means the client\'s own Cliloc.enu was supplied instead of a converted one.',
|
||||
enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED', 'TRUNCATED', 'EMPTY', 'NOT_BUFFER', 'DISABLED', 'NO_SOURCE', 'SHARD_DOWN', 'PROTOCOL', 'BUSY', 'UNAVAILABLE', 'SOURCE_CHANGED', 'INCOMPLETE', 'STUCK', 'MALFORMED', 'TOO_LARGE'],
|
||||
},
|
||||
path: { type: 'string', nullable: true },
|
||||
file: { type: 'string', nullable: true },
|
||||
count: { type: 'integer', nullable: true, description: 'Entries stored (blank strings are dropped).', example: 67496 },
|
||||
parsed: { type: 'integer', nullable: true, description: 'Entries read across every source before blanks were dropped.', example: 123527 },
|
||||
blank: { type: 'integer', nullable: true, example: 0 },
|
||||
pages: { type: 'integer', nullable: true, description: 'Bridge only: how many pages the table arrived in (a stock English table is about eleven).', example: 11 },
|
||||
reported: { type: 'integer', nullable: true, description: 'Bridge only: how many rows the shard said it holds.', example: 67496 },
|
||||
received: { type: 'integer', nullable: true, description: 'Bridge only: how many arrived. Disagreeing with `reported` means the walk is wrong.', example: 67496 },
|
||||
overlayProblem: { type: 'string', nullable: true, description: 'The base imported, but the overlay directory could not be read. Reported rather than fatal.' },
|
||||
sources: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'Per-source breakdown: what each file contributed and how much of it overrode an earlier source.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string' },
|
||||
kind: { type: 'string', enum: ['shard', 'base', 'custom'] },
|
||||
entries: { type: 'integer' },
|
||||
added: { type: 'integer' },
|
||||
overrode: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
missingSources: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: { type: 'string' },
|
||||
description: 'On `needsReview`: the sources that vanished. Nothing was applied.',
|
||||
},
|
||||
acceptedMissing: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: { type: 'string' },
|
||||
description: 'On `imported` with `approve`: the vanished sources the admin accepted.',
|
||||
},
|
||||
},
|
||||
},
|
||||
UoAssetStatus: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Admin view of the client-asset import (docs/link/v8.md §6, §8). What the site holds beside what the shard’s UO client currently is. Holding nothing at all is a supported state — creature pages simply render without pictures, which is what every install did before this pipeline existed.',
|
||||
properties: {
|
||||
linked: {
|
||||
type: 'boolean',
|
||||
description: 'Whether a shard is configured and enabled at all. Stated rather than inferred: `shard: null` is also what a linked shard that is merely DOWN looks like, and the two want opposite things from an admin surface — one disables its import buttons, the other keeps them available so the operator can retry.',
|
||||
example: true,
|
||||
},
|
||||
loaded: {
|
||||
type: 'object',
|
||||
description: 'What this site currently holds.',
|
||||
properties: {
|
||||
assets: { type: 'integer', description: 'Rows in the imported catalogue.', example: 787 },
|
||||
stored: { type: 'integer', description: 'How many of those have a picture on disk. Lower than `assets` when the shard listed a key it could not render.', example: 787 },
|
||||
creatures: { type: 'integer', description: 'Atlas creatures the shard has answered a body question about, resolved or not.', example: 812 },
|
||||
resolved: { type: 'integer', description: 'How many of those resolved to a body id. The rest are types this shard’s scripts do not define, or spawn entries naming an item rather than a creature.', example: 780 },
|
||||
catalog: { type: 'string', nullable: true, description: 'The shard’s catalogue id at the last import — derived from its client files, so it changes exactly when they do.', example: 'a3f9c21d4b8e0771' },
|
||||
extractorVersion: { type: 'integer', nullable: true, description: 'The version of the shard’s extraction code. A bump makes every derived byte drift even though the client files did not move.', example: 1 },
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
items: { type: 'integer', description: 'Item pictures held. Unlike the catalogue this has no total to compare against: item art is fetched because something on the site names it, so this is the working set rather than a fraction of one.', example: 1840 },
|
||||
land: { type: 'integer', description: 'Land tile pictures held. Zero on every install until something asks for one.', example: 0 },
|
||||
last: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'What the last import actually did. NULL on an install that has never imported, and on one whose last import predates this field — both of which mean "no import recorded", which is a different answer from an import that fetched nothing. The admin activity log records the same action, but it is one unfiltered list of every admin action on the site, so an import from three client patches ago is not findable there.',
|
||||
properties: {
|
||||
at: { type: 'string', format: 'date-time' },
|
||||
by: { type: 'string', nullable: true, description: 'The admin who pressed it, from their session.' },
|
||||
force: { type: 'boolean', description: 'True when it was a full re-import rather than an update.' },
|
||||
approve: { type: 'boolean', description: 'True when it accepted assets the shard had stopped offering.' },
|
||||
assets: { type: 'integer', example: 1095 },
|
||||
fetched: { type: 'integer', example: 12 },
|
||||
written: { type: 'integer', example: 12 },
|
||||
removed: { type: 'integer', example: 0 },
|
||||
absent: { type: 'integer', example: 0 },
|
||||
unsupported: { type: 'integer', example: 0 },
|
||||
bodies: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The body pass, as a tally rather than one number: `unknown` is real drift — a spawn file naming a type this shard’s scripts do not define — and reads identically to a failure if both are summed into "not resolved".',
|
||||
properties: {
|
||||
ok: { type: 'integer', example: 780 },
|
||||
unknown: { type: 'integer', example: 20 },
|
||||
notCreature: { type: 'integer', example: 12 },
|
||||
failed: { type: 'integer', example: 0 },
|
||||
},
|
||||
},
|
||||
art: { type: 'integer', description: 'Creatures pointing at a picture afterwards.', example: 763 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
shard: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The shard’s own client files right now. NULL when there is no shard link or it could not be reached — see `reason`.',
|
||||
properties: {
|
||||
files: { type: 'integer', description: 'How many of the animation/definition files this catalogue reads the shard actually has. Few clients carry all five anim files.', example: 9 },
|
||||
extractorVersion: { type: 'integer', example: 1 },
|
||||
hashing: { type: 'boolean', description: 'A hash is being computed in the background. A null `sha256` while this is true means “not yet”, never “changed”.', example: false },
|
||||
complete: { type: 'boolean', description: 'Every client file has a content hash.', example: true },
|
||||
imaging: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Whether the shard host can render an image at all. `ok: false` is the named NO_IMAGING state: ServUO under Mono needs libgdiplus, and without it a Linux shard cannot decode a sprite. Cliloc and atlas import are unaffected.',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
code: { type: 'string', nullable: true, example: null },
|
||||
reason: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
families: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Which asset key families this shard’s plugin overlay serves. An overlay older than phase 5 answers `["body"]` only — it has the creature catalogue and no item art.',
|
||||
example: ['body', 'land', 'static'],
|
||||
},
|
||||
},
|
||||
},
|
||||
drift: {
|
||||
type: 'boolean',
|
||||
nullable: true,
|
||||
description: 'True when the shard’s client files no longer match what was imported — press Import. NULL when they could not be read.',
|
||||
example: false,
|
||||
},
|
||||
reason: { type: 'string', nullable: true, description: 'Why the shard could not be asked, when it could not.' },
|
||||
code: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Machine-readable cause of `reason`.',
|
||||
enum: ['DISABLED', 'NO_SOURCE', 'SHARD_DOWN', 'PROTOCOL', 'BUSY', 'NO_IMAGING', 'SOURCE_CHANGED', 'UNAVAILABLE'],
|
||||
},
|
||||
},
|
||||
},
|
||||
UoAssetImportResult: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Outcome of an asset import. Reported rather than thrown, so a shard that is down or a host that cannot render images is an answer and not a 500.',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed'],
|
||||
description: '`skipped`: no shard is configured. `unchanged`: the client files match what was imported and nothing was fetched. `needsReview`: assets this site holds are no longer offered by the shard, and nothing was changed — re-run with `approve` to accept it.',
|
||||
example: 'imported',
|
||||
},
|
||||
reason: { type: 'string', nullable: true },
|
||||
code: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Machine-readable cause. `NO_IMAGING` is a shard host with no libgdiplus; `SOURCE_CHANGED` is a client patched partway through the walk, in which case nothing was applied.',
|
||||
enum: ['DISABLED', 'NO_SOURCE', 'SHARD_DOWN', 'PROTOCOL', 'BUSY', 'NO_IMAGING', 'SOURCE_CHANGED', 'INCOMPLETE', 'STUCK', 'MALFORMED', 'TOO_LARGE', 'UNAVAILABLE'],
|
||||
},
|
||||
catalog: { type: 'string', nullable: true, example: 'a3f9c21d4b8e0771' },
|
||||
extractorVersion: { type: 'integer', nullable: true, example: 1 },
|
||||
assets: { type: 'integer', nullable: true, description: 'Catalogue rows after the import.', example: 787 },
|
||||
fetched: { type: 'integer', nullable: true, description: 'How many sprites actually crossed the wire. On an Update after a client patch this is far smaller than `assets`, which is the point of the manifest.', example: 12 },
|
||||
written: { type: 'integer', nullable: true, description: 'How many were written to disk.', example: 12 },
|
||||
absent: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
description: 'Keys the shard listed but could not render. NOT a failure: this client has no art at that key, which is the expected answer for two thirds of the playable ghost and gargoyle bodies.',
|
||||
example: 0,
|
||||
},
|
||||
unsupported: { type: 'integer', nullable: true, description: 'Keys the shard does not serve at all. Unlike `absent` this indicates a bug on the site’s side, not a gap in the client.', example: 0 },
|
||||
removed: { type: 'integer', nullable: true, description: 'Assets deleted because the shard no longer offers them (only with `approve`).', example: 0 },
|
||||
scanned: { type: 'integer', nullable: true, description: 'Body ids the shard walked. Far larger than `assets` — most of the addressable range has no art.', example: 2047 },
|
||||
pages: { type: 'integer', nullable: true, description: 'Manifest pages. This family pages on the shard’s scan budget rather than on bytes, so several is normal.', example: 4 },
|
||||
playerBodies: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: { type: 'integer' },
|
||||
description: 'The body ids the shard reports as player-character bodies — every registered race’s male, female and ghost bodies, asked of the shard rather than hardcoded. These render head-on; everything else renders three-quarter.',
|
||||
example: [400, 401, 402, 403, 605, 606, 607, 608, 666, 667, 694, 695],
|
||||
},
|
||||
vanished: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
description: 'On `needsReview`: up to fifty of the keys that disappeared, each with the picture this site currently serves for it. The filename is there because the decision being asked for is "is it right that these disappear?", and an asset key names nothing a human recognises — `body/820/a23` is a horse.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string', example: 'body/820/a23' },
|
||||
file: { type: 'string', nullable: true, description: 'Filename under uploads/atlas/, or null if this site never stored a picture for it.', example: 'uo-body-820-a23-9f3c1a77.png' },
|
||||
},
|
||||
},
|
||||
},
|
||||
vanishedCount: { type: 'integer', nullable: true },
|
||||
bodies: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The slug → body id pass (§8). The shard constructs each creature and reads its body id, which is the only thing correct for a shard’s own custom creatures.',
|
||||
properties: {
|
||||
asked: { type: 'integer', example: 812 },
|
||||
answered: { type: 'integer', example: 812 },
|
||||
resolved: { type: 'integer', example: 780 },
|
||||
tally: {
|
||||
type: 'object',
|
||||
description: 'Per-outcome counts. `unknown` is real drift worth acting on — a spawn file naming a type this shard’s scripts do not define. `notCreature` is a spawn entry for an item or decoration and is permanent.',
|
||||
properties: {
|
||||
ok: { type: 'integer', example: 780 },
|
||||
unknown: { type: 'integer', example: 20 },
|
||||
notCreature: { type: 'integer', example: 12 },
|
||||
failed: { type: 'integer', example: 0 },
|
||||
},
|
||||
},
|
||||
reason: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
art: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The derivation onto `shard_spawn_creatures.art`. An operator-supplied `spawnAtlas.art.json` always wins over an imported sprite.',
|
||||
properties: {
|
||||
applied: { type: 'integer', description: 'Creatures now pointing at a picture.', example: 763 },
|
||||
derived: { type: 'integer', description: 'From the import.', example: 763 },
|
||||
operator: { type: 'integer', description: 'From the operator’s own map.', example: 0 },
|
||||
error: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UoItemArtWarmResult: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Outcome of one item-art warm pass (docs/link/v8.md §11, phase 5). Unlike the body catalogue there is no manifest and no set: the client addresses 49,152 item graphics times three thousand hues, so what gets fetched is defined by what this site’s own rows name — every distinct (ItemID, hue) on a player vendor, plus anything a character sheet has shown since the last pass. Reported rather than thrown, so a shard that is down is an answer and not a 500.',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'failed'],
|
||||
description:
|
||||
'`skipped`: no shard is configured. `unchanged`: every wanted picture is already held and current. `unavailable`: the shard could not be asked, or its plugin overlay is too old to serve item art.',
|
||||
example: 'imported',
|
||||
},
|
||||
reason: { type: 'string', nullable: true },
|
||||
code: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description:
|
||||
'Machine-readable cause. `NO_IMAGING` is a shard host with no libgdiplus. `UNSUPPORTED` is a plugin overlay that serves the creature catalogue but not item art — update the overlay.',
|
||||
enum: ['DISABLED', 'NO_SOURCE', 'SHARD_DOWN', 'PROTOCOL', 'BUSY', 'NO_IMAGING', 'UNSUPPORTED', 'INCOMPLETE', 'STUCK', 'MALFORMED', 'TOO_LARGE', 'UNAVAILABLE'],
|
||||
},
|
||||
catalog: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description:
|
||||
'The shard’s art catalogue id these pictures were fetched under — a hash of the files that decide their bytes. Stored per row, which is how staleness is answered without a manifest.',
|
||||
example: '7c1e04b9aa2f3d58',
|
||||
},
|
||||
wanted: { type: 'integer', nullable: true, description: 'Distinct keys this site’s rows name right now.', example: 1840 },
|
||||
held: { type: 'integer', nullable: true, description: 'How many of those are already stored and current.', example: 1440 },
|
||||
asked: { type: 'integer', nullable: true, description: 'How many this pass actually requested. Bounded by `limit`.', example: 400 },
|
||||
fetched: { type: 'integer', nullable: true, description: 'How many the shard returned a picture for.', example: 396 },
|
||||
written: { type: 'integer', nullable: true, description: 'How many were written to disk.', example: 396 },
|
||||
absent: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
description:
|
||||
'Keys the shard has no art for. NOT a failure — 9,963 of this client’s static ids have an empty index entry, and an item using one simply has no picture.',
|
||||
example: 4,
|
||||
},
|
||||
unsupported: { type: 'integer', nullable: true, description: 'Keys the shard does not serve at all. A bug on the site’s side rather than a gap in the client.', example: 0 },
|
||||
remaining: { type: 'integer', nullable: true, description: 'Wanted keys left for the next pass. Passes repeat on a timer, so a backlog drains without an operator.', example: 0 },
|
||||
},
|
||||
},
|
||||
UoShardLinkRequest: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
properties: {
|
||||
code: { type: 'string', description: 'The one-time code shown by [link in game.', example: 'AB12CD' },
|
||||
},
|
||||
},
|
||||
UoShardLinkResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
linked: { type: 'boolean', example: true },
|
||||
account: { type: 'string', example: 'whitlocktech' },
|
||||
},
|
||||
},
|
||||
UoShardLink: {
|
||||
type: 'object',
|
||||
description: 'A linked in-game account (GET /player/shard/accounts).',
|
||||
properties: {
|
||||
account: { type: 'string', example: 'whitlocktech' },
|
||||
userId: { type: 'integer', example: 42 },
|
||||
charName: { type: 'string', nullable: true, example: 'Darrow' },
|
||||
linkedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
UoTownCrierRequest: {
|
||||
type: 'object',
|
||||
required: ['id', 'lines'],
|
||||
properties: {
|
||||
id: { type: 'string', maxLength: 64, description: 'Re-posting the same id replaces the prior entry.', example: 'news-42' },
|
||||
lines: { type: 'array', items: { type: 'string', maxLength: 200 }, example: ['Hear ye!', 'Market tax is now 5%.'] },
|
||||
durationSec: { type: 'integer', minimum: 1, maximum: 86400, example: 3600 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -47,6 +47,11 @@ function fakeCtx(overrides = {}) {
|
||||
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
|
||||
auth: { getUserFromRequest: spy(null) },
|
||||
push: { publish: spy(Promise.resolve()) },
|
||||
// MODULE_API 1.7.0. Both are fire-and-forget and return undefined by
|
||||
// contract — a module gets no delivery answer back, deliberately — so the
|
||||
// spies return undefined rather than a promise, which is what core does.
|
||||
events: { emit: spy(undefined), reconcile: spy(undefined) },
|
||||
inbox: { push: spy(undefined) },
|
||||
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
@@ -95,6 +100,13 @@ function fakeApi() {
|
||||
extensions: [],
|
||||
streams: null,
|
||||
legs: [],
|
||||
teamProvider: null,
|
||||
slashCommands: [],
|
||||
triggers: null,
|
||||
audiences: null,
|
||||
eventActions: null,
|
||||
eventBudgets: null,
|
||||
eventOptionSources: null,
|
||||
hooks: {},
|
||||
}
|
||||
const called = new Set()
|
||||
@@ -107,6 +119,34 @@ function fakeApi() {
|
||||
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
|
||||
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
|
||||
registerAnnounceLeg(leg) { record.legs.push(leg) },
|
||||
// MODULE_API 1.6.0. `once` because core holds a single provider per
|
||||
// deployment — a second registration is a collision there, so it has to be
|
||||
// one here too, or this suite would pass a shape core rejects at load.
|
||||
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
|
||||
// MODULE_API 1.6.0, live since phase 7. `once` for the same reason core
|
||||
// takes it: a second call is a module changing its mind halfway through
|
||||
// register(), which core rejects.
|
||||
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
|
||||
// MODULE_API 1.7.0, live since ENGAGEMENT.md Phase 11. `once` on both, for
|
||||
// the reason above: core stages a registrant's whole batch and applies it as
|
||||
// one, so a second call is a module changing its mind mid-register().
|
||||
registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers },
|
||||
registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences },
|
||||
// MODULE_API 1.9.0 (ENGAGEMENT.md Phase 11b). `once` again, and here it is
|
||||
// load-bearing rather than tidy: a rule belongs to exactly ONE named group,
|
||||
// and merging two calls would make "which group is this rule in" — the
|
||||
// question the one-shot seed guard answers — unanswerable.
|
||||
registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds },
|
||||
// MODULE_API 1.10.0 (EVENTS.md F, EVENTS_PLAN.md Phases 7 and 9). `once` on
|
||||
// all three, matching core: it stages a registrant's whole batch and applies
|
||||
// it as one, so a second call is a module changing its mind mid-register().
|
||||
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
|
||||
registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets },
|
||||
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
|
||||
// And the fourth, from Phase 11b. `once` for the same reason, and present here
|
||||
// for a second one: a verb this module calls and this fake does not have is a
|
||||
// TypeError in `entry.test.js` rather than a surprise at somebody's boot.
|
||||
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
|
||||
407
server/test/assetBridge.test.js
Normal file
407
server/test/assetBridge.test.js
Normal file
@@ -0,0 +1,407 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const bridge = require('../utils/assetBridge')
|
||||
|
||||
// The three walks over the asset plane, driven against a stubbed sidecar client
|
||||
// (docs/link/v8.md §5, §6, §8 — protocol 8, phase 3).
|
||||
//
|
||||
// Two families of failure are asserted here and they are not the same shape.
|
||||
//
|
||||
// **The envelope failures** are ways the shard can be wrong that leave this side
|
||||
// holding a catalogue it believes is complete. They are invisible downstream: a
|
||||
// catalogue missing its last three hundred bodies renders as a site where some
|
||||
// creatures have pictures and some do not, which is exactly what NO catalogue
|
||||
// looks like. Each corresponds to a field §3.4 puts on the wire specifically so
|
||||
// this side can tell the difference.
|
||||
//
|
||||
// **The absence failures** are the opposite mistake, and phase 3's more likely
|
||||
// one: treating a body this client has no art for as an error. Two thirds of the
|
||||
// playable ghost and gargoyle bodies are in that state on a stock client, and an
|
||||
// import that failed — or even warned loudly — on them would teach an operator to
|
||||
// ignore the panel.
|
||||
|
||||
const saved = {}
|
||||
|
||||
function stub({ sources, manifest = [], fetch = [], bodies = [] } = {}) {
|
||||
saved.getAssetSources = uoLinkClient.getAssetSources
|
||||
saved.getAssetManifest = uoLinkClient.getAssetManifest
|
||||
saved.fetchAssets = uoLinkClient.fetchAssets
|
||||
saved.resolveBodies = uoLinkClient.resolveBodies
|
||||
|
||||
const calls = { manifest: [], fetch: [], bodies: [] }
|
||||
|
||||
uoLinkClient.getAssetSources = async () => sources
|
||||
uoLinkClient.getAssetManifest = async ({ family, cursor } = {}) => {
|
||||
calls.manifest.push({ family: family ?? null, cursor: cursor ?? null })
|
||||
const next = manifest.shift()
|
||||
if (!next) throw new Error('the walk asked for more manifest pages than the test supplied')
|
||||
return next
|
||||
}
|
||||
uoLinkClient.fetchAssets = async ({ keys, catalog, cursor } = {}) => {
|
||||
calls.fetch.push({ keys, catalog: catalog ?? null, cursor: cursor ?? null })
|
||||
const next = fetch.shift()
|
||||
if (!next) throw new Error('the walk asked for more fetch pages than the test supplied')
|
||||
return next
|
||||
}
|
||||
uoLinkClient.resolveBodies = async (types) => {
|
||||
calls.bodies.push(types)
|
||||
const next = bodies.shift()
|
||||
if (!next) throw new Error('the walk asked for more body chunks than the test supplied')
|
||||
return next
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
function restore() {
|
||||
for (const [name, fn] of Object.entries(saved)) {
|
||||
if (fn) uoLinkClient[name] = fn
|
||||
}
|
||||
}
|
||||
|
||||
const ok = (data) => ({ ok: true, status: 200, data })
|
||||
const fail = (status, data) => ({ ok: false, status, data })
|
||||
|
||||
const CATALOG = 'a3f9c21d4b8e0771'
|
||||
|
||||
const manifestPage = (rows, extra = {}) =>
|
||||
ok({
|
||||
kind: 'assets.manifest.ok',
|
||||
family: 'body',
|
||||
catalog: CATALOG,
|
||||
extractorVersion: 1,
|
||||
playerBodies: [400, 401, 402, 403],
|
||||
scanned: rows.length,
|
||||
rows,
|
||||
more: false,
|
||||
cut: 'end',
|
||||
...extra,
|
||||
})
|
||||
|
||||
const fetchPage = (rows, extra = {}) =>
|
||||
ok({
|
||||
kind: 'assets.fetch.ok',
|
||||
family: 'body',
|
||||
catalog: CATALOG,
|
||||
rows,
|
||||
more: false,
|
||||
cut: 'end',
|
||||
...extra,
|
||||
})
|
||||
|
||||
const row = (body, sha = 'aa') => ({
|
||||
key: `body/${body}/a0`,
|
||||
sha256: sha,
|
||||
bytes: 900,
|
||||
width: 24,
|
||||
height: 63,
|
||||
body,
|
||||
direction: 1,
|
||||
})
|
||||
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')
|
||||
|
||||
const sourcesReply = (extra = {}) =>
|
||||
ok({
|
||||
kind: 'assets.sources.ok',
|
||||
extractorVersion: 1,
|
||||
imaging: { ok: true },
|
||||
hashing: false,
|
||||
complete: true,
|
||||
files: [
|
||||
{ name: 'anim.idx', size: 10, mtime: 1, sha256: 'a' },
|
||||
{ name: 'anim.mul', size: 20, mtime: 2, sha256: 'b' },
|
||||
{ name: 'body.def', size: 30, mtime: 3, sha256: 'c' },
|
||||
// Not a source this family reads: `art.mul` decides item pictures, not
|
||||
// creature ones, and folding it in would make every item-art change look
|
||||
// like a reason to re-import the whole body catalogue.
|
||||
{ name: 'art.mul', size: 148000000, mtime: 4, sha256: 'd' },
|
||||
],
|
||||
...extra,
|
||||
})
|
||||
|
||||
// ── the source gate (§6 stage 1) ──────────────────────────────────────────
|
||||
|
||||
test('the source fingerprint keeps only the files the body catalogue reads', async (t) => {
|
||||
stub({ sources: sourcesReply() })
|
||||
t.after(restore)
|
||||
|
||||
const fingerprint = await bridge.sourceFingerprint()
|
||||
|
||||
assert.deepEqual(Object.keys(fingerprint.files).sort(), ['anim.idx', 'anim.mul', 'body.def'])
|
||||
assert.equal(fingerprint.extractorVersion, 1)
|
||||
})
|
||||
|
||||
test('a bumped extractor version is drift even when every client file is identical', () => {
|
||||
const files = { 'anim.mul': { size: 1, mtime: 2, sha256: 'x' } }
|
||||
|
||||
assert.equal(
|
||||
bridge.sameSources({ files, extractorVersion: 1 }, { files, extractorVersion: 1 }),
|
||||
true,
|
||||
)
|
||||
// §7: a corrected frame offset changes every derived byte while every source
|
||||
// file stays byte-identical. If this returned true the fix would never reach
|
||||
// an install whose client never moves.
|
||||
assert.equal(
|
||||
bridge.sameSources({ files, extractorVersion: 2 }, { files, extractorVersion: 1 }),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('a client that GAINED an anim file is drift, not a match', () => {
|
||||
const before = { files: { 'anim.mul': { size: 1, mtime: 2, sha256: 'x' } }, extractorVersion: 1 }
|
||||
const after = {
|
||||
files: {
|
||||
'anim.mul': { size: 1, mtime: 2, sha256: 'x' },
|
||||
// A client that grows an anim5.mul is a client whose gargoyles suddenly
|
||||
// resolve. Comparing only the files present in both would call that
|
||||
// unchanged and never import them.
|
||||
'anim5.mul': { size: 9, mtime: 9, sha256: 'y' },
|
||||
},
|
||||
extractorVersion: 1,
|
||||
}
|
||||
|
||||
assert.equal(bridge.sameSources(before, after), false)
|
||||
})
|
||||
|
||||
test('a null hash falls back to size and mtime rather than reading as changed', () => {
|
||||
// The shard hashes 195 MB anim files off the request path, so a null sha256 is
|
||||
// "not computed yet". Treating it as a difference would re-import the whole
|
||||
// catalogue on every restart until the background pass finished.
|
||||
const a = { files: { 'anim.mul': { size: 5, mtime: 7, sha256: null } }, extractorVersion: 1 }
|
||||
const b = { files: { 'anim.mul': { size: 5, mtime: 7, sha256: 'later' } }, extractorVersion: 1 }
|
||||
|
||||
assert.equal(bridge.sameSources(a, b), true)
|
||||
})
|
||||
|
||||
// ── the manifest walk (§6 stage 2) ────────────────────────────────────────
|
||||
|
||||
test('the manifest walks every page and stops only on cut: end', async (t) => {
|
||||
const calls = stub({
|
||||
manifest: [
|
||||
manifestPage([row(12), row(34)], { more: true, cursor: 'b:34', cut: 'limit' }),
|
||||
manifestPage([row(400)]),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await bridge.readManifest()
|
||||
|
||||
assert.equal(result.rows.length, 3)
|
||||
assert.equal(result.catalog, CATALOG)
|
||||
assert.deepEqual(result.playerBodies, [400, 401, 402, 403])
|
||||
assert.deepEqual(
|
||||
calls.manifest.map((c) => c.cursor),
|
||||
[null, 'b:34'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a short page that did not end the catalogue is refused', async (t) => {
|
||||
// `cut: 'limit'` with `more: false` is the shard saying it stopped for its own
|
||||
// reason. Importing what arrived would silently drop every body after it, and
|
||||
// the result is indistinguishable from a client with fewer creatures.
|
||||
stub({ manifest: [manifestPage([row(12)], { more: false, cut: 'limit' })] })
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(() => bridge.readManifest(), /stopped sending asset rows/)
|
||||
})
|
||||
|
||||
test('a cursor that does not advance is refused rather than looped on', async (t) => {
|
||||
stub({
|
||||
manifest: [
|
||||
manifestPage([row(12)], { more: true, cursor: 'b:12', cut: 'budget' }),
|
||||
manifestPage([row(13)], { more: true, cursor: 'b:12', cut: 'budget' }),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(() => bridge.readManifest(), /without advancing its cursor/)
|
||||
})
|
||||
|
||||
test('the client files changing mid-walk aborts the whole import', async (t) => {
|
||||
// The catalogue id is derived from the client files themselves, so a change
|
||||
// between two pages means half of what we hold describes files that no longer
|
||||
// exist — and nothing later can tell which half.
|
||||
stub({
|
||||
manifest: [
|
||||
manifestPage([row(12)], { more: true, cursor: 'b:12', cut: 'limit' }),
|
||||
manifestPage([row(34)], { catalog: 'something-else' }),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(() => bridge.readManifest(), /changed while the manifest was being read/)
|
||||
})
|
||||
|
||||
// ── the fetch (§5) ────────────────────────────────────────────────────────
|
||||
|
||||
test('a fetch passes the catalogue id and decodes the PNG', async (t) => {
|
||||
const calls = stub({
|
||||
fetch: [fetchPage([{ key: 'body/12/a0', status: 'ok', sha256: 'aa', bytes: 4, width: 24, height: 63, body: 12, direction: 1, png }])],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const { assets } = await bridge.fetchAssets({ keys: ['body/12/a0'], catalog: CATALOG })
|
||||
|
||||
assert.equal(calls.fetch[0].catalog, CATALOG)
|
||||
assert.equal(assets.get('body/12/a0').png.length, 4)
|
||||
assert.equal(assets.get('body/12/a0').width, 24)
|
||||
})
|
||||
|
||||
test('a body catalogued at a later action keeps that action in its row', async (t) => {
|
||||
// §11.2, phase 6. 73 of a stock client's bodies have no art at action 0 and are
|
||||
// catalogued at the first action that does — body 820's is 23, and it is a
|
||||
// horse. The action travels with the row because the atlas join needs it in
|
||||
// SQL; re-deriving it from the key would put a second parser of §5's scheme in
|
||||
// the schema.
|
||||
stub({
|
||||
manifest: [
|
||||
manifestPage([
|
||||
{ ...row(12), action: 0 },
|
||||
{ key: 'body/820/a23', sha256: 'bb', bytes: 900, width: 68, height: 69, body: 820, action: 23, direction: 1 },
|
||||
]),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const { rows } = await bridge.readManifest({})
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.key, r.action]),
|
||||
[
|
||||
['body/12/a0', 0],
|
||||
['body/820/a23', 23],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('an overlay older than phase 6 reads as action 0 rather than as unknown', async (t) => {
|
||||
// A phase-3 through phase-5 overlay omits `action` entirely, and every key it
|
||||
// ever produced ended in `a0`. Reading that as null would make the atlas join
|
||||
// COALESCE it back to 0 anyway; reading it as 0 here says so once.
|
||||
stub({ manifest: [manifestPage([row(12)])] })
|
||||
t.after(restore)
|
||||
|
||||
const { rows } = await bridge.readManifest({})
|
||||
|
||||
assert.equal(rows[0].action, 0)
|
||||
})
|
||||
|
||||
test('an absent asset is a counted row, not a failed fetch', async (t) => {
|
||||
// The whole reason this is not an error: two thirds of the playable ghost and
|
||||
// gargoyle bodies have no art on a stock client (§5.2), and an import that
|
||||
// failed on them could never succeed.
|
||||
stub({
|
||||
fetch: [
|
||||
fetchPage([
|
||||
{ key: 'body/12/a0', status: 'ok', sha256: 'aa', bytes: 4, png },
|
||||
{ key: 'body/666/a0', status: 'absent' },
|
||||
{ key: 'body/400/a2/f3', status: 'unsupported' },
|
||||
]),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const { assets, missing } = await bridge.fetchAssets({
|
||||
keys: ['body/12/a0', 'body/666/a0', 'body/400/a2/f3'],
|
||||
catalog: CATALOG,
|
||||
})
|
||||
|
||||
assert.equal(assets.size, 1)
|
||||
// Counted apart, because they mean different things: `absent` is a gap in the
|
||||
// operator's client and `unsupported` is a bug on this side.
|
||||
assert.equal(missing.absent, 1)
|
||||
assert.equal(missing.unsupported, 1)
|
||||
})
|
||||
|
||||
test('a busy shard is retried rather than failing the walk', async (t) => {
|
||||
saved.fetchAssets = uoLinkClient.fetchAssets
|
||||
t.after(restore)
|
||||
|
||||
let attempts = 0
|
||||
|
||||
uoLinkClient.fetchAssets = async () => {
|
||||
attempts++
|
||||
if (attempts < 3) return fail(425, { reason: 'busy' })
|
||||
return fetchPage([{ key: 'body/12/a0', status: 'ok', sha256: 'aa', bytes: 4, png }])
|
||||
}
|
||||
|
||||
const { assets } = await bridge.fetchAssets({ keys: ['body/12/a0'], catalog: CATALOG })
|
||||
|
||||
assert.equal(attempts, 3)
|
||||
assert.equal(assets.size, 1)
|
||||
})
|
||||
|
||||
test('a shard host with no libgdiplus is named, not reported as a dead shard', async (t) => {
|
||||
saved.getAssetManifest = uoLinkClient.getAssetManifest
|
||||
t.after(restore)
|
||||
|
||||
uoLinkClient.getAssetManifest = async () =>
|
||||
fail(503, { reason: "this shard host cannot render images - Mono's System.Drawing needs libgdiplus" })
|
||||
|
||||
await assert.rejects(
|
||||
() => bridge.readManifest(),
|
||||
(err) => err.code === 'NO_IMAGING',
|
||||
)
|
||||
})
|
||||
|
||||
// ── the body pass (§8) ────────────────────────────────────────────────────
|
||||
|
||||
test('body resolution chunks to the shard cap and records every outcome', async (t) => {
|
||||
const creatures = []
|
||||
|
||||
for (let i = 0; i < bridge.BODY_CHUNK + 5; i++) {
|
||||
creatures.push({ slug: `c-${i}`, name: `Creature${i}` })
|
||||
}
|
||||
|
||||
const reply = (types) =>
|
||||
ok({
|
||||
kind: 'assets.bodies.ok',
|
||||
rows: types.map((type, i) => (i === 0 ? { type, status: 'unknown' } : { type, status: 'ok', body: 100 + i })),
|
||||
more: false,
|
||||
cut: 'end',
|
||||
})
|
||||
|
||||
const calls = stub({ bodies: [] })
|
||||
t.after(restore)
|
||||
|
||||
uoLinkClient.resolveBodies = async (types) => {
|
||||
calls.bodies.push(types)
|
||||
return reply(types)
|
||||
}
|
||||
|
||||
const rows = await bridge.resolveBodies({ creatures })
|
||||
|
||||
// Two chunks, and neither over the cap: the shard REFUSES an over-long list
|
||||
// rather than truncating it, so a chunk size above its cap does not degrade —
|
||||
// every request fails.
|
||||
assert.equal(calls.bodies.length, 2)
|
||||
assert.ok(calls.bodies.every((chunk) => chunk.length <= bridge.BODY_CHUNK))
|
||||
|
||||
assert.equal(rows.length, creatures.length)
|
||||
// The negative answers are kept. Without them the next pass asks again, and
|
||||
// the pass costs a real constructor per name on the shard's Core thread.
|
||||
assert.equal(rows.filter((r) => r.status === 'unknown').length, 2)
|
||||
})
|
||||
|
||||
test('two slugs sharing a class name are asked once and both get the answer', async (t) => {
|
||||
const calls = stub({
|
||||
bodies: [
|
||||
ok({ kind: 'assets.bodies.ok', rows: [{ type: 'GiantSpider', status: 'ok', body: 28 }], more: false, cut: 'end' }),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const rows = await bridge.resolveBodies({
|
||||
creatures: [
|
||||
{ slug: 'giant-spider', name: 'GiantSpider' },
|
||||
{ slug: 'giantspider', name: 'GiantSpider' },
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(calls.bodies[0], ['GiantSpider'])
|
||||
assert.equal(rows.length, 2)
|
||||
assert.ok(rows.every((r) => r.body === 28))
|
||||
})
|
||||
156
server/test/atlasSourceSelection.test.js
Normal file
156
server/test/atlasSourceSelection.test.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// Which atlas source runs, and what boot does with the answer
|
||||
// (docs/link/v8.md §10, §17.7; docs/website/SPAWN_ATLAS.md).
|
||||
//
|
||||
// The model is the only place that decides between a local ServUO tree and the
|
||||
// shard bridge, so these drive it with the shard, the database and the
|
||||
// filesystem all stubbed. Nothing here reaches a real sidecar or a real tree.
|
||||
//
|
||||
// The rule under test is the one the cliloc pipeline settled first and this
|
||||
// inherits: the bridge wins whenever uo-link is configured and enabled, a local
|
||||
// path is what a site with no shard link uses, and an explicit path is an
|
||||
// instruction that overrules both.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const atlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const db = require('../model/shardAtlas/shardAtlas.db')
|
||||
const source = require('../utils/spawnAtlasSource')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const { ctx } = require('./_setup')
|
||||
|
||||
const saved = {
|
||||
getMeta: db.getMeta,
|
||||
getPending: db.getPending,
|
||||
getFacets: db.getFacets,
|
||||
hashFrom: source.hashFrom,
|
||||
buildFrom: source.buildFrom,
|
||||
getSafe: uoLinkConfig.getSafe,
|
||||
settingsGet: ctx.settings.get,
|
||||
}
|
||||
|
||||
function restore() {
|
||||
db.getMeta = saved.getMeta
|
||||
db.getPending = saved.getPending
|
||||
db.getFacets = saved.getFacets
|
||||
source.hashFrom = saved.hashFrom
|
||||
source.buildFrom = saved.buildFrom
|
||||
uoLinkConfig.getSafe = saved.getSafe
|
||||
ctx.settings.get = saved.settingsGet
|
||||
}
|
||||
|
||||
/** Whatever source the model chose, captured rather than read. */
|
||||
function rig({ linked = true, treePath = '', meta = null } = {}) {
|
||||
const asked = { hash: [], build: [] }
|
||||
|
||||
uoLinkConfig.getSafe = async () => ({
|
||||
enabled: linked,
|
||||
baseUrl: linked ? 'http://127.0.0.1:8099' : null,
|
||||
})
|
||||
ctx.settings.get = async () => treePath
|
||||
|
||||
db.getMeta = async () => meta
|
||||
db.getPending = async () => null
|
||||
db.getFacets = async () => []
|
||||
|
||||
source.hashFrom = async (descriptor) => {
|
||||
asked.hash.push(descriptor)
|
||||
return { 'Data/Regions.xml': 'aa' }
|
||||
}
|
||||
source.buildFrom = async (descriptor) => {
|
||||
asked.build.push(descriptor)
|
||||
throw new Error('the test stops before a build')
|
||||
}
|
||||
|
||||
return asked
|
||||
}
|
||||
|
||||
test('a linked shard is the atlas source, and the configured path is not consulted', async () => {
|
||||
const asked = rig({ linked: true, treePath: '/srv/servuo' })
|
||||
|
||||
const status = await atlas.status()
|
||||
|
||||
assert.equal(status.source, 'bridge')
|
||||
assert.equal(status.path, 'the shard bridge')
|
||||
assert.equal(status.configured, true)
|
||||
assert.deepEqual(asked.hash[0], { kind: 'bridge', root: '' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('with no shard linked the configured tree is the source', async () => {
|
||||
const asked = rig({ linked: false, treePath: '/srv/servuo' })
|
||||
|
||||
const status = await atlas.status()
|
||||
|
||||
assert.equal(status.source, 'fs')
|
||||
assert.equal(status.path, '/srv/servuo')
|
||||
assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/srv/servuo' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('an explicit path overrules the bridge — it is an instruction, not a default', async () => {
|
||||
const asked = rig({ linked: true, treePath: '/srv/servuo' })
|
||||
|
||||
await atlas.status({ path: '/tmp/other-tree' })
|
||||
|
||||
assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/tmp/other-tree' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('no shard and no path is "nothing configured", not an error', async () => {
|
||||
rig({ linked: false, treePath: '' })
|
||||
|
||||
const status = await atlas.status()
|
||||
assert.equal(status.configured, false)
|
||||
|
||||
const result = await atlas.refresh()
|
||||
assert.equal(result.status, 'skipped')
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('boot does not call the shard; it says where the import lives instead', async () => {
|
||||
// §17.7's rule, and the reason it is not free: an install whose atlas comes
|
||||
// over the bridge has NO automatic refresh at all, so the skip has to be
|
||||
// deliberate and visible rather than a path that quietly does nothing.
|
||||
const asked = rig({ linked: true, treePath: '/srv/servuo' })
|
||||
|
||||
const result = await atlas.refreshOnBoot()
|
||||
|
||||
assert.equal(result.status, 'skipped')
|
||||
assert.equal(result.source, 'bridge')
|
||||
assert.equal(asked.hash.length, 0, 'boot made no shard call at all')
|
||||
assert.equal(asked.build.length, 0)
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('boot still refreshes by itself from a local tree', async () => {
|
||||
const asked = rig({ linked: false, treePath: '/srv/servuo' })
|
||||
|
||||
await atlas.refreshOnBoot()
|
||||
|
||||
assert.deepEqual(asked.hash[0], { kind: 'fs', root: '/srv/servuo' })
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a source that cannot be read is reported, with which end could not read it', async () => {
|
||||
rig({ linked: true, treePath: '' })
|
||||
|
||||
source.hashFrom = async () => {
|
||||
const { TreeBridgeError } = require('../utils/treeBridge')
|
||||
throw new TreeBridgeError('the shard is not serving its tree', 'DISABLED')
|
||||
}
|
||||
|
||||
const result = await atlas.refresh()
|
||||
|
||||
assert.equal(result.status, 'unavailable')
|
||||
assert.equal(result.source, 'bridge')
|
||||
assert.equal(result.code, 'DISABLED')
|
||||
|
||||
restore()
|
||||
})
|
||||
208
server/test/checkBundle.test.js
Normal file
208
server/test/checkBundle.test.js
Normal file
@@ -0,0 +1,208 @@
|
||||
// The bundle check, checked.
|
||||
//
|
||||
// `scripts/checkBundle.js` exists because v1.0.0 shipped without
|
||||
// `server/commands/` and died at the register stage on the operator's box. A
|
||||
// check written in response to one bug is worth exactly as much as its coverage
|
||||
// of that bug, so the first two tests below are that bug, in both modes: a list
|
||||
// that has stopped covering what the entry point reaches, and a tarball with the
|
||||
// file missing from it.
|
||||
//
|
||||
// **Every fixture is a template literal, and that is load-bearing** — the same
|
||||
// reason checkImports.test.js gives. `scripts/checkImports.js` scans this
|
||||
// directory too, so an ordinary quoted string holding a relative require would
|
||||
// make this file fail that check. Templates are blanked by the stripper.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
reachable,
|
||||
resolveFile,
|
||||
checkDeclaration,
|
||||
checkBundle,
|
||||
declaredServerPaths
|
||||
} = require('../scripts/checkBundle')
|
||||
|
||||
/**
|
||||
* Write a throwaway module tree: `files` under server/, `bundle` as its
|
||||
* ci/bundle.json. Returns the module root.
|
||||
*/
|
||||
function fixture(files, bundle = { server: ['index.js'] }) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-uo-bundle-'))
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
const file = path.join(root, 'server', name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, source)
|
||||
}
|
||||
fs.mkdirSync(path.join(root, 'ci'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'ci', 'bundle.json'), JSON.stringify(bundle))
|
||||
return root
|
||||
}
|
||||
|
||||
const cleanup = (root) => fs.rmSync(root, { recursive: true, force: true })
|
||||
|
||||
// ── The regression this script was written for ─────────────────────────────
|
||||
|
||||
test('--check catches a directory the include list has stopped covering', () => {
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `const g = require('./commands/guild.command')`,
|
||||
'commands/guild.command.js': `module.exports = {}`
|
||||
},
|
||||
{ server: ['index.js'] } // `commands` missing — exactly v1.0.0
|
||||
)
|
||||
try {
|
||||
const { uncovered } = checkDeclaration(root)
|
||||
assert.strictEqual(uncovered.size, 1)
|
||||
assert.ok(uncovered.has('server/commands'))
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('--bundle catches the file missing from an assembled tarball', () => {
|
||||
const root = fixture({ 'index.js': `require('./commands/guild.command')` })
|
||||
try {
|
||||
const { missing } = checkBundle(root)
|
||||
assert.strictEqual(missing.length, 1)
|
||||
assert.strictEqual(missing[0].specifier, './commands/guild.command')
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── It has to reach requires that are not at the top level ─────────────────
|
||||
|
||||
test('follows requires written inside a function', () => {
|
||||
// index.js requires inside `register()` because require order is load-bearing.
|
||||
// A check that only saw file-scope requires would have missed the real bug.
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `module.exports = function register(ctx) { const r = require('./router/a') }`,
|
||||
'router/a.js': `module.exports = {}`
|
||||
},
|
||||
{ server: ['index.js', 'router'] }
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
assert.strictEqual(checkBundle(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('follows requires transitively, not just one hop', () => {
|
||||
const root = fixture(
|
||||
{
|
||||
'index.js': `require('./a')`,
|
||||
'a.js': `require('./b')`,
|
||||
'b.js': `require('./deep/c')`,
|
||||
'deep/c.js': `module.exports = {}`
|
||||
},
|
||||
{ server: ['index.js', 'a.js', 'b.js'] } // `deep` missing
|
||||
)
|
||||
try {
|
||||
const { uncovered } = checkDeclaration(root)
|
||||
assert.ok(uncovered.has('server/deep'))
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Resolution has to match Node's, or it invents failures ─────────────────
|
||||
|
||||
test('resolves a directory to its index.js', () => {
|
||||
const root = fixture({ 'index.js': `require('./boot')`, 'boot/index.js': `module.exports = {}` },
|
||||
{ server: ['index.js', 'boot'] })
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves a .json dependency, and does not try to parse it for requires', () => {
|
||||
const root = fixture({ 'index.js': `require('./data/atlas.json')`, 'data/atlas.json': `{"a":1}` },
|
||||
{ server: ['index.js', 'data'] })
|
||||
try {
|
||||
const { uncovered, missing } = checkDeclaration(root)
|
||||
assert.strictEqual(missing.length, 0)
|
||||
assert.strictEqual(uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('survives a require cycle', () => {
|
||||
const root = fixture({ 'index.js': `require('./a')`, 'a.js': `require('./index')` },
|
||||
{ server: ['index.js', 'a.js'] })
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).uncovered.size, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('a specifier that resolves to nothing is reported, not thrown', () => {
|
||||
const root = fixture({ 'index.js': `require('./gone')` })
|
||||
try {
|
||||
const { missing } = checkDeclaration(root)
|
||||
assert.strictEqual(missing.length, 1)
|
||||
assert.strictEqual(missing[0].specifier, './gone')
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('prose describing a require is not a require', () => {
|
||||
// The failure mode checkImports.js hit the first time it ran: index.js's own
|
||||
// header explains why it must never require express, and comments in this
|
||||
// repo name module paths constantly.
|
||||
const root = fixture(
|
||||
{ 'index.js': `// this file used to require('./commands/gone')\nmodule.exports = 1` },
|
||||
{ server: ['index.js'] }
|
||||
)
|
||||
try {
|
||||
assert.strictEqual(checkDeclaration(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
test('node_modules inside a bundle is npm\'s business, not this check\'s', () => {
|
||||
const root = fixture({
|
||||
'index.js': `module.exports = 1`,
|
||||
'node_modules/ws/index.js': `require('./lib/that-npm-owns')`
|
||||
})
|
||||
try {
|
||||
assert.strictEqual(checkBundle(root).missing.length, 0)
|
||||
} finally {
|
||||
cleanup(root)
|
||||
}
|
||||
})
|
||||
|
||||
// ── And the real repo, which is the check that actually gates a release ────
|
||||
|
||||
test('the real ci/bundle.json covers everything the real entry point reaches', () => {
|
||||
const { uncovered, missing, reached } = checkDeclaration()
|
||||
assert.deepStrictEqual([...uncovered.keys()], [])
|
||||
assert.deepStrictEqual(missing, [])
|
||||
assert.ok(reached > 1, 'the walk should reach more than the entry point itself')
|
||||
})
|
||||
|
||||
test('every path ci/bundle.json declares exists', () => {
|
||||
// A list naming a path that has moved packs nothing and says nothing — `cp`
|
||||
// in the release would fail, but only after the tag had been pushed.
|
||||
for (const p of declaredServerPaths()) {
|
||||
assert.ok(fs.existsSync(p), `ci/bundle.json names ${p}, which does not exist`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the entry point is reachable from the declared list', () => {
|
||||
const entry = path.resolve(__dirname, '..', 'index.js')
|
||||
assert.ok(reachable(entry).files.includes(entry))
|
||||
assert.ok(resolveFile(path.dirname(entry), './core'))
|
||||
})
|
||||
303
server/test/clilocBridge.test.js
Normal file
303
server/test/clilocBridge.test.js
Normal file
@@ -0,0 +1,303 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const bridge = require('../utils/clilocBridge')
|
||||
|
||||
// The walk over `GET /cliloc`, driven against a stubbed sidecar client.
|
||||
//
|
||||
// Everything asserted here is a way the shard can be wrong that leaves the
|
||||
// website holding a table it believes is complete. That is the failure worth
|
||||
// testing, because it is invisible downstream: a truncated cliloc table renders
|
||||
// some items with names and some with ids, which is exactly what NO table looks
|
||||
// like. None of these are hypothetical shapes — each corresponds to a field the
|
||||
// paging envelope carries specifically so this side can tell the difference
|
||||
// (docs/link/v8.md §3.4).
|
||||
|
||||
const saved = {}
|
||||
|
||||
function stub({ sources, pages }) {
|
||||
saved.getAssetSources = uoLinkClient.getAssetSources
|
||||
saved.getClilocTable = uoLinkClient.getClilocTable
|
||||
|
||||
const calls = []
|
||||
|
||||
uoLinkClient.getAssetSources = async () => sources
|
||||
uoLinkClient.getClilocTable = async ({ lang, cursor } = {}) => {
|
||||
calls.push({ lang, cursor: cursor ?? null })
|
||||
const next = pages.shift()
|
||||
if (!next) throw new Error('the walk asked for more pages than the test supplied')
|
||||
return next
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
function restore() {
|
||||
if (saved.getAssetSources) uoLinkClient.getAssetSources = saved.getAssetSources
|
||||
if (saved.getClilocTable) uoLinkClient.getClilocTable = saved.getClilocTable
|
||||
}
|
||||
|
||||
const ok = (data) => ({ ok: true, status: 200, data })
|
||||
|
||||
/** One page of rows, with the source fingerprint every page echoes. */
|
||||
const page = (rows, extra = {}) =>
|
||||
ok({
|
||||
kind: 'cliloc.table.ok',
|
||||
lang: 'enu',
|
||||
file: 'cliloc.enu',
|
||||
size: 4989921,
|
||||
mtime: 1757462400000,
|
||||
total: 3,
|
||||
rows,
|
||||
more: false,
|
||||
cut: 'end',
|
||||
...extra,
|
||||
})
|
||||
|
||||
const sourcesReply = (file = {}) =>
|
||||
ok({
|
||||
kind: 'assets.sources.ok',
|
||||
extractorVersion: 1,
|
||||
imaging: { ok: true },
|
||||
hashing: false,
|
||||
complete: true,
|
||||
files: [
|
||||
{ name: 'cliloc.enu', path: '/uo/cliloc.enu', size: 4989921, mtime: 1757462400000, sha256: 'abc', ...file },
|
||||
{ name: 'art.mul', path: '/uo/art.mul', size: 148000000, mtime: 1, sha256: null },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Stage 1: the fingerprint ───────────────────────────────────────────────
|
||||
|
||||
test('fingerprint picks the cliloc file out of the client manifest', async (t) => {
|
||||
stub({ sources: sourcesReply(), pages: [] })
|
||||
t.after(restore)
|
||||
|
||||
const fp = await bridge.fingerprint()
|
||||
|
||||
assert.equal(fp.file, 'cliloc.enu')
|
||||
assert.equal(fp.size, 4989921)
|
||||
assert.equal(fp.sha256, 'abc')
|
||||
assert.equal(fp.extractorVersion, 1)
|
||||
})
|
||||
|
||||
test('a client with no cliloc file is NO_SOURCE, not a crash', async (t) => {
|
||||
stub({
|
||||
sources: ok({ extractorVersion: 1, files: [{ name: 'art.mul', size: 1, mtime: 1 }] }),
|
||||
pages: [],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.fingerprint(), (err) => {
|
||||
assert.equal(err.code, 'NO_SOURCE')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('the asset plane being switched off reads as a refusal, not a bug', async (t) => {
|
||||
stub({
|
||||
sources: { ok: false, status: 403, data: { reason: 'asset extraction is disabled on this shard' } },
|
||||
pages: [],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.fingerprint(), (err) => {
|
||||
assert.equal(err.code, 'DISABLED')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// A hash that has not been computed yet is the shard's ordinary first answer:
|
||||
// hashing the 343 MB of art and animation it also serves cannot fit in a 10 s
|
||||
// reply, so it happens off the request path. Treating a null hash as a CHANGE
|
||||
// would make the panel show drift forever on a shard nobody has imported from.
|
||||
test('a missing hash falls back to (size, mtime) rather than reading as drift', () => {
|
||||
const before = { size: 10, mtime: 20, sha256: null, extractorVersion: 1 }
|
||||
const after = { size: 10, mtime: 20, sha256: null, extractorVersion: 1 }
|
||||
|
||||
assert.equal(bridge.sameSource(before, after), true)
|
||||
assert.equal(bridge.sameSource(before, { ...after, mtime: 21 }), false)
|
||||
})
|
||||
|
||||
test('a hash on both sides beats size and mtime, which a patched-in-place file can preserve', () => {
|
||||
const a = { size: 10, mtime: 20, sha256: 'aaa', extractorVersion: 1 }
|
||||
|
||||
assert.equal(bridge.sameSource(a, { ...a, sha256: 'bbb' }), false)
|
||||
assert.equal(bridge.sameSource(a, { ...a, size: 11, mtime: 99 }), true)
|
||||
})
|
||||
|
||||
test('the extractor version is part of the fingerprint, so a corrected reader drifts', () => {
|
||||
const a = { size: 10, mtime: 20, sha256: 'aaa', extractorVersion: 1 }
|
||||
|
||||
assert.equal(bridge.sameSource(a, { ...a, extractorVersion: 2 }), false)
|
||||
})
|
||||
|
||||
// ── Stage 2: the walk ──────────────────────────────────────────────────────
|
||||
|
||||
test('a one-page table comes back whole', async (t) => {
|
||||
const calls = stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [page([{ n: 3, f: 0, t: 'c' }, { n: 1, f: 2, t: 'a' }])],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const { entries, source } = await bridge.readCliloc()
|
||||
|
||||
assert.deepEqual(entries, [
|
||||
{ number: 3, flag: 0, text: 'c' },
|
||||
{ number: 1, flag: 2, text: 'a' },
|
||||
])
|
||||
assert.equal(source.pages, 1)
|
||||
assert.equal(source.received, 2)
|
||||
assert.equal(source.reported, 3)
|
||||
assert.deepEqual(calls, [{ lang: 'enu', cursor: null }])
|
||||
})
|
||||
|
||||
test('pages are walked by echoing the cursor back until more is false', async (t) => {
|
||||
const calls = stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [
|
||||
page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }),
|
||||
page([{ n: 2, f: 0, t: 'b' }], { more: true, cursor: 'n:2', cut: 'budget' }),
|
||||
page([{ n: 3, f: 0, t: 'c' }]),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const { entries, source } = await bridge.readCliloc()
|
||||
|
||||
assert.equal(entries.length, 3)
|
||||
assert.equal(source.pages, 3)
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c.cursor),
|
||||
[null, 'n:1', 'n:2'],
|
||||
)
|
||||
})
|
||||
|
||||
// `cut` is the field that is easy to omit and expensive not to have. A short
|
||||
// page means the source ended, the byte budget was spent, or the family hit its
|
||||
// own limit — and only the first means finished.
|
||||
test('a last page that did not end the table is refused, not imported', async (t) => {
|
||||
stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [page([{ n: 1, f: 0, t: 'a' }], { more: false, cut: 'limit' })],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'INCOMPLETE')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('a shard that does not advance its cursor is stopped rather than spun on', async (t) => {
|
||||
stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [
|
||||
page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }),
|
||||
page([{ n: 2, f: 0, t: 'b' }], { more: true, cursor: 'n:1', cut: 'budget' }),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'STUCK')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('more:true with no cursor at all is the same refusal', async (t) => {
|
||||
stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [page([{ n: 1, f: 0, t: 'a' }], { more: true, cut: 'budget' })],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'STUCK')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// The one failure a count cannot catch: an operator patches their client while
|
||||
// the import is walking it. Half of what arrived is from a file that no longer
|
||||
// exists, and nothing later can tell which half.
|
||||
test('a client patched mid-walk aborts the whole import', async (t) => {
|
||||
stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [
|
||||
page([{ n: 1, f: 0, t: 'a' }], { more: true, cursor: 'n:1', cut: 'budget' }),
|
||||
page([{ n: 2, f: 0, t: 'b' }], { size: 5000000, mtime: 1757470000000 }),
|
||||
],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'SOURCE_CHANGED')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 425 is flow control and the ORDINARY answer during an import — the shard's
|
||||
// asset plane serves one request at a time on purpose — so it is retried rather
|
||||
// than failed. (The backoff is real time, so this exercises one retry only.)
|
||||
test('a busy shard is retried, because the work is happening', async (t) => {
|
||||
saved.getAssetSources = uoLinkClient.getAssetSources
|
||||
saved.getClilocTable = uoLinkClient.getClilocTable
|
||||
t.after(restore)
|
||||
|
||||
let attempts = 0
|
||||
uoLinkClient.getAssetSources = async () => sourcesReply()
|
||||
uoLinkClient.getClilocTable = async () => {
|
||||
attempts++
|
||||
if (attempts === 1) return { ok: false, status: 425, data: { kind: 'bridge.busy' } }
|
||||
return page([{ n: 1, f: 0, t: 'a' }])
|
||||
}
|
||||
|
||||
const { entries } = await bridge.readCliloc()
|
||||
|
||||
assert.equal(attempts, 2)
|
||||
assert.equal(entries.length, 1)
|
||||
})
|
||||
|
||||
test('a page with no rows array is malformed, not an empty table', async (t) => {
|
||||
stub({ sources: sourcesReply(), pages: [ok({ kind: 'cliloc.table.ok', more: false, cut: 'end' })] })
|
||||
t.after(restore)
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'MALFORMED')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('a shard that never ends the table is bounded by the page cap', async (t) => {
|
||||
saved.getAssetSources = uoLinkClient.getAssetSources
|
||||
saved.getClilocTable = uoLinkClient.getClilocTable
|
||||
t.after(restore)
|
||||
|
||||
let n = 0
|
||||
uoLinkClient.getAssetSources = async () => sourcesReply()
|
||||
uoLinkClient.getClilocTable = async () => {
|
||||
n++
|
||||
return page([{ n, f: 0, t: 'x' }], { more: true, cursor: `n:${n}`, cut: 'budget' })
|
||||
}
|
||||
|
||||
await assert.rejects(bridge.readCliloc(), (err) => {
|
||||
assert.equal(err.code, 'TOO_LARGE')
|
||||
return true
|
||||
})
|
||||
assert.equal(n, bridge.MAX_PAGES)
|
||||
})
|
||||
|
||||
test('rows with an unusable id are dropped rather than stored as NaN', async (t) => {
|
||||
stub({
|
||||
sources: sourcesReply(),
|
||||
pages: [page([{ n: 'nonsense', f: 0, t: 'a' }, { n: 7, f: 0, t: 'b' }])],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const { entries } = await bridge.readCliloc()
|
||||
|
||||
assert.deepEqual(entries, [{ number: 7, flag: 0, text: 'b' }])
|
||||
})
|
||||
330
server/test/clilocSourceSelection.test.js
Normal file
330
server/test/clilocSourceSelection.test.js
Normal file
@@ -0,0 +1,330 @@
|
||||
// Which cliloc source runs, and what the shard path does with the answer
|
||||
// (docs/link/v8.md §9, docs/website/CLILOCS.md).
|
||||
//
|
||||
// The model is the only place that decides between the two pipelines, so these
|
||||
// drive it with the shard, the database and the filesystem all stubbed. Nothing
|
||||
// here reaches the real sidecar or a real table.
|
||||
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const clilocs = require('../model/shardClilocs/shardClilocs.model')
|
||||
const db = require('../model/shardClilocs/shardClilocs.db')
|
||||
const bridge = require('../utils/clilocBridge')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const { ctx } = require('./_setup')
|
||||
|
||||
const saved = {
|
||||
getMeta: db.getMeta,
|
||||
replaceAll: db.replaceAll,
|
||||
count: db.count,
|
||||
fingerprint: bridge.fingerprint,
|
||||
readCliloc: bridge.readCliloc,
|
||||
getSafe: uoLinkConfig.getSafe,
|
||||
settingsGet: ctx.settings.get,
|
||||
}
|
||||
|
||||
function restore() {
|
||||
db.getMeta = saved.getMeta
|
||||
db.replaceAll = saved.replaceAll
|
||||
db.count = saved.count
|
||||
bridge.fingerprint = saved.fingerprint
|
||||
bridge.readCliloc = saved.readCliloc
|
||||
uoLinkConfig.getSafe = saved.getSafe
|
||||
ctx.settings.get = saved.settingsGet
|
||||
}
|
||||
|
||||
const FINGERPRINT = {
|
||||
kind: 'bridge',
|
||||
file: 'cliloc.enu',
|
||||
size: 4989921,
|
||||
mtime: 1757462400000,
|
||||
sha256: 'abc',
|
||||
extractorVersion: 1,
|
||||
hashing: false,
|
||||
complete: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* A rig with the shard reachable (or not), the configured overlay path pointed
|
||||
* at a temp directory, and every write captured rather than made.
|
||||
*/
|
||||
function rig({ linked = true, meta = null, clientPath = '', rows = [] } = {}) {
|
||||
const applied = []
|
||||
|
||||
uoLinkConfig.getSafe = async () => ({ enabled: linked, baseUrl: linked ? 'http://127.0.0.1:8099' : null })
|
||||
ctx.settings.get = async (key) => (key === clilocs.SETTING_KEY ? clientPath : null)
|
||||
|
||||
db.getMeta = async () => meta
|
||||
db.count = async () => meta?.count ?? 0
|
||||
db.replaceAll = async (entries, writtenMeta) => {
|
||||
applied.push({ entries, meta: writtenMeta })
|
||||
return { count: entries.length, blank: 0, duplicates: 0 }
|
||||
}
|
||||
|
||||
bridge.fingerprint = async () => FINGERPRINT
|
||||
bridge.readCliloc = async () => ({
|
||||
entries: rows,
|
||||
source: {
|
||||
kind: 'bridge',
|
||||
lang: 'enu',
|
||||
file: 'cliloc.enu',
|
||||
size: FINGERPRINT.size,
|
||||
mtime: FINGERPRINT.mtime,
|
||||
pages: 1,
|
||||
reported: rows.length,
|
||||
received: rows.length,
|
||||
},
|
||||
})
|
||||
|
||||
return applied
|
||||
}
|
||||
|
||||
function tmpWithOverlay(contents) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cliloc-sel-'))
|
||||
fs.mkdirSync(path.join(dir, 'custom'), { recursive: true })
|
||||
if (contents !== undefined) fs.writeFileSync(path.join(dir, 'custom', 'shard.tsv'), contents)
|
||||
return dir
|
||||
}
|
||||
|
||||
// ── Which source runs ──────────────────────────────────────────────────────
|
||||
|
||||
test('a configured shard is the base source, and the file path is not consulted', async (t) => {
|
||||
const applied = rig({ rows: [{ number: 1, flag: 0, text: 'a' }] })
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.equal(result.source, 'bridge')
|
||||
assert.equal(applied.length, 1)
|
||||
assert.equal(applied[0].meta.source, 'bridge')
|
||||
})
|
||||
|
||||
test('no shard link falls back to the file pipeline, unchanged', async (t) => {
|
||||
rig({ linked: false })
|
||||
t.after(restore)
|
||||
|
||||
// No path configured either, so the file path reports exactly what it always
|
||||
// did — which is the assertion: the fallback is the OLD code, not a new one.
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'skipped')
|
||||
assert.equal(result.reason, 'no cliloc path configured')
|
||||
})
|
||||
|
||||
test('an explicit path is still an escape hatch, even with a shard linked', async (t) => {
|
||||
let asked = false
|
||||
rig({ linked: true })
|
||||
bridge.fingerprint = async () => {
|
||||
asked = true
|
||||
return FINGERPRINT
|
||||
}
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh({ path: path.join(os.tmpdir(), 'nope-does-not-exist') })
|
||||
|
||||
assert.equal(asked, false, 'the shard must not be asked when a file was named')
|
||||
assert.equal(result.status, 'unavailable')
|
||||
})
|
||||
|
||||
// Boot deliberately does not call the shard: it would put a sidecar round trip
|
||||
// in the startup sequence to answer a question whose answer is "no" except after
|
||||
// a client patch, which is an operator action.
|
||||
test('boot imports nothing over the bridge and leaves the loaded table serving', async (t) => {
|
||||
let asked = false
|
||||
rig({ linked: true })
|
||||
bridge.fingerprint = async () => {
|
||||
asked = true
|
||||
return FINGERPRINT
|
||||
}
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refreshOnBoot()
|
||||
|
||||
assert.equal(result.status, 'skipped')
|
||||
assert.equal(result.source, 'bridge')
|
||||
assert.equal(asked, false)
|
||||
})
|
||||
|
||||
// ── The gate ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('an unchanged client file and no overlays is a no-op', async (t) => {
|
||||
const applied = rig({
|
||||
meta: { source: 'bridge', base: FINGERPRINT, hashes: {}, parserVersion: 1, count: 67496 },
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'unchanged')
|
||||
assert.equal(result.count, 67496)
|
||||
assert.equal(applied.length, 0)
|
||||
})
|
||||
|
||||
test('a patched client re-imports', async (t) => {
|
||||
const applied = rig({
|
||||
meta: {
|
||||
source: 'bridge',
|
||||
base: { ...FINGERPRINT, sha256: 'older' },
|
||||
hashes: {},
|
||||
parserVersion: 1,
|
||||
count: 10,
|
||||
},
|
||||
rows: [{ number: 1, flag: 0, text: 'a' }],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
assert.equal((await clilocs.refresh()).status, 'imported')
|
||||
assert.equal(applied.length, 1)
|
||||
})
|
||||
|
||||
// The upgrade path. An install that used the converted-file pipeline carries its
|
||||
// base label in the stored fingerprint; on the bridge that label is SUPPOSED to
|
||||
// disappear. Counting it as a vanished source would make the first import after
|
||||
// the upgrade demand an approval for a change the upgrade itself made.
|
||||
test('the retired file base is not reported as a vanished source', async (t) => {
|
||||
const applied = rig({
|
||||
meta: {
|
||||
source: 'file',
|
||||
hashes: { 'clilocs.plain': 'aaa' },
|
||||
parserVersion: 1,
|
||||
count: 67496,
|
||||
},
|
||||
rows: [{ number: 1, flag: 0, text: 'a' }],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'imported', result.reason)
|
||||
assert.equal(applied.length, 1)
|
||||
})
|
||||
|
||||
// An overlay is a different matter: it vanished, and an unmounted volume looks
|
||||
// exactly like a deliberate deletion from here.
|
||||
test('a vanished OVERLAY still stages for review', async (t) => {
|
||||
const applied = rig({
|
||||
meta: {
|
||||
source: 'bridge',
|
||||
base: FINGERPRINT,
|
||||
hashes: { 'custom/shard.tsv': 'aaa' },
|
||||
parserVersion: 1,
|
||||
count: 5,
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'needsReview')
|
||||
assert.deepEqual(result.missingSources, ['custom/shard.tsv'])
|
||||
assert.equal(applied.length, 0)
|
||||
|
||||
const accepted = await clilocs.refresh({ approve: true })
|
||||
assert.equal(accepted.status, 'imported')
|
||||
assert.deepEqual(accepted.acceptedMissing, ['custom/shard.tsv'])
|
||||
})
|
||||
|
||||
// ── The merge ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('an overlay overrides the shard table, and says so', async (t) => {
|
||||
const dir = tmpWithOverlay('1023721\ta better staff\n900001\ta shard-only item\n')
|
||||
const applied = rig({
|
||||
clientPath: dir,
|
||||
rows: [
|
||||
{ number: 1023721, flag: 0, text: 'quarter staff' },
|
||||
{ number: 3000001, flag: 0, text: 'Entering Britannia...' },
|
||||
],
|
||||
})
|
||||
t.after(() => {
|
||||
restore()
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'imported', result.reason)
|
||||
|
||||
const stored = new Map(applied[0].entries.map((e) => [e.number, e.text]))
|
||||
assert.equal(stored.get(1023721), 'a better staff', 'the overlay must win')
|
||||
assert.equal(stored.get(3000001), 'Entering Britannia...')
|
||||
assert.equal(stored.get(900001), 'a shard-only item')
|
||||
|
||||
const overlay = result.sources.find((s) => s.kind === 'custom')
|
||||
assert.equal(overlay.label, 'custom/shard.tsv')
|
||||
assert.equal(overlay.added, 1)
|
||||
assert.equal(overlay.overrode, 1)
|
||||
|
||||
// Only overlay hashes are stored now — the base is fingerprinted separately,
|
||||
// and mixing them is what made the upgrade case above ambiguous.
|
||||
assert.deepEqual(Object.keys(applied[0].meta.hashes), ['custom/shard.tsv'])
|
||||
assert.equal(applied[0].meta.base.sha256, 'abc')
|
||||
})
|
||||
|
||||
test('a malformed overlay names the file rather than failing the import namelessly', async (t) => {
|
||||
const dir = tmpWithOverlay('not a cliloc file at all\n')
|
||||
rig({ clientPath: dir, rows: [{ number: 1, flag: 0, text: 'a' }] })
|
||||
t.after(() => {
|
||||
restore()
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'unavailable')
|
||||
assert.match(result.reason, /custom\/shard\.tsv/)
|
||||
})
|
||||
|
||||
// An overlay path an operator has mistyped must not stop a base table that
|
||||
// arrived perfectly well — but it must be visible, or the site silently serves a
|
||||
// table missing every shard-added name.
|
||||
test('an unreadable overlay path is reported beside a successful import', async (t) => {
|
||||
const applied = rig({
|
||||
clientPath: path.join(os.tmpdir(), 'cliloc-does-not-exist-at-all'),
|
||||
rows: [{ number: 1, flag: 0, text: 'a' }],
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await clilocs.refresh()
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.match(result.overlayProblem, /does not exist/)
|
||||
assert.equal(applied.length, 1)
|
||||
})
|
||||
|
||||
// ── Status ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('status describes the shard source, hash state and drift', async (t) => {
|
||||
rig({ meta: { source: 'bridge', base: FINGERPRINT, hashes: {}, parserVersion: 1, count: 67496 } })
|
||||
t.after(restore)
|
||||
|
||||
const status = await clilocs.status()
|
||||
|
||||
assert.equal(status.source, 'bridge')
|
||||
assert.equal(status.file, 'cliloc.enu')
|
||||
assert.equal(status.fileReadable, true)
|
||||
assert.equal(status.drift, false)
|
||||
assert.equal(status.shard.extractorVersion, 1)
|
||||
assert.equal(status.shard.hashing, false)
|
||||
})
|
||||
|
||||
test('a shard that cannot be reached is a problem on the status, not a throw', async (t) => {
|
||||
rig({})
|
||||
bridge.fingerprint = async () => {
|
||||
throw new bridge.ClilocBridgeError('The shard did not answer: timeout', 'SHARD_DOWN')
|
||||
}
|
||||
t.after(restore)
|
||||
|
||||
const status = await clilocs.status()
|
||||
|
||||
assert.equal(status.source, 'bridge')
|
||||
assert.equal(status.fileReadable, false)
|
||||
assert.equal(status.code, 'SHARD_DOWN')
|
||||
// Null, not false: with no fingerprint there is nothing to compare, and
|
||||
// reporting "no drift" would read as "up to date".
|
||||
assert.equal(status.drift, null)
|
||||
})
|
||||
254
server/test/engagementSeeds.test.js
Normal file
254
server/test/engagementSeeds.test.js
Normal file
@@ -0,0 +1,254 @@
|
||||
// ── The shipped bodies and rules (ENGAGEMENT.md Phase 11b) ─────────────────
|
||||
//
|
||||
// `shardEngagement.test.js` proves the mapper produces the right EVENTS. This
|
||||
// file proves the content shipped alongside them is coherent — which is a
|
||||
// different failure mode and a quieter one: a rule pointing at a template key
|
||||
// that does not exist, or a body built around a variable nothing supplies, is
|
||||
// invisible until somebody enables the rule and a person does not get a mail.
|
||||
//
|
||||
// The three properties worth asserting, none of which a hand run would catch:
|
||||
//
|
||||
// 1. **Every rule names a trigger this module declares, and a template that
|
||||
// exists** — its own or core's nine generic keys.
|
||||
// 2. **Every LABEL a body builds a sentence around is supplied on every path
|
||||
// that emits its trigger.** This is the one that earns its keep. The
|
||||
// fragments are declared `required: false` so a missing one can never
|
||||
// REFUSE an emit — a dropped notification is worse than a cosmetic hole —
|
||||
// and that leaves nothing at runtime to notice a mapper that forgot one.
|
||||
// This test is what notices.
|
||||
// 3. **The plain nine are plain** (decision 9). A security notice drifting
|
||||
// into the in-universe register is exactly the change nobody would think to
|
||||
// review, and it is the one with a real cost attached.
|
||||
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const engagement = require('../utils/shardEngagement')
|
||||
const seeds = require('../config/engagementSeeds')
|
||||
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
|
||||
|
||||
let tracker
|
||||
beforeEach(() => { tracker = engagement.createTracker() })
|
||||
|
||||
const byId = new Map(TRIGGERS.map((t) => [t.id, t]))
|
||||
|
||||
// Core's shipped keys, which a module's rule is allowed to name (§4.6.1
|
||||
// property 1). Spelled out rather than imported: this module cannot require core,
|
||||
// and a key disappearing from core is exactly the breakage worth failing on.
|
||||
const CORE_KEYS = new Set(['notify.event', 'inapp.event', 'notify.digest'])
|
||||
|
||||
// The nine that stay PLAIN (decision 9): security, infrastructure, staff, admin.
|
||||
const PLAIN = new Set([
|
||||
'uo.account.login_failed', 'uo.account.unlinked',
|
||||
'uo.server.up', 'uo.server.down',
|
||||
'uo.page.new', 'uo.cheat.detected',
|
||||
'uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved',
|
||||
])
|
||||
|
||||
// ── The shape of the set ───────────────────────────────────────────────────
|
||||
|
||||
test('every declared trigger has exactly one rule, and every rule a declared trigger', () => {
|
||||
const ruled = seeds.RULES.map((r) => r.trigger_id)
|
||||
assert.equal(new Set(ruled).size, ruled.length, 'no trigger has two rules')
|
||||
assert.deepEqual([...ruled].sort(), TRIGGERS.map((t) => t.id).sort())
|
||||
})
|
||||
|
||||
test('every rule ships disabled, with a cooldown and a per-hour ceiling', () => {
|
||||
for (const r of seeds.RULES) {
|
||||
// `enabled` is not set here at all — the registry forces 0 — so the
|
||||
// assertion is that nobody added it. Q3's invariant, at the source.
|
||||
assert.equal(r.enabled, undefined, `${r.trigger_id} does not set enabled`)
|
||||
assert.ok(Number.isInteger(r.cooldown_seconds), `${r.trigger_id} has a cooldown`)
|
||||
assert.ok(r.max_sends_per_hour >= 1, `${r.trigger_id} has a per-hour ceiling`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every template key a rule names exists — its own or core\'s', () => {
|
||||
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
|
||||
for (const r of seeds.RULES) {
|
||||
for (const [channel, key] of Object.entries(r.template_keys)) {
|
||||
assert.ok(
|
||||
own.has(key) || CORE_KEYS.has(key),
|
||||
`${r.trigger_id}.${channel} names "${key}", which is neither ours nor core's`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('the seventeen in-universe families have both channels; the nine plain ones have neither', () => {
|
||||
const own = new Set(seeds.TEMPLATES.map((t) => t.key))
|
||||
let bespoke = 0
|
||||
for (const r of seeds.RULES) {
|
||||
const usesOwn = Object.values(r.template_keys).some((k) => own.has(k))
|
||||
if (PLAIN.has(r.trigger_id)) {
|
||||
// **Decision 9, as a check.** A security notice written as a letter is
|
||||
// indistinguishable in register from the phishing mail it warns about.
|
||||
assert.equal(usesOwn, false, `${r.trigger_id} must stay plain`)
|
||||
continue
|
||||
}
|
||||
bespoke += 1
|
||||
assert.ok(own.has(r.template_keys.email), `${r.trigger_id} has an in-universe email body`)
|
||||
// Both channels in the same voice: one rule fires on both at once, and a
|
||||
// player who reads the inbox item and then the mail must not meet two
|
||||
// different narrators.
|
||||
assert.ok(own.has(r.template_keys.inapp), `${r.trigger_id} has an in-universe in-app body`)
|
||||
// The DIGEST stays core's. A day of events rolled into a list is not a
|
||||
// letter from anybody.
|
||||
assert.equal(r.template_keys.digest, 'notify.digest', `${r.trigger_id} digests generically`)
|
||||
}
|
||||
// Eighteen since protocol 6: the champion FALLS, in the same crier's voice as
|
||||
// the champion walking, because they are one story told in two mails.
|
||||
assert.equal(bespoke, 18)
|
||||
assert.equal(seeds.TEMPLATES.length, 36)
|
||||
})
|
||||
|
||||
test('a template key is core\'s grammar — dots and hyphens, never an underscore', () => {
|
||||
// `uo.champ.boss_up` is a legal TRIGGER id and an illegal TEMPLATE key, which
|
||||
// is a genuinely confusing pair and the reason this is asserted rather than
|
||||
// remembered. Caught at registration too, as a boot failure.
|
||||
const KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
|
||||
for (const t of seeds.TEMPLATES) {
|
||||
assert.ok(KEY.test(t.key), `${t.key} matches core's template-key grammar`)
|
||||
assert.ok(t.key.startsWith('uo.'), `${t.key} is namespaced`)
|
||||
assert.ok(TRIGGER_IDS.has(t.triggerId), `${t.key} binds a declared trigger`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an email body has a subject and an in-app body has none', () => {
|
||||
for (const t of seeds.TEMPLATES) {
|
||||
if (t.channel === 'email') assert.ok(t.subject, `${t.key} has a subject`)
|
||||
else assert.equal(t.subject, null, `${t.key} leaves the email column NULL`)
|
||||
}
|
||||
})
|
||||
|
||||
test('no body names a brand, a colour or a logo (§4.6.1 property 2)', () => {
|
||||
// One prebuilt image mails as any shard. An in-universe body is UO-specific
|
||||
// and must still be shard-agnostic.
|
||||
const json = JSON.stringify(seeds.TEMPLATES)
|
||||
for (const forbidden of ['#', 'UOMysticmoon', 'http://', 'https://']) {
|
||||
assert.equal(json.includes(forbidden), false, `no body contains "${forbidden}"`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The property the render sweep needed ───────────────────────────────────
|
||||
|
||||
// Every LABEL — the fragments a sentence is built AROUND, as opposed to the
|
||||
// trailing ones that may legitimately be empty. A frame that exercises each.
|
||||
const LABELLED = [
|
||||
['uo.house.idoc_warning', ['houseLabel', 'stageLabel'],
|
||||
{ kind: 'house.decay', serial: '0x40012345', to: 'GREATLY', from: 'FAIRLY', ownerAcct: 'darrow' }],
|
||||
['uo.house.collapsed', ['houseLabel'],
|
||||
{ kind: 'house.decay', serial: '0x40012345', to: 'COLLAPSED', ownerAcct: 'darrow' }],
|
||||
['uo.vendor.sale', ['shopLabel', 'itemLine'],
|
||||
{ kind: 'vendor.sale', vendorSerial: '0x1', itemType: 'Iron Ingot', price: 100, ownerAcct: 'darrow' }],
|
||||
['uo.points.rank_changed', ['boardLabel', 'standingLine'],
|
||||
{ kind: 'points.board', system: 'Virtue', top: [{ rank: 1, serial: '0x9', name: 'Darrow' }] }],
|
||||
// `autoPickWhen` is a label in the same sense: "Attend before {{autoPickWhen}}"
|
||||
// has a hole in it without one. It is `required: false` like the others and
|
||||
// guaranteed by the mapper's own guard — `uo.election.opened` is not emitted at
|
||||
// all unless the frame carried `autoPickAt`.
|
||||
['uo.election.opened', ['phaseLabel', 'autoPickWhen'],
|
||||
{ kind: 'city.update', city: 'Britain', electionPhase: 'nominate', autoPickAt: '2026-09-04T00:00:00Z' }],
|
||||
['uo.house.refreshed', ['houseLabel'],
|
||||
{ kind: 'house.decay', serial: '0x40012345', to: 'LIKENEW', from: 'GREATLY', ownerAcct: 'darrow' }],
|
||||
]
|
||||
|
||||
test('every label a body builds a sentence around is supplied by the mapper', () => {
|
||||
for (const [triggerId, labels, frame] of LABELLED) {
|
||||
// A first frame is never a transition, so the upsert kinds need a prior one.
|
||||
engagement.mapShardEvent(
|
||||
{ ...frame, top: frame.top && [{ rank: 1, serial: '0x0', name: 'Mireille' }], electionPhase: frame.electionPhase && 'none' },
|
||||
tracker,
|
||||
)
|
||||
const targets = engagement.mapShardEvent(frame, tracker)
|
||||
const target = targets.find((t) => t.triggerId === triggerId)
|
||||
assert.ok(target, `${triggerId} fired`)
|
||||
for (const label of labels) {
|
||||
assert.ok(
|
||||
target.data[label] !== undefined && target.data[label] !== '',
|
||||
`${triggerId} supplies ${label} — a body builds a sentence around it`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a label is supplied even when every optional field is absent', () => {
|
||||
// The case the render sweep modelled: a v4 overlay, a house with no name and
|
||||
// no region. `houseLabel` falls back to the seal number, which is worse prose
|
||||
// and better than "Be it known that , recorded to thy name".
|
||||
const target = engagement.mapShardEvent(
|
||||
{ kind: 'house.decay', serial: '0x40012345', to: 'IDOC', ownerAcct: 'darrow' },
|
||||
tracker,
|
||||
)[0]
|
||||
assert.match(target.data.houseLabel, /0x40012345/)
|
||||
assert.equal(target.data.stageLabel, 'in imminent danger of collapse')
|
||||
// The detail line names only what the frame carried — "Recorded at: ." is the
|
||||
// shape this avoids. The stage is always there, so the line is too; a house
|
||||
// with no coordinates simply does not get the "Recorded at" half.
|
||||
assert.equal(target.data.whereLine, 'Stage entered: IDOC.')
|
||||
})
|
||||
|
||||
test('a detail line names only the parts the frame actually carried', () => {
|
||||
engagement.mapShardEvent({ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', fees: { exempt: true } }, tracker)
|
||||
const at = new Date(Date.now() + 3600_000).toISOString()
|
||||
const target = engagement.mapShardEvent(
|
||||
{ kind: 'vendor.listing', serial: '0x1', ownerAcct: 'd', shopName: 'The Anvil', fees: { dismissalAt: at, funds: 1200 } },
|
||||
tracker,
|
||||
)[0]
|
||||
assert.equal(target.triggerId, 'uo.vendor.expiring')
|
||||
assert.match(target.data.ledgerLine, /On hand: 1200 gold/)
|
||||
assert.equal(target.data.ledgerLine.includes('Charged each period'), false)
|
||||
})
|
||||
|
||||
// ── Trailing fragments ─────────────────────────────────────────────────────
|
||||
|
||||
test('a trailing fragment leads with its own space, or is absent entirely', () => {
|
||||
// `{{slainBy}}.` must close as "has fallen." with no fragment and
|
||||
// "has fallen at the hands of a lich lord." with one. A fragment that forgot
|
||||
// its leading space produces "has fallenat the hands of" and nothing would
|
||||
// notice.
|
||||
const withKiller = engagement.mapShardEvent(
|
||||
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' }, killer: { name: 'a lich lord' } },
|
||||
tracker,
|
||||
)[0]
|
||||
assert.equal(withKiller.data.slainBy, ' at the hands of a lich lord')
|
||||
|
||||
const without = engagement.mapShardEvent(
|
||||
{ kind: 'player.death', who: { name: 'Darrow', acct: 'darrow' } },
|
||||
tracker,
|
||||
)[0]
|
||||
assert.equal(without.data.slainBy, undefined)
|
||||
})
|
||||
|
||||
test('every declared fragment carries an example that shows its own shape', () => {
|
||||
// The `example` is what the template editor previews and test-sends with, so a
|
||||
// trailing fragment whose example omits the leading space teaches an author the
|
||||
// wrong thing about where to put one.
|
||||
const TRAILING = ['slainBy', 'atPlace', 'inSuccessionTo', 'candidateNote', 'damagerNote']
|
||||
for (const t of TRIGGERS) {
|
||||
for (const v of t.variables.filter((x) => TRAILING.includes(x.name))) {
|
||||
assert.ok(v.example.startsWith(' '), `${t.id}.${v.name} example leads with its space`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── The group key ──────────────────────────────────────────────────────────
|
||||
|
||||
test('one rule group, and appending to it later would reach fresh installs only', () => {
|
||||
// A group is seeded ONCE under its own settings guard, which is 11a's seed-key
|
||||
// finding as a mechanism. This assertion exists so that adding a twenty-sixth
|
||||
// rule has to edit a test whose name says what appending costs.
|
||||
// TWO groups since protocol 6, and the second one is this test's whole point
|
||||
// made concrete: `uo.champ.boss_killed` could not be appended to `triggers-v1`,
|
||||
// because a deployment that has already stamped that key would never have
|
||||
// received it. A new rule gets a new key.
|
||||
assert.equal(seeds.RULE_GROUPS.length, 2)
|
||||
assert.equal(seeds.RULE_GROUPS[0].key, 'triggers-v1')
|
||||
assert.equal(seeds.RULE_GROUPS[0].rules.length, 26)
|
||||
assert.equal(seeds.RULE_GROUPS[1].key, 'champ-boss-killed-v1')
|
||||
assert.deepEqual(seeds.RULE_GROUPS[1].rules.map((r) => r.trigger_id), ['uo.champ.boss_killed'])
|
||||
// No rule belongs to two groups, and between them they are the whole set.
|
||||
const grouped = seeds.RULE_GROUPS.flatMap((g) => g.rules.map((r) => r.trigger_id))
|
||||
assert.equal(new Set(grouped).size, grouped.length)
|
||||
assert.deepEqual([...grouped].sort(), seeds.RULES.map((r) => r.trigger_id).sort())
|
||||
})
|
||||
@@ -53,6 +53,81 @@ test('registers exactly what module.json declares', () => {
|
||||
|
||||
assert.deepStrictEqual(api.record.extensions.map((e) => e.slot), manifest.extensions)
|
||||
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['towncrier'])
|
||||
|
||||
// The event contract (MODULE_API 1.10.0, EVENTS_PLAN.md Phase 9). Asserted
|
||||
// here rather than only in the actions' own suite because registration is the
|
||||
// half that can silently not happen: a declaration file nothing calls is a
|
||||
// deployment whose event authors simply never see the verbs, with no error
|
||||
// anywhere.
|
||||
assert.deepStrictEqual(
|
||||
api.record.eventActions.map((a) => a.id).sort(),
|
||||
[
|
||||
'uo.boss.spawn',
|
||||
'uo.broadcast',
|
||||
'uo.creature.spawn',
|
||||
'uo.decor.place',
|
||||
'uo.gate.open',
|
||||
'uo.item.grant',
|
||||
'uo.news.post',
|
||||
'uo.npc.place',
|
||||
'uo.participation.collect',
|
||||
'uo.participation.open',
|
||||
'uo.towncrier.post',
|
||||
'uo.world.save',
|
||||
],
|
||||
)
|
||||
// Phase 12a's five are all the MODULE's dimensions, never core's (org lead,
|
||||
// 2026-09-07): core meters whatever a module declares and knows nothing about
|
||||
// Ultima Online. Asserted as an ordered list because the order is the order
|
||||
// an author meets them in a cap meter.
|
||||
assert.deepStrictEqual(api.record.eventBudgets.map((b) => b.id), [
|
||||
'uo.broadcasts',
|
||||
'uo.creatures',
|
||||
'uo.bosses',
|
||||
'uo.npcs',
|
||||
'uo.decor',
|
||||
'uo.gate.minutes',
|
||||
'uo.rewards',
|
||||
])
|
||||
// Phase 11b. One key, because ServUO has almost no others: of the 158 non-Bridge
|
||||
// `Config.Get` call sites in `Scripts/`, roughly eight are read live, and a lease
|
||||
// on any of the rest applies cleanly and does nothing.
|
||||
// Phase 12b adds five TARGETED leases beside it -- a key that names a capability
|
||||
// over many things, with the target supplied per step. Four spawner properties
|
||||
// (`MaxCount`, not the `Amount` EVENTS_PLAN.md named: there is no such property
|
||||
// on ServUO 57.4) and the seasonal status, which is a three-value enum over eight
|
||||
// events rather than the nine-value one section G described.
|
||||
assert.deepStrictEqual(api.record.eventLeases.map((l) => l.id), [
|
||||
'uo.playercaps.skillcap',
|
||||
'uo.spawner.maxcount',
|
||||
'uo.spawner.mindelay',
|
||||
'uo.spawner.maxdelay',
|
||||
'uo.spawner.running',
|
||||
'uo.seasonal.status',
|
||||
])
|
||||
// Only the targeted ones declare a target, and every one of them names a source:
|
||||
// a target field with no list behind it is the free-text box the option-source
|
||||
// contract exists to replace.
|
||||
for (const lease of api.record.eventLeases) {
|
||||
if (lease.id === 'uo.playercaps.skillcap') {
|
||||
assert.strictEqual(lease.target, undefined, 'a config lease has no target')
|
||||
continue
|
||||
}
|
||||
assert.ok(lease.target && lease.target.label, `${lease.id} has no target label`)
|
||||
assert.ok(lease.target.source, `${lease.id} has no target source`)
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
api.record.eventOptionSources.map((s) => s.id).sort(),
|
||||
[
|
||||
'uo.options.creatures',
|
||||
'uo.options.decor',
|
||||
'uo.options.items',
|
||||
'uo.options.landmarks',
|
||||
'uo.options.regions',
|
||||
'uo.options.seasonal',
|
||||
'uo.options.spawners',
|
||||
],
|
||||
)
|
||||
assert.ok(api.record.streams.length > 0)
|
||||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||||
@@ -85,6 +160,35 @@ test('every registered stream is namespaced or grandfathered', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('registers a Team provider with all three methods', () => {
|
||||
// Core requires all three: a provider that could list Teams but not their
|
||||
// members would leave core holding Teams it can never populate, which is not
|
||||
// the same as a call that fails. Asserted here so a refactor that drops one
|
||||
// fails in this suite rather than at load on an operator's install.
|
||||
const api = fakeApi()
|
||||
register(fakeCtx(), api)
|
||||
|
||||
const provider = api.record.teamProvider
|
||||
assert.ok(provider, 'a UO guild is a Team; something has to answer for them')
|
||||
for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
|
||||
assert.strictEqual(typeof provider[method], 'function', `${method} is missing`)
|
||||
}
|
||||
})
|
||||
|
||||
test('registration does not call the provider, or touch the database', async () => {
|
||||
// register() runs while core's app.js is still being required, with the pool
|
||||
// pointed at a dead port — routeManifest.js and swagger.js both depend on that.
|
||||
// Registration is a CLAIM; core does not ask anything until it reconciles,
|
||||
// which is after onBoot.
|
||||
const ctx = fakeCtx()
|
||||
let queried = false
|
||||
const frozen = Object.freeze({ ...ctx, db: Object.freeze({ query: async () => { queried = true; return [] } }) })
|
||||
const api = fakeApi()
|
||||
|
||||
register(frozen, api)
|
||||
assert.equal(queried, false, 'a query at registration time would hang the manifest and the spec build')
|
||||
})
|
||||
|
||||
test('takes a frozen ctx and does not try to write to it', () => {
|
||||
const ctx = fakeCtx()
|
||||
assert.ok(Object.isFrozen(ctx))
|
||||
|
||||
170
server/test/frozenManifest.test.js
Normal file
170
server/test/frozenManifest.test.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// The frozen manifest's derivation, checked.
|
||||
//
|
||||
// `scripts/frozenManifest.js` runs in one place — a CI job with a whole core
|
||||
// checked out beside it — so it is the least-exercised piece of machinery in this
|
||||
// repo, and it is the piece that decides whether the URLs this module claims are
|
||||
// the URLs it serves (MODULE_API.md §5.3). Its three answers are pure functions of
|
||||
// two manifests and a fragment, so all three are asked here, with fixtures rather
|
||||
// than a clone.
|
||||
//
|
||||
// What is deliberately NOT asserted here: the numbers. `routes.manifest.json`'s
|
||||
// 72 routes are proved by the job that generates them from a real core, and a
|
||||
// copy of that count in this file would only ever be a second thing to update.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { diffManifests, coverage, MANIFEST, FRAGMENT } = require('../scripts/frozenManifest')
|
||||
|
||||
const manifest = (public_ = [], internal = []) => ({ public: public_, internal })
|
||||
const get = (p) => ({ method: 'GET', path: p })
|
||||
|
||||
test('the module\'s routes are the ones a core gains by loading it', () => {
|
||||
const before = manifest([get('/api/v1/public/settings')])
|
||||
const after = manifest([get('/api/v1/public/settings'), get('/api/v1/public/shard/status')])
|
||||
|
||||
const { added, removed } = diffManifests(before, after)
|
||||
assert.deepStrictEqual(removed, [])
|
||||
assert.deepStrictEqual(added, [{ method: 'GET', path: '/api/v1/public/shard/status', tier: 'public' }])
|
||||
})
|
||||
|
||||
test('a route core loses to the module is reported, not quietly absorbed', () => {
|
||||
// The failure this exists for: a module whose mount displaces a core route.
|
||||
// It cannot show up as an addition — the URL is unchanged — so a check that
|
||||
// only looked at what appeared would call this clean.
|
||||
const before = manifest([get('/api/v1/public/settings'), get('/api/v1/public/status')])
|
||||
const after = manifest([get('/api/v1/public/settings')])
|
||||
|
||||
const { removed } = diffManifests(before, after)
|
||||
assert.deepStrictEqual(removed, ['public GET /api/v1/public/status'])
|
||||
})
|
||||
|
||||
test('a route whose METHOD changed counts as removed and added', () => {
|
||||
const { added, removed } = diffManifests(
|
||||
manifest([{ method: 'POST', path: '/api/v1/admin/thing' }]),
|
||||
manifest([{ method: 'PUT', path: '/api/v1/admin/thing' }]),
|
||||
)
|
||||
assert.deepStrictEqual(removed, ['public POST /api/v1/admin/thing'])
|
||||
assert.strictEqual(added.length, 1)
|
||||
})
|
||||
|
||||
test('the internal app is diffed too, and keeps its own tier', () => {
|
||||
const { added } = diffManifests(
|
||||
manifest([], [get('/internal/health')]),
|
||||
manifest([], [get('/internal/health'), get('/internal/uo/thing')]),
|
||||
)
|
||||
assert.deepStrictEqual(added, [{ method: 'GET', path: '/internal/uo/thing', tier: 'internal' }])
|
||||
})
|
||||
|
||||
test('added routes are sorted, so the committed file does not churn on traversal order', () => {
|
||||
const { added } = diffManifests(
|
||||
manifest([]),
|
||||
manifest([get('/b'), get('/a'), { method: 'POST', path: '/a' }]),
|
||||
)
|
||||
assert.deepStrictEqual(added.map((r) => `${r.method} ${r.path}`), ['GET /a', 'GET /b', 'POST /a'])
|
||||
})
|
||||
|
||||
// ── coverage: the route ⇄ fragment agreement ────────────────────────────────
|
||||
|
||||
const fragment = (paths) => ({ paths })
|
||||
|
||||
test('a served route with no documented operation is named', () => {
|
||||
const { undocumented, unserved } = coverage(
|
||||
[get('/api/v1/public/shard/status')],
|
||||
fragment({}),
|
||||
)
|
||||
assert.deepStrictEqual(undocumented, ['GET /api/v1/public/shard/status'])
|
||||
assert.deepStrictEqual(unserved, [])
|
||||
})
|
||||
|
||||
test('a documented operation nobody serves is named too', () => {
|
||||
// The direction core's own spec has no check for, which is how it accumulated
|
||||
// four orphan tags and thirty-three orphan schemas describing routes that had
|
||||
// moved to this repo. A documented URL nobody serves is a client following the
|
||||
// docs into a 404.
|
||||
const { undocumented, unserved } = coverage(
|
||||
[],
|
||||
fragment({ '/api/v1/public/shard/gone': { get: {} } }),
|
||||
)
|
||||
assert.deepStrictEqual(undocumented, [])
|
||||
assert.deepStrictEqual(unserved, ['GET /api/v1/public/shard/gone'])
|
||||
})
|
||||
|
||||
test('express :params and OpenAPI {params} are the same route', () => {
|
||||
const { undocumented, unserved } = coverage(
|
||||
[{ method: 'DELETE', path: '/api/v1/admin/users/:id/shard/link/:account' }],
|
||||
fragment({ '/api/v1/admin/users/{id}/shard/link/{account}': { delete: {} } }),
|
||||
)
|
||||
assert.deepStrictEqual(undocumented, [])
|
||||
assert.deepStrictEqual(unserved, [])
|
||||
})
|
||||
|
||||
test('methods are matched, not just paths', () => {
|
||||
const { undocumented, unserved } = coverage(
|
||||
[{ method: 'POST', path: '/api/v1/admin/shard/kick' }],
|
||||
fragment({ '/api/v1/admin/shard/kick': { get: {} } }),
|
||||
)
|
||||
assert.deepStrictEqual(undocumented, ['POST /api/v1/admin/shard/kick'])
|
||||
assert.deepStrictEqual(unserved, ['GET /api/v1/admin/shard/kick'])
|
||||
})
|
||||
|
||||
// ── the committed artifacts, against each other ─────────────────────────────
|
||||
//
|
||||
// These two files are generated together by a job that has a real core; here
|
||||
// there is no core, so what can still be asked is whether they agree with each
|
||||
// other. If they do not, one of them was committed without the other.
|
||||
|
||||
test('every route in the committed manifest has a committed operation', () => {
|
||||
const routes = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')).routes
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
const { undocumented, unserved } = coverage(routes, spec)
|
||||
assert.deepStrictEqual(undocumented, [], 'routes.manifest.json lists routes swagger-fragment.json does not document')
|
||||
assert.deepStrictEqual(unserved, [], 'swagger-fragment.json documents operations routes.manifest.json does not list')
|
||||
})
|
||||
|
||||
test('the fragment carries only the three sections §6.1a allows', () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
assert.deepStrictEqual(Object.keys(spec).sort(), ['components', 'paths', 'tags'])
|
||||
assert.deepStrictEqual(Object.keys(spec.components), ['schemas'])
|
||||
})
|
||||
|
||||
test('the fragment defines only namespaced schemas, and redefines none of core\'s', () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
for (const name of Object.keys(spec.components.schemas)) {
|
||||
assert.match(name, /^Uo[A-Z]/, `${name} is not namespaced — core wins the collision and drops it (§6.1a)`)
|
||||
}
|
||||
// Core's shared schemas are REFERENCED by their core names and not redefined;
|
||||
// they resolve in the merged document, which is the whole point of a fragment.
|
||||
const refs = JSON.stringify(spec.paths).match(/#\/components\/schemas\/([A-Za-z0-9_]+)/g) || []
|
||||
const core = [...new Set(refs.map((r) => r.split('/').pop()))].filter((n) => !n.startsWith('Uo'))
|
||||
assert.deepStrictEqual(core.sort(), ['Error', 'ValidationError'])
|
||||
})
|
||||
|
||||
test('every path in the fragment is fully qualified', () => {
|
||||
const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8'))
|
||||
for (const p of Object.keys(spec.paths)) {
|
||||
// §6.1a: core merges the fragment verbatim and never re-derives a prefix, so
|
||||
// a router-relative path here is a path nothing serves.
|
||||
assert.match(p, /^\/api\/v1\/(public|admin|player)\//, `${p} is not a fully-qualified URL`)
|
||||
assert.doesNotMatch(p, /\/$/, `${p} has a trailing slash — no client calls that URL`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the manifest and the module\'s declared mounts agree', () => {
|
||||
const routes = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')).routes
|
||||
const { mounts } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8'))
|
||||
|
||||
const declared = []
|
||||
for (const [tier, prefixes] of Object.entries(mounts)) {
|
||||
for (const prefix of prefixes) declared.push(`/api/v1/${tier}${prefix}/`)
|
||||
}
|
||||
// The extension slot is core's resource, not one of our mounts (§2.4).
|
||||
const slot = '/api/v1/admin/users/'
|
||||
|
||||
for (const route of routes) {
|
||||
const under = declared.some((d) => route.path.startsWith(d)) || route.path.startsWith(slot)
|
||||
assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`)
|
||||
}
|
||||
})
|
||||
148
server/test/guildCommand.test.js
Normal file
148
server/test/guildCommand.test.js
Normal file
@@ -0,0 +1,148 @@
|
||||
// `/guild` — the chat command registered through `api.registerSlashCommands`
|
||||
// (TEAMS.md §7.1, MODULE_API 1.6.0).
|
||||
//
|
||||
// The properties worth pinning are all about the ANSWER being the same answer
|
||||
// the website gives, because that is the whole risk of a second surface: the
|
||||
// audience rungs are re-resolved here rather than assumed, the shard's own
|
||||
// offline guard is honoured, and the link prompt appears only when linking would
|
||||
// actually change what the caller is told.
|
||||
|
||||
const { test, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const command = require('../commands/guild.command')
|
||||
const db = require('../model/teamProvider/teamProvider.db')
|
||||
const provider = require('../model/teamProvider/teamProvider.model')
|
||||
const visibility = require('../utils/shardVisibility')
|
||||
|
||||
const originals = {
|
||||
getConfig: visibility.getConfig,
|
||||
viewerLevel: visibility.viewerLevel,
|
||||
boardIsCurrent: provider.boardIsCurrent,
|
||||
listGuilds: db.listGuilds,
|
||||
listGuildMembers: db.listGuildMembers,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
visibility.getConfig = originals.getConfig
|
||||
visibility.viewerLevel = originals.viewerLevel
|
||||
provider.boardIsCurrent = originals.boardIsCurrent
|
||||
db.listGuilds = originals.listGuilds
|
||||
db.listGuildMembers = originals.listGuildMembers
|
||||
})
|
||||
|
||||
const GUILDS = [
|
||||
{ id: 7, name: 'Knights of the Codex', abbr: 'KOC', alliance: 'The Accord', members: 12, online: 3, leader_name: 'Dain' },
|
||||
{ id: 9, name: 'Knights Hospitaller', abbr: 'KH', alliance: null, members: 4, online: 0, leader_name: null },
|
||||
]
|
||||
|
||||
const MEMBERS = [
|
||||
{ serial: 1, name: 'Dain', rank: 4, web_id: '31', linked_user_id: null },
|
||||
{ serial: 2, name: 'Elowen', rank: 4, web_id: null, linked_user_id: 44 },
|
||||
{ serial: 3, name: 'Wat', rank: 2, web_id: null, linked_user_id: null },
|
||||
]
|
||||
|
||||
function stub({ audience = 'anonymous', enabled = true, level = 'anonymous', current = true } = {}) {
|
||||
visibility.getConfig = async () => ({ guilds: { enabled, audience } })
|
||||
visibility.viewerLevel = async () => level
|
||||
provider.boardIsCurrent = async () => (current ? { ok: true } : { ok: false, reason: 'socket down' })
|
||||
db.listGuilds = async () => GUILDS
|
||||
db.listGuildMembers = async () => MEMBERS
|
||||
}
|
||||
|
||||
const anonymous = { platform: 'discord', platformUserId: '1', userId: null, role: null, isLinked: false, isStaff: false }
|
||||
const linked = { platform: 'discord', platformUserId: '2', userId: 31, role: 'player', isLinked: true, isStaff: false }
|
||||
|
||||
test('the definition stays inside the option schema §7.1.1 allows', () => {
|
||||
assert.equal(command.name, 'guild')
|
||||
assert.equal(command.access, 'everyone')
|
||||
for (const option of command.options) {
|
||||
assert.ok(['string', 'integer', 'boolean', 'user'].includes(option.type))
|
||||
assert.ok(option.description.length <= 100)
|
||||
}
|
||||
})
|
||||
|
||||
test('the guilds feature being off withholds everything, staff included', async () => {
|
||||
stub({ enabled: false, level: 'admin' })
|
||||
const res = await command.handler({ options: {}, actor: { ...linked, role: 'admin', isStaff: true } })
|
||||
assert.match(res.text, /does not publish guild information/)
|
||||
assert.equal(res.ephemeral, true)
|
||||
})
|
||||
|
||||
// The reason this command is not a thin wrapper over a public route: a rung
|
||||
// below the feature's audience must be refused HERE, or a shard that gates
|
||||
// guilds to staff would publish them to a Discord channel.
|
||||
test('a caller below the feature audience is refused', async () => {
|
||||
stub({ audience: 'staff', level: 'anonymous' })
|
||||
const res = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.match(res.text, /not shown to your account/)
|
||||
assert.equal(res.ephemeral, true)
|
||||
})
|
||||
|
||||
test('an unlinked caller is invited to link — but only when linking would change the answer', async () => {
|
||||
stub({ audience: 'player', level: 'anonymous' })
|
||||
const gated = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.match(gated.notice, /Link your account/)
|
||||
|
||||
// Public guilds: there is nothing more to see, so there is nothing to prompt.
|
||||
stub({ audience: 'anonymous', level: 'anonymous' })
|
||||
const open = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.equal(open.notice, null)
|
||||
|
||||
// Gated to staff: linking reaches `player` and stops there, so the invitation
|
||||
// would be an instruction to do something that changes nothing. Found on the
|
||||
// live rig, where a staff-gated shard still offered it.
|
||||
stub({ audience: 'staff', level: 'anonymous' })
|
||||
const unreachable = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.match(unreachable.text, /not shown to your account/)
|
||||
assert.equal(unreachable.notice, null)
|
||||
})
|
||||
|
||||
test('a stale board answers offline rather than reporting what it still holds', async () => {
|
||||
stub({ current: false })
|
||||
const res = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.match(res.text, /not connected right now/)
|
||||
})
|
||||
|
||||
test('no argument lists the largest guilds', async () => {
|
||||
stub()
|
||||
const res = await command.handler({ options: {}, actor: anonymous })
|
||||
assert.equal(res.title, 'Guilds on this shard')
|
||||
assert.equal(res.fields.length, 2)
|
||||
assert.match(res.fields[0].name, /Knights of the Codex/)
|
||||
assert.match(res.fields[0].value, /12 members · 3 online/)
|
||||
})
|
||||
|
||||
test('a name resolves by abbreviation, then exactly, then by unique prefix', async () => {
|
||||
stub()
|
||||
const byAbbr = await command.handler({ options: { name: 'koc' }, actor: anonymous })
|
||||
assert.match(byAbbr.title, /Knights of the Codex/)
|
||||
|
||||
const exact = await command.handler({ options: { name: 'Knights Hospitaller' }, actor: anonymous })
|
||||
assert.match(exact.title, /Hospitaller/)
|
||||
|
||||
// "knights" hits both, and answering with either would be worse than asking.
|
||||
const ambiguous = await command.handler({ options: { name: 'knights' }, actor: anonymous })
|
||||
assert.match(ambiguous.text, /Several guilds match/)
|
||||
assert.equal(ambiguous.ephemeral, true)
|
||||
})
|
||||
|
||||
test('a miss is an answer, not a failure', async () => {
|
||||
stub()
|
||||
const res = await command.handler({ options: { name: 'nobody' }, actor: anonymous })
|
||||
assert.match(res.text, /No guild matches/)
|
||||
})
|
||||
|
||||
// `linked` counts BOTH sources the roster uses — the shard's asserted web id and
|
||||
// the link table — because that is what "linked" means everywhere else here.
|
||||
test('the detail carries the counts, the leaders and a link to the module page', async () => {
|
||||
stub({ level: 'player' })
|
||||
const res = await command.handler({ options: { name: 'KOC' }, actor: linked })
|
||||
const field = (name) => res.fields.find((f) => f.name === name).value
|
||||
assert.equal(field('Members'), '12')
|
||||
assert.equal(field('Online'), '3')
|
||||
assert.equal(field('Linked accounts'), '2')
|
||||
assert.equal(field('Leaders'), 'Dain, Elowen')
|
||||
assert.match(res.url, /\/uo\/guilds\/7$/)
|
||||
assert.equal(res.notice, null)
|
||||
})
|
||||
@@ -121,7 +121,11 @@ test('every table this fragment declares is prefixed shard_ or uo_link_', () =>
|
||||
|
||||
// ── The settings rows this module owns ──────────────────────────────────────
|
||||
|
||||
const SETTINGS_KEYS = ['game_account_signup', 'uo_link_protocol_3_migrated']
|
||||
const SETTINGS_KEYS = [
|
||||
'game_account_signup',
|
||||
'uo_link_protocol_3_migrated',
|
||||
'uo_link_protocol_4_migrated',
|
||||
]
|
||||
|
||||
test('both settings seeds are INSERT IGNORE, so a replay never resets a value', () => {
|
||||
for (const key of SETTINGS_KEYS) {
|
||||
@@ -131,6 +135,95 @@ test('both settings seeds are INSERT IGNORE, so a replay never resets a value',
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// ── The protocol pin ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Two declaration sites and one constant have to agree, and for a while they did
|
||||
// not: the protocol-4 cutover moved `link`, the overlay and this module's ingest,
|
||||
// and left both pins here at 3. A fresh install then spoke 3 to a protocol-4
|
||||
// sidecar, which 409s every REST call — an install that reads nothing from its
|
||||
// shard, with the cause only in the log. These tests are the guard.
|
||||
|
||||
// The protocol this build speaks, read from the model rather than written here.
|
||||
//
|
||||
// Hardcoding the number in this test is what the protocol-4 bug looked like from the
|
||||
// other side: the emitters moved, one declaration site did not, and every site agreed
|
||||
// with itself. Reading DEFAULT_PROTOCOL makes the assertion "the three declarations
|
||||
// AGREE" rather than "they all say 4", so a bump that misses one of them fails here
|
||||
// instead of on an operator's install.
|
||||
const { DEFAULT_PROTOCOL } = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
|
||||
test('the column default pins the protocol this build speaks', () => {
|
||||
assert.ok(Number.isInteger(DEFAULT_PROTOCOL) && DEFAULT_PROTOCOL > 0, 'no protocol pin exported')
|
||||
|
||||
const create = statements.find((s) => /CREATE TABLE.*uo_link_config/is.test(s))
|
||||
assert.ok(create, 'uo_link_config is gone')
|
||||
assert.match(
|
||||
create,
|
||||
new RegExp('protocol +INT +NOT NULL DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
|
||||
'the CREATE TABLE default must name the protocol this build speaks',
|
||||
)
|
||||
|
||||
// The last MODIFY wins on replay, so it is the one that decides an existing
|
||||
// database's default.
|
||||
const modifies = statements.filter((s) =>
|
||||
/^ALTER TABLE\s+uo_link_config\s+MODIFY COLUMN protocol/i.test(s),
|
||||
)
|
||||
assert.ok(modifies.length > 0, 'the default-fixing MODIFY is gone')
|
||||
assert.match(
|
||||
modifies[modifies.length - 1],
|
||||
new RegExp('DEFAULT ' + DEFAULT_PROTOCOL + '(?![0-9])', 'i'),
|
||||
)
|
||||
})
|
||||
|
||||
// The one-shot migration for the CURRENT protocol, whatever it is. Same argument as
|
||||
// above: these three assertions used to be written once per version by hand, so the
|
||||
// version that mattered — the newest — was the one with no test until someone
|
||||
// remembered to copy the block.
|
||||
test('the current protocol has a one-shot migration, correctly ordered and guarded', () => {
|
||||
const marker = `uo_link_protocol_${DEFAULT_PROTOCOL}_migrated`
|
||||
|
||||
const update = statements.findIndex(
|
||||
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes(marker),
|
||||
)
|
||||
const insert = statements.findIndex((s) => /^INSERT/i.test(s) && s.includes(`'${marker}'`))
|
||||
|
||||
assert.ok(update >= 0, `no migration to protocol ${DEFAULT_PROTOCOL}`)
|
||||
assert.ok(insert >= 0, `no one-shot marker for protocol ${DEFAULT_PROTOCOL}`)
|
||||
assert.ok(insert > update, 'the marker is written before the UPDATE reads it')
|
||||
|
||||
// `protocol < N`, never `= N-1`: an install that missed an earlier migration has to
|
||||
// be carried the whole way rather than one step.
|
||||
assert.match(
|
||||
statements[update],
|
||||
new RegExp('protocol *< *' + DEFAULT_PROTOCOL + '(?![0-9])'),
|
||||
)
|
||||
})
|
||||
|
||||
test('the protocol-4 marker is written AFTER the update that reads it', () => {
|
||||
const update = statements.findIndex(
|
||||
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes('uo_link_protocol_4_migrated'),
|
||||
)
|
||||
const marker = statements.findIndex(
|
||||
(s) => /^INSERT/i.test(s) && s.includes("'uo_link_protocol_4_migrated'"),
|
||||
)
|
||||
assert.ok(update >= 0, 'the protocol-4 migration is gone')
|
||||
assert.ok(marker >= 0, 'the one-shot marker is gone')
|
||||
assert.ok(marker > update, 'the marker is written before the UPDATE reads it')
|
||||
})
|
||||
|
||||
test('the protocol-4 one-shot carries an install forward from any older pin', () => {
|
||||
const update = statements.find(
|
||||
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes('uo_link_protocol_4_migrated'),
|
||||
)
|
||||
assert.match(
|
||||
update,
|
||||
/protocol\s*<\s*4/,
|
||||
'must be `protocol < 4`, not `= 3`: an install that never took the protocol-3 ' +
|
||||
'migration has to be carried the whole way rather than one step',
|
||||
)
|
||||
})
|
||||
|
||||
test('the protocol-3 marker is written AFTER the update that reads it', () => {
|
||||
const update = statements.findIndex(
|
||||
(s) => /^UPDATE\s+uo_link_config/i.test(s) && s.includes('uo_link_protocol_3_migrated'),
|
||||
|
||||
559
server/test/shardAssets.model.test.js
Normal file
559
server/test/shardAssets.model.test.js
Normal file
@@ -0,0 +1,559 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const core = require('../core')
|
||||
const model = require('../model/shardAssets/shardAssets.model')
|
||||
const db = require('../model/shardAssets/shardAssets.db')
|
||||
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
|
||||
const atlasModel = require('../model/shardAtlas/shardAtlas.model')
|
||||
const bridge = require('../utils/assetBridge')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
|
||||
// The import as a decision, with the shard and the database both stubbed
|
||||
// (docs/link/v8.md §6, §12 — protocol 8, phase 3).
|
||||
//
|
||||
// Each of these is a way the import can be wrong that an operator would either
|
||||
// never notice or notice only weeks later, on a page:
|
||||
//
|
||||
// - Re-fetching every sprite on every Update. Correct output, and it makes the
|
||||
// manifest — the entire reason stage 2 carries hashes instead of pixels —
|
||||
// dead weight.
|
||||
// - Silently dropping an asset the shard stopped offering. An unmounted client
|
||||
// volume and a deliberate downgrade are the same thing from here, and the
|
||||
// wrong guess deletes artwork nobody asked to delete.
|
||||
// - Overwriting artwork the operator drew themselves. §12 states outright that
|
||||
// theirs wins, and a sprite rip replacing hand-drawn portraits is not
|
||||
// recoverable by pressing anything.
|
||||
// - Treating a body with no art as a failure. Two thirds of the playable ghost
|
||||
// and gargoyle bodies are in that state on a stock client.
|
||||
|
||||
const saved = {}
|
||||
let uploadDir
|
||||
|
||||
function stubEverything({ manifest, fetched, held = new Map(), meta = null, sources } = {}) {
|
||||
saved.sourceFingerprint = bridge.sourceFingerprint
|
||||
saved.readManifest = bridge.readManifest
|
||||
saved.fetchAssets = bridge.fetchAssets
|
||||
saved.resolveBodies = bridge.resolveBodies
|
||||
saved.allAssets = db.allAssets
|
||||
saved.saveAssets = db.saveAssets
|
||||
saved.recordLastImport = db.recordLastImport
|
||||
saved.getMeta = db.getMeta
|
||||
saved.countAssets = db.countAssets
|
||||
saved.countBodies = db.countBodies
|
||||
saved.replaceBodies = db.replaceBodies
|
||||
saved.artBySlug = db.artBySlug
|
||||
saved.allCreatureTypes = atlasDb.allCreatureTypes
|
||||
saved.setCreatureArt = atlasDb.setCreatureArt
|
||||
saved.loadArtMap = atlasModel.loadArtMap
|
||||
saved.getSafe = uoLinkConfig.getSafe
|
||||
|
||||
const seen = { saved: null, fetchedKeys: null, art: null, last: null }
|
||||
|
||||
uoLinkConfig.getSafe = async () => ({ enabled: true, baseUrl: 'http://127.0.0.1:8080' })
|
||||
|
||||
bridge.sourceFingerprint = async () =>
|
||||
sources ?? {
|
||||
files: { 'anim.mul': { size: 1, mtime: 2, sha256: 'x' } },
|
||||
extractorVersion: 1,
|
||||
hashing: false,
|
||||
complete: true,
|
||||
imaging: { ok: true },
|
||||
}
|
||||
|
||||
bridge.readManifest = async () => manifest
|
||||
bridge.fetchAssets = async ({ keys }) => {
|
||||
seen.fetchedKeys = keys
|
||||
return fetched ?? { assets: new Map(), missing: { absent: 0, unsupported: 0 } }
|
||||
}
|
||||
bridge.resolveBodies = async () => []
|
||||
|
||||
db.allAssets = async () => held
|
||||
db.getMeta = async () => meta
|
||||
db.countAssets = async () => ({ total: held.size, stored: held.size })
|
||||
db.countBodies = async () => ({ total: 0, resolved: 0 })
|
||||
db.saveAssets = async (rows) => {
|
||||
seen.saved = rows
|
||||
return rows.length
|
||||
}
|
||||
db.recordLastImport = async (last) => {
|
||||
seen.last = last
|
||||
}
|
||||
db.replaceBodies = async () => 0
|
||||
db.artBySlug = async () => ({})
|
||||
|
||||
atlasDb.allCreatureTypes = async () => []
|
||||
atlasDb.setCreatureArt = async (map) => {
|
||||
seen.art = map
|
||||
return Object.keys(map).length
|
||||
}
|
||||
atlasModel.loadArtMap = () => ({})
|
||||
|
||||
return seen
|
||||
}
|
||||
|
||||
function restore() {
|
||||
for (const [name, fn] of Object.entries(saved)) {
|
||||
if (!fn) continue
|
||||
if (name in db) db[name] = fn
|
||||
if (name in bridge) bridge[name] = fn
|
||||
if (name in atlasDb) atlasDb[name] = fn
|
||||
if (name === 'loadArtMap') atlasModel.loadArtMap = fn
|
||||
if (name === 'getSafe') uoLinkConfig.getSafe = fn
|
||||
}
|
||||
}
|
||||
|
||||
/** A real uploads directory, because the import checks the disk as well as the row. */
|
||||
function useTempUploads(t) {
|
||||
uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uo-assets-'))
|
||||
const previous = core.uploads
|
||||
|
||||
Object.defineProperty(core, 'uploads', {
|
||||
configurable: true,
|
||||
get: () => ({ ...previous, UPLOAD_DIR: uploadDir }),
|
||||
})
|
||||
|
||||
t.after(() => {
|
||||
Object.defineProperty(core, 'uploads', { configurable: true, get: () => previous })
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
return uploadDir
|
||||
}
|
||||
|
||||
const row = (body, sha) => ({
|
||||
key: `body/${body}/a0`,
|
||||
family: 'body',
|
||||
sha256: sha,
|
||||
bytes: 900,
|
||||
width: 24,
|
||||
height: 63,
|
||||
body,
|
||||
direction: 1,
|
||||
})
|
||||
|
||||
const manifestOf = (rows) => ({
|
||||
rows,
|
||||
catalog: 'cat1',
|
||||
extractorVersion: 1,
|
||||
playerBodies: [400],
|
||||
pages: 1,
|
||||
scanned: 2047,
|
||||
})
|
||||
|
||||
const sprite = (sha) => ({
|
||||
sha256: sha,
|
||||
bytes: 4,
|
||||
width: 24,
|
||||
height: 63,
|
||||
body: 12,
|
||||
direction: 1,
|
||||
png: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
})
|
||||
|
||||
// ── the gate ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('unchanged client files import nothing at all', async (t) => {
|
||||
const sources = {
|
||||
files: { 'anim.mul': { size: 1, mtime: 2, sha256: 'x' } },
|
||||
extractorVersion: 1,
|
||||
hashing: false,
|
||||
complete: true,
|
||||
imaging: { ok: true },
|
||||
}
|
||||
|
||||
stubEverything({ manifest: manifestOf([]), meta: { sources }, sources })
|
||||
t.after(restore)
|
||||
|
||||
bridge.readManifest = async () => {
|
||||
throw new Error('the gate should have stopped before reading a manifest')
|
||||
}
|
||||
|
||||
const result = await model.importAssets()
|
||||
|
||||
assert.equal(result.status, 'unchanged')
|
||||
})
|
||||
|
||||
test('a host that cannot render images is named rather than walked', async (t) => {
|
||||
// §4.4: reported on the SOURCE gate, so an operator meets it while setting the
|
||||
// shard up rather than from an empty bestiary weeks later.
|
||||
stubEverything({
|
||||
manifest: manifestOf([]),
|
||||
sources: {
|
||||
files: {},
|
||||
extractorVersion: 1,
|
||||
hashing: false,
|
||||
complete: true,
|
||||
imaging: { ok: false, code: 'NO_IMAGING', reason: 'needs libgdiplus' },
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.importAssets()
|
||||
|
||||
assert.equal(result.status, 'unavailable')
|
||||
assert.equal(result.code, 'NO_IMAGING')
|
||||
})
|
||||
|
||||
// ── the diff (§6) ─────────────────────────────────────────────────────────
|
||||
|
||||
test('only the keys whose hash moved are fetched', async (t) => {
|
||||
const dir = useTempUploads(t)
|
||||
fs.mkdirSync(path.join(dir, model.ART_SUBDIR), { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, model.ART_SUBDIR, 'kept.png'), 'x')
|
||||
|
||||
const held = new Map([
|
||||
['body/12/a0', { key: 'body/12/a0', sha256: 'same', file: 'kept.png' }],
|
||||
['body/34/a0', { key: 'body/34/a0', sha256: 'old', file: 'kept.png' }],
|
||||
])
|
||||
|
||||
const seen = stubEverything({
|
||||
held,
|
||||
manifest: manifestOf([row(12, 'same'), row(34, 'new')]),
|
||||
fetched: { assets: new Map([['body/34/a0', sprite('new')]]), missing: { absent: 0, unsupported: 0 } },
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
// The whole point of a manifest that carries hashes and not pixels.
|
||||
assert.deepEqual(seen.fetchedKeys, ['body/34/a0'])
|
||||
assert.equal(result.written, 1)
|
||||
})
|
||||
|
||||
test('a body catalogued at a later action is imported under that key', async (t) => {
|
||||
// §11.2, phase 6. Body 820 has no art at action 0 and a horse at action 23, so
|
||||
// its key is `body/820/a23` — and the filename, the stored row and the atlas
|
||||
// join all have to agree on that. A name built as `uo-body-820-a0-…` would be
|
||||
// a file nothing ever asks for, with the creature page still showing text.
|
||||
const dir = useTempUploads(t)
|
||||
|
||||
const seen = stubEverything({
|
||||
manifest: manifestOf([
|
||||
{ ...row(820, 'new'), key: 'body/820/a23', action: 23 },
|
||||
]),
|
||||
fetched: {
|
||||
assets: new Map([['body/820/a23', { ...sprite('new'), action: 23 }]]),
|
||||
missing: { absent: 0, unsupported: 0 },
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.deepEqual(seen.fetchedKeys, ['body/820/a23'])
|
||||
|
||||
const saved = seen.saved[0]
|
||||
assert.equal(saved.key, 'body/820/a23')
|
||||
assert.equal(saved.action, 23)
|
||||
// Content-addressed, and the stem is the key: the action is IN the filename.
|
||||
assert.equal(saved.file, 'uo-body-820-a23-new.png')
|
||||
assert.ok(fs.existsSync(path.join(dir, model.ART_SUBDIR, saved.file)))
|
||||
})
|
||||
|
||||
test('an unchanged key whose file is missing from disk is fetched again', async (t) => {
|
||||
// The row and the file can disagree — a wiped uploads volume, a restore from a
|
||||
// database dump. Trusting the row alone leaves a broken image on a creature
|
||||
// page with nothing anywhere reporting a problem, and re-fetching a sprite is
|
||||
// far cheaper than that.
|
||||
useTempUploads(t)
|
||||
|
||||
const held = new Map([['body/12/a0', { key: 'body/12/a0', sha256: 'same', file: 'gone.png' }]])
|
||||
|
||||
const seen = stubEverything({
|
||||
held,
|
||||
manifest: manifestOf([row(12, 'same')]),
|
||||
fetched: { assets: new Map([['body/12/a0', sprite('same')]]), missing: { absent: 0, unsupported: 0 } },
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await model.importAssets({ force: true })
|
||||
|
||||
assert.deepEqual(seen.fetchedKeys, ['body/12/a0'])
|
||||
})
|
||||
|
||||
test('a key that vanished from the manifest needs review before anything changes', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const held = new Map([['body/99/a0', { key: 'body/99/a0', sha256: 'a', file: 'x.png' }]])
|
||||
|
||||
const seen = stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
assert.equal(result.status, 'needsReview')
|
||||
assert.equal(result.vanishedCount, 1)
|
||||
// Nothing was applied. An unmounted client volume and a deliberate downgrade
|
||||
// look identical from here.
|
||||
assert.equal(seen.saved, null)
|
||||
})
|
||||
|
||||
test('approve accepts the vanished key and removes its file', async (t) => {
|
||||
const dir = useTempUploads(t)
|
||||
fs.mkdirSync(path.join(dir, model.ART_SUBDIR), { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, model.ART_SUBDIR, 'gone.png'), 'x')
|
||||
|
||||
const held = new Map([['body/99/a0', { key: 'body/99/a0', sha256: 'a', file: 'gone.png' }]])
|
||||
|
||||
stubEverything({
|
||||
held,
|
||||
manifest: manifestOf([row(12, 'a')]),
|
||||
fetched: { assets: new Map([['body/12/a0', sprite('a')]]), missing: { absent: 0, unsupported: 0 } },
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.importAssets({ force: true, approve: true })
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.equal(result.removed, 1)
|
||||
assert.equal(fs.existsSync(path.join(dir, model.ART_SUBDIR, 'gone.png')), false)
|
||||
})
|
||||
|
||||
// ── absence is not failure (§5.2) ─────────────────────────────────────────
|
||||
|
||||
test('a key the shard could not render keeps the picture already held', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const held = new Map([['body/12/a0', { key: 'body/12/a0', sha256: 'old', file: 'existing.png' }]])
|
||||
|
||||
const seen = stubEverything({
|
||||
held,
|
||||
manifest: manifestOf([row(12, 'new')]),
|
||||
// Listed, asked for, and not served. A shard that suddenly cannot render one
|
||||
// sprite must not cost the picture we already have.
|
||||
fetched: { assets: new Map(), missing: { absent: 1, unsupported: 0 } },
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.equal(result.absent, 1)
|
||||
assert.equal(seen.saved[0].file, 'existing.png')
|
||||
})
|
||||
|
||||
// ── the derivation (§12) ──────────────────────────────────────────────────
|
||||
|
||||
test("the operator's own artwork wins over an imported sprite", async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const seen = stubEverything({ manifest: manifestOf([]) })
|
||||
t.after(restore)
|
||||
|
||||
db.artBySlug = async () => ({ 'giant-spider': 'uo-body-28-aaaabbbb.png', wolf: 'uo-body-34-ccccdddd.png' })
|
||||
// Someone who drew their own giant spider must not have it replaced by a
|
||||
// sprite rip on the next Update. §12 states this outright.
|
||||
atlasModel.loadArtMap = () => ({ 'giant-spider': 'my-own-spider.png' })
|
||||
|
||||
await model.importAssets({ force: true })
|
||||
|
||||
assert.equal(seen.art['giant-spider'], 'my-own-spider.png')
|
||||
assert.equal(seen.art.wolf, 'uo-body-34-ccccdddd.png')
|
||||
})
|
||||
|
||||
test('a sprite filename carries its hash so a changed picture is a changed URL', () => {
|
||||
const before = model.fileNameFor('body/34/a0', 'aaaaaaaabbbb')
|
||||
const after = model.fileNameFor('body/34/a0', 'ccccccccdddd')
|
||||
|
||||
// A stable name would be overwritten in place, and every browser and CDN that
|
||||
// had cached it would keep serving last month's client's sprite — with the
|
||||
// database row correct and nothing to notice.
|
||||
assert.notEqual(before, after)
|
||||
assert.match(before, /^uo-body-34-a0-[0-9a-f]{8}\.png$/)
|
||||
})
|
||||
|
||||
// ── what the panel reads (phase 8) ────────────────────────────────────────
|
||||
//
|
||||
// The admin surface is the only thing that imports — boot never calls the shard
|
||||
// — so everything an operator can learn about an import, they learn from what
|
||||
// these two return. Each of these is a way the panel would render a confident
|
||||
// sentence that is not true.
|
||||
|
||||
test('the vanished keys come back with the pictures they currently have', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const held = new Map([
|
||||
['body/820/a23', { key: 'body/820/a23', sha256: 'a', file: 'uo-body-820-a23-aabbccdd.png' }],
|
||||
])
|
||||
|
||||
stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
// The decision being asked for is "is it right that these disappear?", and a
|
||||
// key names nothing a human recognises. Without the filename the panel has
|
||||
// nothing to show but `body/820/a23`, which is a horse.
|
||||
assert.equal(result.status, 'needsReview')
|
||||
assert.deepEqual(result.vanished, [
|
||||
{ key: 'body/820/a23', file: 'uo-body-820-a23-aabbccdd.png' },
|
||||
])
|
||||
})
|
||||
|
||||
test('an import records what it did, including the body tally and who ran it', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const seen = stubEverything({
|
||||
manifest: manifestOf([row(12, 'new')]),
|
||||
fetched: {
|
||||
assets: new Map([['body/12/a0', sprite('new')]]),
|
||||
missing: { absent: 3, unsupported: 0 },
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
atlasDb.allCreatureTypes = async () => [{ slug: 'wolf', name: 'Wolf' }]
|
||||
bridge.resolveBodies = async () => [
|
||||
{ slug: 'wolf', typeName: 'Wolf', body: 34, status: 'ok' },
|
||||
{ slug: 'ghost-of-something', typeName: 'GhostOfSomething', body: null, status: 'unknown' },
|
||||
]
|
||||
|
||||
await model.importAssets({ force: true, by: 'colby' })
|
||||
|
||||
assert.equal(seen.last.by, 'colby')
|
||||
assert.equal(seen.last.force, true)
|
||||
assert.equal(seen.last.written, 1)
|
||||
assert.equal(seen.last.absent, 3)
|
||||
// The body pass is kept as a TALLY rather than a single "resolved" number:
|
||||
// `unknown` means the spawn files name a type this shard's scripts do not
|
||||
// define, which is real drift, and it reads identically to a failure if both
|
||||
// are summed into "not resolved".
|
||||
assert.deepEqual(seen.last.bodies, { ok: 1, unknown: 1, notCreature: 0, failed: 0 })
|
||||
})
|
||||
|
||||
test('a summary that cannot be written does not fail an import that applied', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
stubEverything({
|
||||
manifest: manifestOf([row(12, 'new')]),
|
||||
fetched: {
|
||||
assets: new Map([['body/12/a0', sprite('new')]]),
|
||||
missing: { absent: 0, unsupported: 0 },
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
db.recordLastImport = async () => {
|
||||
throw new Error('the meta row is locked')
|
||||
}
|
||||
|
||||
// The pictures are already on disk and the rows are already committed. Failing
|
||||
// here would report a failure for an import that succeeded, and the operator's
|
||||
// next move — press it again — would re-fetch the whole catalogue for nothing.
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.equal(result.written, 1)
|
||||
})
|
||||
|
||||
test('status says whether a shard is linked rather than leaving it to be inferred', async (t) => {
|
||||
stubEverything({ manifest: manifestOf([]) })
|
||||
t.after(restore)
|
||||
|
||||
db.getMeta = async () => ({ catalog: 'cat1', last: { by: 'colby', written: 4 } })
|
||||
|
||||
const linked = await model.getStatus()
|
||||
|
||||
assert.equal(linked.linked, true)
|
||||
assert.deepEqual(linked.loaded.last, { by: 'colby', written: 4 })
|
||||
|
||||
// A shard that is linked but DOWN also reports `shard: null`, which is why the
|
||||
// panel cannot read this off that: one wants its buttons disabled and the
|
||||
// other wants them available so the operator can retry.
|
||||
uoLinkConfig.getSafe = async () => ({ enabled: false, baseUrl: '' })
|
||||
|
||||
const unlinked = await model.getStatus()
|
||||
|
||||
assert.equal(unlinked.linked, false)
|
||||
assert.equal(unlinked.reason, 'uo-link is not configured')
|
||||
})
|
||||
|
||||
test('the catalogue count is the body family, not every asset in the table', async (t) => {
|
||||
stubEverything({ manifest: manifestOf([]) })
|
||||
t.after(restore)
|
||||
|
||||
let askedFor = 'never called'
|
||||
|
||||
// Item and land art live in the same table as the body catalogue (phase 5) and
|
||||
// are counted separately on purpose: one is a set with a size, the other is
|
||||
// however much of an unbounded space the site has happened to ask for. A
|
||||
// whole-table count reported 1,095 portraits plus 313 item pictures as a
|
||||
// "1,408-row catalogue" on the one screen that answers "did the import work".
|
||||
db.countAssets = async (family) => {
|
||||
askedFor = family
|
||||
return { total: 1095, stored: 1095 }
|
||||
}
|
||||
|
||||
const status = await model.getStatus()
|
||||
|
||||
assert.equal(askedFor, 'body')
|
||||
assert.equal(status.loaded.assets, 1095)
|
||||
})
|
||||
|
||||
test('item pictures are not "vanished" just because the body manifest never listed them', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
// The state every install reaches within a day of its first import: a body
|
||||
// catalogue, plus whatever item art the warm pass has fetched because a
|
||||
// marketplace page asked for it. Both live in `shard_assets`.
|
||||
const held = new Map([
|
||||
['body/12/a0', { key: 'body/12/a0', family: 'body', sha256: 'a', file: 'wolf.png' }],
|
||||
['static/3934/h1801', { key: 'static/3934/h1801', family: 'static', sha256: 'b', file: 'robe.png' }],
|
||||
])
|
||||
|
||||
const seen = stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
|
||||
t.after(restore)
|
||||
|
||||
// The family filter is the fix, so the stub has to honour it or the test
|
||||
// passes against a whole-table read.
|
||||
db.allAssets = async (family) =>
|
||||
new Map([...held].filter(([, r]) => !family || r.family === family))
|
||||
|
||||
const result = await model.importAssets({ force: true })
|
||||
|
||||
// Before the filter this was `needsReview` naming the item picture, and
|
||||
// approving it would have deleted every picture the warm pass had fetched —
|
||||
// with a sentence saying the shard had stopped offering them, which it had
|
||||
// not: a body manifest never mentions item art at all.
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.equal(result.removed, 0)
|
||||
assert.ok(seen.saved)
|
||||
})
|
||||
|
||||
test('an approved vanish deletes the row, not just the picture', async (t) => {
|
||||
const dir = useTempUploads(t)
|
||||
fs.mkdirSync(path.join(dir, model.ART_SUBDIR), { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, model.ART_SUBDIR, 'gone.png'), 'x')
|
||||
|
||||
const held = new Map([
|
||||
['body/99/a0', { key: 'body/99/a0', family: 'body', sha256: 'a', file: 'gone.png' }],
|
||||
])
|
||||
|
||||
let removedKeys = null
|
||||
|
||||
const seen = stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
|
||||
t.after(restore)
|
||||
|
||||
db.saveAssets = async (rows, meta, remove) => {
|
||||
seen.saved = rows
|
||||
removedKeys = remove
|
||||
return rows.length
|
||||
}
|
||||
|
||||
const result = await model.importAssets({ force: true, approve: true })
|
||||
|
||||
assert.equal(result.removed, 1)
|
||||
// The file was already unlinked before this fix; the ROW was not. A row whose
|
||||
// picture is gone keeps being counted, keeps being offered for review on every
|
||||
// forced import, and can still point a creature page at a file that is not
|
||||
// there — with the import reporting "nothing was changed" about a deletion it
|
||||
// had already performed.
|
||||
assert.deepEqual(removedKeys, ['body/99/a0'])
|
||||
assert.equal(fs.existsSync(path.join(dir, model.ART_SUBDIR, 'gone.png')), false)
|
||||
})
|
||||
726
server/test/shardEngagement.test.js
Normal file
726
server/test/shardEngagement.test.js
Normal file
@@ -0,0 +1,726 @@
|
||||
// ── The wire-kind → engagement-trigger mapper (ENGAGEMENT.md Phase 11) ─────
|
||||
//
|
||||
// Two halves, tested separately for the reason the file splits them: `mapShardEvent`
|
||||
// is pure given a tracker and needs no database, and `fromShardEvent` is the half
|
||||
// that resolves an account into a person and therefore does.
|
||||
//
|
||||
// What is asserted here is deliberately not "each field is copied". It is the
|
||||
// three things a rule cannot express and a plain mapping would get wrong —
|
||||
// transitions, thresholds, and who an event is ABOUT — plus the four places §8.6
|
||||
// or the protocol docs say the obvious implementation is the wrong one.
|
||||
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const engagement = require('../utils/shardEngagement')
|
||||
const { TRIGGERS, TRIGGER_IDS } = require('../config/shardTriggers')
|
||||
const { PATHS } = require('../config/clientPaths')
|
||||
|
||||
let tracker
|
||||
beforeEach(() => { tracker = engagement.createTracker() })
|
||||
|
||||
const map = (event) => engagement.mapShardEvent(event, tracker)
|
||||
const ids = (event) => map(event).map((t) => t.triggerId)
|
||||
const one = (event) => {
|
||||
const out = map(event)
|
||||
assert.equal(out.length, 1, `expected exactly one target, got ${out.length}`)
|
||||
return out[0]
|
||||
}
|
||||
|
||||
// ── The catalogue itself ───────────────────────────────────────────────────
|
||||
|
||||
test('the declared set is the one ENGAGEMENT.md §8.6 commits to, carve-outs included', () => {
|
||||
// 27 since protocol 6: `uo.champ.boss_killed` joins the twenty-six §8.6 named.
|
||||
// It is not one of the four carve-outs below being reinstated — it is a row the
|
||||
// catalogue could not have, because until protocol 6 the wire had no kind for a
|
||||
// boss defeat and the inference from `champ.update` was not good enough to mail.
|
||||
assert.equal(TRIGGERS.length, 27)
|
||||
// The four rows that do NOT ship, each with its reason recorded in §8.6. This
|
||||
// assertion is the guard on the carve-outs: adding one back is a decision, and
|
||||
// a decision should have to edit a test that says so.
|
||||
for (const carved of [
|
||||
'uo.market.item_listed', // a saved SEARCH; no per-user query store exists
|
||||
'uo.guild.joined', // core's team.member.joined already fires for it
|
||||
'uo.link.requested', // no addressable recipient, and a ~5-minute TTL
|
||||
]) {
|
||||
assert.equal(TRIGGER_IDS.has(carved), false, `${carved} is carved out`)
|
||||
}
|
||||
// Every id is this module's, which is what `namespaced()` enforces at
|
||||
// registration — asserted here too so the failure names the id rather than
|
||||
// arriving as a boot error.
|
||||
for (const t of TRIGGERS) assert.ok(t.id.startsWith('uo.'), `${t.id} is namespaced`)
|
||||
})
|
||||
|
||||
test('every variable carries an example, because a template is previewed with it', () => {
|
||||
for (const t of TRIGGERS) {
|
||||
for (const v of t.variables) {
|
||||
assert.ok(v.example !== undefined && v.example !== '', `${t.id}.${v.name} has an example`)
|
||||
assert.ok(v.description, `${t.id}.${v.name} has a description`)
|
||||
}
|
||||
// A subjectKey that is not one of the trigger's own variables is refused at
|
||||
// registration; catching it here names the trigger instead of the boot.
|
||||
if (t.subjectKey) {
|
||||
assert.ok(
|
||||
t.variables.some((v) => v.name === t.subjectKey),
|
||||
`${t.id} subjectKey "${t.subjectKey}" is one of its variables`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a url variable is site-RELATIVE — an absolute one ends up in an href', () => {
|
||||
for (const t of TRIGGERS) {
|
||||
for (const v of t.variables.filter((x) => x.type === 'url')) {
|
||||
assert.ok(v.example.startsWith('/'), `${t.id}.${v.name} example is rooted`)
|
||||
// Not protocol-relative: `//evil.test/x` passes an "is it rooted" check.
|
||||
assert.ok(!v.example.startsWith('//'), `${t.id}.${v.name} is not protocol-relative`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a url example names a route this module actually mounts', () => {
|
||||
// Phase 11b's live walk. Every `url` example read `/shard/…` — module.json's
|
||||
// `mounts` — and the client router prefixes a module's routes with its **ID**
|
||||
// (`registry.registerRoutes`), so every one of them was a 404. It matters twice
|
||||
// over: the example is what the template editor previews and test-sends with,
|
||||
// and `clientPaths.js` is now the single place both it and the bodies read.
|
||||
const known = new Set(Object.values(PATHS))
|
||||
for (const t of TRIGGERS) {
|
||||
for (const v of t.variables.filter((x) => x.type === 'url')) {
|
||||
// A parameterised path (`/uo/guilds/1042`) is legal; its PARENT must be known.
|
||||
const parent = v.example.replace(/\/[^/]+$/, '')
|
||||
assert.ok(
|
||||
known.has(v.example) || known.has(parent),
|
||||
`${t.id}.${v.name} example "${v.example}" is not a route this module mounts`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every url variable a body can interpolate is actually SUPPLIED', () => {
|
||||
// The defect this exists for is invisible in the source and invisible in a
|
||||
// fixture: a declared-but-never-populated optional interpolates to the empty
|
||||
// string, so the letter renders perfectly and its call-to-action button has no
|
||||
// href. Nine of the sixteen in-universe bodies shipped that way.
|
||||
//
|
||||
// Driven off the DECLARATIONS rather than a hand list, so the next url variable
|
||||
// added is covered the day it is declared.
|
||||
const frames = {
|
||||
'uo.house.idoc_warning': DECAY,
|
||||
'uo.house.refreshed': { ...DECAY, from: 'Greatly', to: 'LikeNew' },
|
||||
'uo.vendor.expiring': listing(FEES(20)),
|
||||
'uo.guild.left': { kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' },
|
||||
// Two frames each: an upsert kind is never a transition on FIRST sight, so
|
||||
// the tracker has to see a baseline before the change means anything.
|
||||
'uo.governor.elected': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
|
||||
'uo.governor.appointed': [city(), city({ governor: { serial: '0x1FB', name: 'Darrow', acct: 'seed_002' } })],
|
||||
'uo.election.opened': [city(), city({ electionPhase: 'nominate', autoPickAt: inHours(48), candidates: 2 })],
|
||||
'uo.champ.started': [champ({ active: false }), champ({ active: true })],
|
||||
'uo.champ.boss_up': [champ({ bossUp: false }), champ({ bossUp: true })],
|
||||
// Protocol 6. A single frame, unlike its two neighbours: a defeat is an
|
||||
// EVENT on the wire rather than a change spotted between two snapshots, which
|
||||
// is the whole reason the kind was worth a protocol bump.
|
||||
'uo.champ.boss_killed': bossKilled(),
|
||||
'uo.server.up': { kind: 'server.hello', shard: 'Rig' },
|
||||
'uo.server.down': { kind: 'server.shutdown' },
|
||||
'uo.page.new': { kind: 'page.new', type: 'Bug', sender: { name: 'Darrow' }, message: 'stuck' },
|
||||
'uo.economy.milestone': [supply(50_000_000), supply(300_000_000)],
|
||||
}
|
||||
|
||||
for (const t of TRIGGERS) {
|
||||
const urls = t.variables.filter((v) => v.type === 'url')
|
||||
if (!urls.length) continue
|
||||
const frame = frames[t.id]
|
||||
assert.ok(frame, `${t.id} declares a url variable and this test has no frame for it`)
|
||||
|
||||
const fresh = engagement.createTracker()
|
||||
let target = null
|
||||
for (const f of Array.isArray(frame) ? frame : [frame]) {
|
||||
const hit = engagement.mapShardEvent(f, fresh).find((x) => x.triggerId === t.id)
|
||||
if (hit) target = hit
|
||||
}
|
||||
assert.ok(target, `${t.id} did not fire for its frame`)
|
||||
|
||||
for (const v of urls) {
|
||||
assert.ok(target.data[v.name], `${t.id}.${v.name} is declared but never supplied`)
|
||||
assert.ok(String(target.data[v.name]).startsWith('/'), `${t.id}.${v.name} is site-relative`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The declaration that the whole ceiling lattice exists for.
|
||||
test('uo.cheat.detected ceilings at staff and NEVER at owner', () => {
|
||||
const cheat = TRIGGERS.find((t) => t.id === 'uo.cheat.detected')
|
||||
assert.equal(cheat.ceiling, 'staff')
|
||||
assert.equal(cheat.audience, 'staff')
|
||||
// The three operator-facing ones sit a rung lower still: `staff` means admin,
|
||||
// editor AND moderator, so a digest of what moderators did must not ceiling there.
|
||||
for (const id of ['uo.audit.staff_action', 'uo.economy.milestone', 'uo.world.saved']) {
|
||||
assert.equal(TRIGGERS.find((t) => t.id === id).ceiling, 'admin', `${id} ceilings at admin`)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Houses ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const DECAY = {
|
||||
kind: 'house.decay',
|
||||
serial: '0x400142F9',
|
||||
from: 'Fairly',
|
||||
to: 'Greatly',
|
||||
name: 'Millrace',
|
||||
ownerAcct: 'seed_002',
|
||||
region: 'Britain',
|
||||
map: 'Felucca',
|
||||
x: 1480,
|
||||
y: 1600,
|
||||
lastRefreshed: '2026-08-25T17:21:14Z',
|
||||
}
|
||||
|
||||
test('a late decay stage warns the owner; an early one says nothing', () => {
|
||||
const t = one(DECAY)
|
||||
assert.equal(t.triggerId, 'uo.house.idoc_warning')
|
||||
assert.equal(t.ownerAccount, 'seed_002')
|
||||
assert.equal(t.data.stage, 'Greatly')
|
||||
assert.equal(t.data.location, 'Felucca 1480, 1600 (Britain)')
|
||||
// An EARLY stage says nothing — a house drifting from Slightly to Somewhat is
|
||||
// not news, and mailing it would make the warning worthless.
|
||||
assert.deepEqual(ids({ ...DECAY, to: 'Slightly' }), [])
|
||||
})
|
||||
|
||||
test('a refresh is its own trigger, and it is what cancels the warning', () => {
|
||||
// Phase 11b decision 11. Until this branch existed a refresh reached the engine
|
||||
// as SILENCE, so `uo.house.idoc_warning`'s 900-second delay had nothing to be
|
||||
// cancelled by and was simply a late mail (§4.2a). Nothing on the wire changed:
|
||||
// the decay sweep has always emitted this transition.
|
||||
const t = one({ ...DECAY, from: 'Greatly', to: 'LikeNew' })
|
||||
assert.equal(t.triggerId, 'uo.house.refreshed')
|
||||
assert.equal(t.ownerAccount, 'seed_002')
|
||||
// The SAME subject as the warning it cancels — `outboxDb.cancel` matches on
|
||||
// (rule, subject_key), so a different one would cancel nothing.
|
||||
assert.equal(t.data.houseSerial, one(DECAY).data.houseSerial)
|
||||
assert.equal(t.data.previousStage, 'Greatly')
|
||||
// A TRAILING fragment: its own leading space, and empty rather than reading
|
||||
// "It stood in decay." when the previous stage has no word of its own.
|
||||
assert.equal(t.data.fromLine, ' It stood greatly worn.')
|
||||
assert.equal(one({ ...DECAY, from: 'Somewhat', to: 'LikeNew' }).data.fromLine, undefined)
|
||||
})
|
||||
|
||||
test('the v5 schedule rides along when present and is simply absent when not', () => {
|
||||
const withSchedule = one({
|
||||
...DECAY,
|
||||
schedule: {
|
||||
dynamicDecay: true,
|
||||
nextStage: '2026-09-01T20:33:15Z',
|
||||
estimatedCollapse: '2026-09-06T20:33:15Z',
|
||||
},
|
||||
})
|
||||
assert.equal(withSchedule.data.nextStage, '2026-09-01T20:33:15Z')
|
||||
assert.equal(withSchedule.data.estimatedCollapse, '2026-09-06T20:33:15Z')
|
||||
|
||||
// **A dynamic-decay shard omits `estimatedCollapse` at every stage before
|
||||
// IDOC, and a v4 overlay omits the whole block.** `docs/link/v5.md` is explicit
|
||||
// that absence means "not knowable", never "not yet read" — so the mapper must
|
||||
// pass the absence through rather than computing a fallback, which would
|
||||
// republish exactly the guess the shard refused to make.
|
||||
const dynamic = one({ ...DECAY, schedule: { dynamicDecay: true, nextStage: '2026-09-01T20:33:15Z' } })
|
||||
assert.equal(dynamic.data.nextStage, '2026-09-01T20:33:15Z')
|
||||
assert.equal('estimatedCollapse' in dynamic.data, false)
|
||||
|
||||
const v4 = one(DECAY)
|
||||
assert.equal('nextStage' in v4.data, false)
|
||||
assert.equal('estimatedCollapse' in v4.data, false)
|
||||
})
|
||||
|
||||
test('Collapsed is its own trigger, not a louder warning', () => {
|
||||
const t = one({ ...DECAY, to: 'Collapsed' })
|
||||
assert.equal(t.triggerId, 'uo.house.collapsed')
|
||||
assert.equal(t.ownerAccount, 'seed_002')
|
||||
})
|
||||
|
||||
test('house.remove carries only a serial, so the owner is looked up later', () => {
|
||||
const t = one({ kind: 'house.remove', serial: '0x400142F9' })
|
||||
assert.equal(t.triggerId, 'uo.house.collapsed')
|
||||
assert.equal(t.ownerAccount, undefined)
|
||||
assert.equal(t.houseSerial, '0x400142F9')
|
||||
})
|
||||
|
||||
// ── Vendors: the threshold, and the two ways there is nothing to warn about ──
|
||||
|
||||
const listing = (fees) => ({
|
||||
kind: 'vendor.listing',
|
||||
serial: '0x40001234',
|
||||
shopName: "Darrow's Bargains",
|
||||
ownerAcct: 'darrow_acct',
|
||||
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
|
||||
...(fees === undefined ? {} : { fees }),
|
||||
})
|
||||
|
||||
const inHours = (h) => new Date(Date.now() + h * 3_600_000).toISOString()
|
||||
|
||||
const FEES = (h) => ({
|
||||
exempt: false,
|
||||
newVendorSystem: true,
|
||||
chargePerPeriod: 10548,
|
||||
funds: 8204,
|
||||
payIntervalSec: 86400,
|
||||
periodsRemaining: 1,
|
||||
dismissalAt: inHours(h),
|
||||
})
|
||||
|
||||
test('a vendor entering the warning window fires ONCE, not on every sweep frame', () => {
|
||||
// `vendor.listing` is re-emitted on any price change, so without the crossing
|
||||
// check a vendor inside the window mails its owner every time somebody
|
||||
// reprices a longsword.
|
||||
// 20.5 rather than 20, because `hoursRemaining` FLOORS a live clock: at a whole
|
||||
// number the answer is 20 or 19 depending on whether a millisecond has passed
|
||||
// since the fixture was built, and this assertion was flaking on exactly that.
|
||||
const first = one(listing(FEES(20.5)))
|
||||
assert.equal(first.triggerId, 'uo.vendor.expiring')
|
||||
assert.equal(first.ownerAccount, 'darrow_acct')
|
||||
assert.equal(first.data.hoursRemaining, 20)
|
||||
assert.deepEqual(ids(listing(FEES(19))), [])
|
||||
assert.deepEqual(ids(listing(FEES(18))), [])
|
||||
})
|
||||
|
||||
test('a deposit that leaves the window re-arms the warning', () => {
|
||||
assert.deepEqual(ids(listing(FEES(20))), ['uo.vendor.expiring'])
|
||||
assert.deepEqual(ids(listing(FEES(400))), []) // paid up — out of the window
|
||||
assert.deepEqual(ids(listing(FEES(10))), ['uo.vendor.expiring']) // and back in
|
||||
})
|
||||
|
||||
test('exempt and absent fees are both "nothing to warn about", not "no money"', () => {
|
||||
// A commission vendor has no PayTimer and is NEVER dismissed for fees.
|
||||
// Conflating that with a distant date is how a vendor that cannot expire ends
|
||||
// up in an expiry warning (docs/link/v5.md).
|
||||
assert.deepEqual(ids(listing({ exempt: true })), [])
|
||||
// A pre-v5 overlay sends no `fees` block at all.
|
||||
assert.deepEqual(ids(listing(undefined)), [])
|
||||
})
|
||||
|
||||
test('a vendor already past its dismissal tick reports 0 hours, never a negative', () => {
|
||||
const t = one(listing(FEES(-3)))
|
||||
assert.equal(t.data.hoursRemaining, 0)
|
||||
})
|
||||
|
||||
test('an unowned listing is nobody to notify', () => {
|
||||
const { ownerAcct, ...anonymous } = listing(FEES(10))
|
||||
assert.deepEqual(ids(anonymous), [])
|
||||
})
|
||||
|
||||
// ── Logins: the inversion protocol 5 exists to fix ─────────────────────────
|
||||
|
||||
test('only a FAILED login warns — a successful one produces nothing', () => {
|
||||
const failed = one({ kind: 'account.login.result', acct: 'seed_000', ip: '203.0.113.9', accepted: false, reason: 'BadPass' })
|
||||
assert.equal(failed.triggerId, 'uo.account.login_failed')
|
||||
assert.equal(failed.data.reason, 'BadPass')
|
||||
assert.deepEqual(ids({ kind: 'account.login.result', acct: 'seed_000', accepted: true }), [])
|
||||
})
|
||||
|
||||
test('the pre-decision attempt kind is not mapped at all', () => {
|
||||
// `account.login.attempt` fires from a sink that runs BEFORE the auth decision
|
||||
// and whose args default `Accepted = true`, so a rule on it would have mailed a
|
||||
// security alert on every successful login. That is why v5 added a second kind
|
||||
// and why this one must stay unmapped.
|
||||
assert.deepEqual(ids({ kind: 'account.login.attempt', acct: 'seed_000', ip: '203.0.113.9' }), [])
|
||||
})
|
||||
|
||||
// ── Transitions ────────────────────────────────────────────────────────────
|
||||
|
||||
const champ = (over) => ({ kind: 'champ.update', serial: '0x40012345', name: 'Abyss', category: 'champion', map: 'Felucca', x: 5187, y: 570, ...over })
|
||||
|
||||
// Protocol 6. The spawn serial matches `champ`'s, so the pair can be walked as
|
||||
// one altar's story: the boss goes up, then it comes down.
|
||||
const bossKilled = (over) => ({
|
||||
kind: 'champ.boss.killed',
|
||||
serial: '0x40012345',
|
||||
bossSerial: '0x901', category: 'champion', boss: 'Semidar', bossType: 'Semidar',
|
||||
map: 'Felucca', x: 5187, y: 570, region: 'Destard',
|
||||
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
|
||||
damagers: [
|
||||
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true, damage: 900 },
|
||||
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
|
||||
],
|
||||
...over,
|
||||
})
|
||||
|
||||
test('a first sighting is never a transition — a reconnect is not twenty spawns starting', () => {
|
||||
assert.deepEqual(ids(champ({ active: true })), [])
|
||||
assert.deepEqual(ids(champ({ active: true })), []) // still no change
|
||||
assert.deepEqual(ids(champ({ active: false })), [])
|
||||
assert.deepEqual(ids(champ({ active: true })), ['uo.champ.started'])
|
||||
})
|
||||
|
||||
test('the boss is its own transition, tracked separately from active', () => {
|
||||
map(champ({ active: true, bossUp: false }))
|
||||
assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up'])
|
||||
assert.deepEqual(ids(champ({ active: true, bossUp: true })), [])
|
||||
})
|
||||
|
||||
test('champ.remove forgets the spawn, so its next appearance is a first sighting', () => {
|
||||
map(champ({ active: false }))
|
||||
map({ kind: 'champ.remove', serial: '0x40012345' })
|
||||
assert.deepEqual(ids(champ({ active: true })), [])
|
||||
})
|
||||
|
||||
// ── champ.boss.killed (Protocol 6) ─────────────────────────────────────────
|
||||
|
||||
test('a defeat fires on the frame itself, with no baseline to compare against', () => {
|
||||
// Unlike its two neighbours above. `champ.update` is a SNAPSHOT, so a first
|
||||
// sighting can never be a transition; a defeat is an event, so a first sighting
|
||||
// is exactly the thing being reported.
|
||||
const hit = one(bossKilled())
|
||||
assert.equal(hit.triggerId, 'uo.champ.boss_killed')
|
||||
assert.equal(hit.data.bossName, 'Semidar')
|
||||
assert.equal(hit.data.killerName, 'Aldric')
|
||||
assert.equal(hit.data.damagerCount, 2)
|
||||
assert.equal(hit.data.damagerNote, ' 2 players fought it.')
|
||||
assert.equal(hit.data.location, 'Felucca 5187, 570 (Destard)')
|
||||
})
|
||||
|
||||
test('the subject is the SPAWN, so boss_up and boss_killed share one cooldown subject', () => {
|
||||
map(champ({ active: true, bossUp: false }))
|
||||
const up = one(champ({ active: true, bossUp: true }))
|
||||
const down = one(bossKilled())
|
||||
assert.equal(up.triggerId, 'uo.champ.boss_up')
|
||||
assert.equal(down.data.spawnSerial, up.data.spawnSerial)
|
||||
})
|
||||
|
||||
test('a defeat the shard could not attribute to an altar stands on the boss itself', () => {
|
||||
// The sweep learns which altar a champion belongs to; a boss that popped and
|
||||
// died between two sweeps arrives with no `serial`. A subject that exists once
|
||||
// is all a cooldown needs, so the boss's own serial stands in rather than the
|
||||
// firing being dropped.
|
||||
const hit = one(bossKilled({ serial: undefined }))
|
||||
assert.equal(hit.data.spawnSerial, '0x901')
|
||||
})
|
||||
|
||||
test('a defeat clears the tracker, so the next boss on that altar is a transition again', () => {
|
||||
map(champ({ active: true, bossUp: false }))
|
||||
map(champ({ active: true, bossUp: true })) // fires boss_up
|
||||
map(bossKilled())
|
||||
// Without the tracker reset this would emit nothing: the tracker would still
|
||||
// believe a boss is up, so the next one would not look like a change.
|
||||
assert.deepEqual(ids(champ({ active: true, bossUp: true })), ['uo.champ.boss_up'])
|
||||
})
|
||||
|
||||
test('the damage TABLE never becomes trigger data, only its size', () => {
|
||||
// `damagers` is `staff` in the visibility config. A trigger variable is
|
||||
// interpolated into mail an operator may address to every subscriber, so a
|
||||
// damager name reaching `data` would undo that field rule one layer up.
|
||||
const hit = one(bossKilled())
|
||||
const rendered = JSON.stringify(hit.data)
|
||||
assert.equal(rendered.includes('Bran'), false, 'no damager name reaches the data')
|
||||
assert.equal(rendered.includes('seed_003'), false, 'no damager account reaches the data')
|
||||
assert.equal(hit.data.damagers, undefined)
|
||||
})
|
||||
|
||||
test('an unattributed kill renders no damager sentence rather than an empty one', () => {
|
||||
const hit = one(bossKilled({ damagers: [] }))
|
||||
assert.equal(hit.data.damagerCount, undefined)
|
||||
assert.equal(hit.data.damagerNote, undefined)
|
||||
})
|
||||
|
||||
const city = (over) => ({ kind: 'city.update', city: 'Britain', electionPhase: 'none', ...over })
|
||||
|
||||
test('a governor change is a transition, and never on first sight', () => {
|
||||
assert.deepEqual(ids(city({ governor: { serial: '0x1', name: 'Mireille' } })), [])
|
||||
const t = one(city({ governor: { serial: '0x2', name: 'Darrow' } }))
|
||||
assert.equal(t.triggerId, 'uo.governor.elected')
|
||||
assert.equal(t.data.governorName, 'Darrow')
|
||||
assert.deepEqual(ids(city({ governor: { serial: '0x2', name: 'Darrow' } })), [])
|
||||
})
|
||||
|
||||
test('an ELECTED governor with a linked account also gets a letter', () => {
|
||||
// Phase 11b, decision 10. §8.6 says `uo.points.rank_changed` cannot address a
|
||||
// person because `top[]` names a serial — and the same reasoning was silently
|
||||
// assumed to cover the governor. It does not: `BridgeJson.Actor()` writes
|
||||
// `acct` on every actor object, so the winner is addressable with no protocol
|
||||
// change. This test is the record of that, and of the decision that the
|
||||
// announcement and the letter are TWO triggers.
|
||||
map(city({ governor: { serial: '0x1', name: 'Mireille', acct: 'mireille' } }))
|
||||
const out = map(city({ governor: { serial: '0x2', name: 'Darrow', acct: 'darrow' } }))
|
||||
assert.deepEqual(out.map((t) => t.triggerId), ['uo.governor.elected', 'uo.governor.appointed'])
|
||||
|
||||
const letter = out[1]
|
||||
assert.equal(letter.ownerAccount, 'darrow')
|
||||
assert.equal(letter.data.city, 'Britain')
|
||||
assert.equal(letter.data.governorName, 'Darrow')
|
||||
// The bulletin carries no owner — it is the town's, not the governor's.
|
||||
assert.equal(out[0].ownerAccount, undefined)
|
||||
})
|
||||
|
||||
test('an UNLINKED governor still gets the town its announcement', () => {
|
||||
// Nobody to write to is an ordinary outcome, not an error — most game accounts
|
||||
// on most shards have never been linked — and it must not cost the city its
|
||||
// proclamation.
|
||||
map(city({ governor: { serial: '0x1', name: 'Mireille' } }))
|
||||
assert.deepEqual(
|
||||
ids(city({ governor: { serial: '0x2', name: 'Darrow' } })),
|
||||
['uo.governor.elected'],
|
||||
)
|
||||
})
|
||||
|
||||
test('an election opening needs its deadline, or it does not fire', () => {
|
||||
map(city({ electionPhase: 'none' }))
|
||||
// **A "vote now" mail with nothing to act by is worse than none**, and
|
||||
// `autoPickAt` is declared required, so a phase change without one is dropped
|
||||
// here rather than refused by `emit` later.
|
||||
assert.deepEqual(ids(city({ electionPhase: 'vote' })), [])
|
||||
|
||||
const fresh = engagement.createTracker()
|
||||
engagement.mapShardEvent(city({ electionPhase: 'none' }), fresh)
|
||||
const out = engagement.mapShardEvent(
|
||||
city({ electionPhase: 'vote', autoPickAt: '2026-09-04T00:00:00Z', candidates: 3 }),
|
||||
fresh,
|
||||
)
|
||||
assert.deepEqual(out.map((t) => t.triggerId), ['uo.election.opened'])
|
||||
assert.equal(out[0].data.autoPickAt, '2026-09-04T00:00:00Z')
|
||||
})
|
||||
|
||||
// ── The shard's own up/down, which is the cooldown table's stress test ─────
|
||||
|
||||
test('a sidecar reconnect is not a restart — server.hello only fires on a real change', () => {
|
||||
// `server.hello` is sent on EVERY sidecar reconnect, not only on a shard
|
||||
// restart, which is exactly the flapping this trigger must not amplify.
|
||||
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), ['uo.server.up'])
|
||||
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'a' }), [])
|
||||
assert.deepEqual(ids({ kind: 'server.hello', shard: 'UOMysticmoon', bootId: 'b' }), [])
|
||||
})
|
||||
|
||||
test('down fires once per outage, and a crash is told apart from a clean stop', () => {
|
||||
map({ kind: 'server.hello', shard: 'UOMysticmoon' })
|
||||
const down = one({ kind: 'server.shutdown' })
|
||||
assert.equal(down.triggerId, 'uo.server.down')
|
||||
assert.equal(down.data.clean, true)
|
||||
assert.deepEqual(ids({ kind: 'server.crashed' }), []) // already down
|
||||
map({ kind: 'server.hello' })
|
||||
assert.equal(one({ kind: 'server.crashed' }).data.clean, false)
|
||||
})
|
||||
|
||||
// ── Thresholds ─────────────────────────────────────────────────────────────
|
||||
|
||||
const supply = (gold, accounts = 50) => ({ kind: 'economy.supply', gold, accounts })
|
||||
|
||||
test('an economy milestone fires on a crossing, in both directions, never on first sight', () => {
|
||||
// A sidecar reconnect on a mature shard must not announce a line it crossed
|
||||
// months ago.
|
||||
assert.deepEqual(ids(supply(900_000_000)), [])
|
||||
const up = one(supply(1_200_000_000))
|
||||
assert.equal(up.triggerId, 'uo.economy.milestone')
|
||||
assert.equal(up.data.direction, 'up')
|
||||
assert.equal(up.data.threshold, 1_000_000_000)
|
||||
assert.deepEqual(ids(supply(1_300_000_000)), []) // same band
|
||||
const down = one(supply(800_000_000))
|
||||
assert.equal(down.data.direction, 'down')
|
||||
assert.equal(down.data.threshold, 1_000_000_000) // the line it fell back through
|
||||
})
|
||||
|
||||
// ── Leaderboards ───────────────────────────────────────────────────────────
|
||||
|
||||
const board = (serial, name) => ({
|
||||
kind: 'points.board',
|
||||
system: 'QueensLoyalty',
|
||||
nameString: "Queen's Loyalty",
|
||||
top: [{ rank: 1, serial, name, points: 29500 }, { rank: 2, serial: '0xFF', name: 'Mireille', points: 21000 }],
|
||||
})
|
||||
|
||||
test('a leaderboard change names the new leader and nobody personally', () => {
|
||||
assert.deepEqual(ids(board('0x1A2B', 'Darrow')), [])
|
||||
const t = one(board('0x1A2C', 'Bran'))
|
||||
assert.equal(t.triggerId, 'uo.points.rank_changed')
|
||||
assert.equal(t.data.leaderName, 'Bran')
|
||||
// The personal half is carved out: `top[]` names a mobile SERIAL and links are
|
||||
// keyed by ACCOUNT, so there is deliberately no owner on this target.
|
||||
assert.equal(t.ownerAccount, undefined)
|
||||
assert.deepEqual(ids(board('0x1A2C', 'Bran')), [])
|
||||
})
|
||||
|
||||
// ── Milestones ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('only a capped skill is a milestone', () => {
|
||||
const who = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' }
|
||||
assert.deepEqual(ids({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 99.8, cap: 100 }), [])
|
||||
const t = one({ kind: 'skill.gain', who, skill: 'Blacksmithy', base: 100, cap: 100 })
|
||||
assert.equal(t.triggerId, 'uo.skill.capped')
|
||||
assert.equal(t.ownerAccount, 'seed_000')
|
||||
// A mobile with no account is nobody's character.
|
||||
assert.deepEqual(ids({ kind: 'skill.gain', who: { serial: '0x2', name: 'A Guard' }, base: 100, cap: 100 }), [])
|
||||
})
|
||||
|
||||
test('both deaths address the victim, never the killer', () => {
|
||||
const victim = { serial: '0x1', name: 'Zara Crowe', acct: 'seed_000' }
|
||||
const murderer = { serial: '0x2', name: 'Darrow', acct: 'seed_001' }
|
||||
const death = one({ kind: 'player.death', who: victim, killer: { name: 'an ogre lord' } })
|
||||
assert.equal(death.ownerAccount, 'seed_000')
|
||||
assert.equal(death.data.killerName, 'an ogre lord')
|
||||
const murder = one({ kind: 'player.murdered', victim, murderer })
|
||||
assert.equal(murder.triggerId, 'uo.character.murdered')
|
||||
assert.equal(murder.ownerAccount, 'seed_000')
|
||||
assert.equal(murder.data.murdererName, 'Darrow')
|
||||
})
|
||||
|
||||
// ── Guilds ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a guild leave and a disband are members-shaped; a join is not mapped at all', () => {
|
||||
const left = one({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' })
|
||||
assert.equal(left.triggerId, 'uo.guild.left')
|
||||
assert.equal(left.guildId, 1042)
|
||||
assert.equal(left.memberSerial, '0x77')
|
||||
|
||||
assert.equal(one({ kind: 'guild.remove', id: 1042 }).triggerId, 'uo.guild.disbanded')
|
||||
|
||||
// Core's `team.member.joined` already fires for this, on every roster
|
||||
// reconcile, because a UO guild IS a Team and this module is the provider.
|
||||
// A second trigger would be two mails for one join (§8.6).
|
||||
assert.deepEqual(ids({ kind: 'guild.join', id: 1042, who: { serial: '0x77', name: 'Bran' } }), [])
|
||||
})
|
||||
|
||||
// ── Staff and operator ─────────────────────────────────────────────────────
|
||||
|
||||
test('the staff-facing pair carry no account of the person they are about, except where it is the point', () => {
|
||||
const page = one({ kind: 'page.new', type: 'Stuck', sender: { name: 'Zara Crowe', acct: 'seed_000' }, message: 'help', map: 'Trammel', x: 1, y: 2 })
|
||||
assert.equal(page.triggerId, 'uo.page.new')
|
||||
assert.equal(page.ownerAccount, undefined) // it is a STAFF audience, not the player's
|
||||
|
||||
const cheat = one({ kind: 'cheat.fastwalk', who: { name: 'Zara Crowe', acct: 'seed_000' }, ip: '203.0.113.9' })
|
||||
assert.equal(cheat.triggerId, 'uo.cheat.detected')
|
||||
assert.equal(cheat.ownerAccount, undefined) // never addressed to the player detected
|
||||
assert.equal(cheat.data.account, 'seed_000') // but staff are told which account
|
||||
})
|
||||
|
||||
test('the three audit kinds fold into one operator trigger', () => {
|
||||
assert.deepEqual(ids({ kind: 'audit.set', staff: 'Mireille', prop: 'Str', old: 100, new: 125, target: 'Zara' }), ['uo.audit.staff_action'])
|
||||
assert.deepEqual(ids({ kind: 'audit.command', staff: 'Mireille', command: '[go', args: 'britain' }), ['uo.audit.staff_action'])
|
||||
const admin = one({ kind: 'admin.audit', origin: 'web', action: 'ban', actor: 'web:9931', target: 'seed_000', reason: 'macroing' })
|
||||
assert.equal(admin.data.action, 'ban')
|
||||
assert.equal(admin.data.origin, 'web')
|
||||
})
|
||||
|
||||
test('world.save.after reports what it wrote', () => {
|
||||
const t = one({ kind: 'world.save.after', items: 1482301, mobiles: 41022 })
|
||||
assert.equal(t.triggerId, 'uo.world.saved')
|
||||
assert.equal(t.data.items, 1482301)
|
||||
// `before` is a boundary, not news.
|
||||
assert.deepEqual(ids({ kind: 'world.save.before' }), [])
|
||||
})
|
||||
|
||||
// ── The guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('an unmapped kind and a malformed frame both produce nothing', () => {
|
||||
assert.deepEqual(ids({ kind: 'char.vitals', serial: '0x1' }), [])
|
||||
assert.deepEqual(ids({ kind: 'region.enter' }), [])
|
||||
assert.deepEqual(engagement.mapShardEvent(null, tracker), [])
|
||||
assert.deepEqual(engagement.mapShardEvent({}, tracker), [])
|
||||
assert.deepEqual(engagement.mapShardEvent({ kind: 42 }, tracker), [])
|
||||
})
|
||||
|
||||
// ── Resolution: the half that reaches the database ─────────────────────────
|
||||
|
||||
// A link row shaped the way `shardLinks.model.getByAccount` actually returns
|
||||
// one, taken FROM that model rather than written out here: the model's `toSafe`
|
||||
// camel-cases the row, and a hand-written fake using the column names is a fake
|
||||
// that will agree with a resolver reading the column names. Stubbing the db
|
||||
// layer and letting the real `toSafe` run is what makes the shape non-negotiable.
|
||||
const shardLinksDb = require('../model/shardLinks/shardLinks.db')
|
||||
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||
|
||||
function linkRow(account, userId) {
|
||||
const realGet = shardLinksDb.getByAccount
|
||||
shardLinksDb.getByAccount = async () => ({
|
||||
account, user_id: userId, char_name: 'Zara Crowe', linked_at: new Date(0),
|
||||
})
|
||||
try {
|
||||
return shardLinksModel.getByAccount(account)
|
||||
} finally {
|
||||
shardLinksDb.getByAccount = realGet
|
||||
}
|
||||
}
|
||||
|
||||
function deps(over = {}) {
|
||||
const emitted = []
|
||||
return {
|
||||
emitted,
|
||||
emit: (triggerId, envelope) => emitted.push({ triggerId, envelope }),
|
||||
tracker,
|
||||
shardLinks: {
|
||||
// Shaped by the REAL model's `toSafe`, not by the column names. A fake that
|
||||
// returns `user_id` agrees with a resolver that reads `user_id`, and the
|
||||
// pair passes while every owner-audienced trigger reaches nobody on a live
|
||||
// shard — which is exactly what happened. `linkRow` below is the guard.
|
||||
getByAccount: async (acct) => (acct === 'seed_002' ? linkRow(acct, 7) : null),
|
||||
userIdsForAccounts: async (accounts) => (accounts.includes('seed_002') ? [7, 9] : []),
|
||||
...over.shardLinks,
|
||||
},
|
||||
shardState: {
|
||||
listHouses: async () => [{ serial: '0x400142F9', ownerAcct: 'seed_002', name: 'Millrace', region: 'Britain' }],
|
||||
listGuilds: async () => [{ id: 1042, name: 'The Silver Hand', abbr: 'TSH' }],
|
||||
listGuildMembers: async () => [{ serial: '0x77', name: 'Bran' }],
|
||||
listGuildMemberAccounts: async () => ['seed_002'],
|
||||
...over.shardState,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('an owner-keyed event resolves the game account to a website user', async () => {
|
||||
const d = deps()
|
||||
await engagement.fromShardEvent(DECAY, d)
|
||||
assert.equal(d.emitted.length, 1)
|
||||
assert.equal(d.emitted[0].triggerId, 'uo.house.idoc_warning')
|
||||
assert.equal(d.emitted[0].envelope.ownerUserId, 7)
|
||||
})
|
||||
|
||||
test('an UNLINKED owner is nobody to notify, and that is not an error', async () => {
|
||||
// The common case on every shard: most game accounts have never been linked.
|
||||
const d = deps()
|
||||
await engagement.fromShardEvent({ ...DECAY, ownerAcct: 'nobody' }, d)
|
||||
assert.deepEqual(d.emitted, [])
|
||||
})
|
||||
|
||||
test('house.remove fills the owner and the name in from the registry mirror', async () => {
|
||||
const d = deps()
|
||||
await engagement.fromShardEvent({ kind: 'house.remove', serial: '0x400142F9' }, d)
|
||||
assert.equal(d.emitted.length, 1)
|
||||
assert.equal(d.emitted[0].envelope.ownerUserId, 7)
|
||||
assert.equal(d.emitted[0].envelope.data.houseName, 'Millrace')
|
||||
})
|
||||
|
||||
test('a guild event carries its own access-checked recipient set, not an ownerUserId', async () => {
|
||||
// §5.1a: "the members of THIS guild" is a different answer every firing, so a
|
||||
// saved segment cannot express it and the set travels on the envelope
|
||||
// (Phase 6, decision 2 — the mechanism the Team fan-out was built on).
|
||||
const d = deps()
|
||||
await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, name: 'The Silver Hand', who: '0x77' }, d)
|
||||
assert.equal(d.emitted.length, 1)
|
||||
assert.deepEqual(d.emitted[0].envelope.recipientUserIds, [7, 9])
|
||||
assert.equal(d.emitted[0].envelope.ownerUserId, undefined)
|
||||
// The two names the frames do not carry come from the mirrors.
|
||||
assert.equal(d.emitted[0].envelope.data.memberName, 'Bran')
|
||||
})
|
||||
|
||||
test('guild.remove names the guild from the board, because the frame carries only an id', async () => {
|
||||
const d = deps()
|
||||
await engagement.fromShardEvent({ kind: 'guild.remove', id: 1042 }, d)
|
||||
assert.equal(d.emitted[0].envelope.data.guildName, 'The Silver Hand')
|
||||
assert.equal(d.emitted[0].envelope.data.abbreviation, 'TSH')
|
||||
})
|
||||
|
||||
test('a guild whose members have all unlinked reaches nobody rather than everybody', async () => {
|
||||
const d = deps({ shardLinks: { userIdsForAccounts: async () => [] } })
|
||||
await engagement.fromShardEvent({ kind: 'guild.leave', id: 1042, who: '0x77' }, d)
|
||||
assert.deepEqual(d.emitted, [])
|
||||
})
|
||||
|
||||
test('a subscribers-shaped event needs no resolution at all', async () => {
|
||||
const d = deps()
|
||||
engagement.mapShardEvent(champ({ active: false }), tracker) // establish the transition
|
||||
await engagement.fromShardEvent(champ({ active: true }), d)
|
||||
assert.equal(d.emitted.length, 1)
|
||||
assert.equal(d.emitted[0].envelope.ownerUserId, undefined)
|
||||
assert.equal(d.emitted[0].envelope.recipientUserIds, undefined)
|
||||
})
|
||||
|
||||
test('a failing lookup costs that one target and never the ingest feed', async () => {
|
||||
const d = deps({ shardLinks: { getByAccount: async () => { throw new Error('db is down') } } })
|
||||
await assert.doesNotReject(() => engagement.fromShardEvent(DECAY, d))
|
||||
assert.deepEqual(d.emitted, [])
|
||||
})
|
||||
126
server/test/shardIngest.eventReconcile.test.js
Normal file
126
server/test/shardIngest.eventReconcile.test.js
Normal file
@@ -0,0 +1,126 @@
|
||||
// A shard restart makes the event resource ledger a claim about a world that no
|
||||
// longer exists (EVENTS.md §F, EVENTS_PLAN.md Phases 8 and 9).
|
||||
//
|
||||
// Core cannot notice that on its own — it has no concept of the game being up —
|
||||
// so the module says when, and `server.hello` carrying a *changed* `bootId` is
|
||||
// the only signal that distinguishes a shard restart from a sidecar reconnect.
|
||||
// Getting that wrong in either direction is a real failure: never asking leaves
|
||||
// core believing a ledger of things that are gone, and asking on every reconnect
|
||||
// makes core orphan rows that are perfectly alive.
|
||||
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../utils/shardIngest')
|
||||
|
||||
function makeDeps() {
|
||||
const order = []
|
||||
const noop = async () => {}
|
||||
return {
|
||||
order,
|
||||
shardEvents: { append: noop },
|
||||
shardState: { clearOnline: async () => { order.push('clearOnline') }, upsertOnline: noop, setOffline: noop },
|
||||
shardLinks: {},
|
||||
shardMarket: {},
|
||||
uoLinkConfig: { recordStatus: async (row) => { order.push(`recordStatus:${row.bootId}`) } },
|
||||
settings: { getInstanceName: async () => 'Rig' },
|
||||
broadcast: () => {},
|
||||
pushDispatch: () => {},
|
||||
engagement: () => {},
|
||||
eventsReconcile: () => { order.push('reconcile') },
|
||||
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||
}
|
||||
}
|
||||
|
||||
const hello = (bootId) => ({ kind: 'server.hello', t: '2026-09-04T10:00:00Z', shard: 'Rig', bootId })
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('the first hello of a process is not a restart', async () => {
|
||||
// The website has just come up and the shard has not moved. Everything in the
|
||||
// ledger is still in force, and asking would be core spending a round trip per
|
||||
// module to be told so.
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
assert.ok(!deps.order.includes('reconcile'))
|
||||
})
|
||||
|
||||
test('a sidecar reconnect is not a restart either', async () => {
|
||||
// `server.hello` is sent on EVERY reconnect, and the sidecar dropping its
|
||||
// socket changes nothing in the game. Reconciling here would orphan every live
|
||||
// row — the ledger would still be right and core would stop believing it.
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
assert.ok(!deps.order.includes('reconcile'))
|
||||
})
|
||||
|
||||
test('a changed bootId asks every module to reconcile its ledger', async () => {
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await shardIngest.ingest(hello('boot-2'), deps)
|
||||
assert.equal(deps.order.filter((s) => s === 'reconcile').length, 1)
|
||||
})
|
||||
|
||||
test('the reconcile happens AFTER the new bootId is recorded', async () => {
|
||||
// The ordering is load-bearing rather than tidy. Every action decides what is
|
||||
// still in force by comparing its stamp against the CURRENT boot id, which it
|
||||
// reads back out of the row `recordStatus` writes. Asking first would compare
|
||||
// every resource against the boot that has just ended — and every one of them
|
||||
// would look live, which is the exact opposite of what a restart means.
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await shardIngest.ingest(hello('boot-2'), deps)
|
||||
|
||||
const recordedAt = deps.order.lastIndexOf('recordStatus:boot-2')
|
||||
const askedAt = deps.order.indexOf('reconcile')
|
||||
assert.ok(recordedAt >= 0 && askedAt >= 0)
|
||||
assert.ok(askedAt > recordedAt, 'reconcile must not run before the new boot id is stored')
|
||||
})
|
||||
|
||||
test('a hello with no bootId at all changes nothing', async () => {
|
||||
// An older plugin, or a frame that lost the field. Not knowing which boot this
|
||||
// is cannot be allowed to read as "a new one".
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await shardIngest.ingest({ kind: 'server.hello', t: '2026-09-04T10:00:00Z', shard: 'Rig' }, deps)
|
||||
assert.ok(!deps.order.includes('reconcile'))
|
||||
})
|
||||
|
||||
test('a backfill replay never reconciles, however many boots it walks through', async () => {
|
||||
// **The defect the live rig found, and nothing else could.** A WS reconnect
|
||||
// replays the last several `server.hello` frames in order — this rig saw three,
|
||||
// each with a different `bootId` — so every replayed frame looks like a
|
||||
// restart. Acting on the intermediate ones would compare a resource stamped
|
||||
// with the CURRENT boot against a boot that ended hours ago and mark it
|
||||
// `orphaned`: a live crier line core will never take down again, lost to
|
||||
// nothing worse than the website reconnecting.
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
for (const boot of ['boot-2', 'boot-3', 'boot-4']) {
|
||||
await shardIngest.ingest(hello(boot), { ...deps, fromBackfill: true })
|
||||
}
|
||||
assert.ok(!deps.order.includes('reconcile'))
|
||||
// The replay still moves the tracked boot on, so the NEXT live hello is
|
||||
// measured against where the replay left off rather than against boot-1.
|
||||
assert.ok(deps.order.includes('recordStatus:boot-4'))
|
||||
})
|
||||
|
||||
test('a live hello after a replay is still a restart', async () => {
|
||||
// The gate is about the frame, not about the module going quiet: skipping the
|
||||
// replay must not make the next genuine restart invisible.
|
||||
const deps = makeDeps()
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await shardIngest.ingest(hello('boot-2'), { ...deps, fromBackfill: true })
|
||||
await shardIngest.ingest(hello('boot-3'), deps)
|
||||
assert.equal(deps.order.filter((s) => s === 'reconcile').length, 1)
|
||||
})
|
||||
|
||||
test('a reconcile that throws does not take the ingest down with it', async () => {
|
||||
// Fire-and-forget by the contract, and the feed must survive one bad module:
|
||||
// `ingest()` never throws, because a single event may not kill the socket.
|
||||
const deps = makeDeps()
|
||||
deps.eventsReconcile = () => { throw new Error('registry exploded') }
|
||||
await shardIngest.ingest(hello('boot-1'), deps)
|
||||
await assert.doesNotReject(() => shardIngest.ingest(hello('boot-2'), deps))
|
||||
})
|
||||
75
server/test/shardIngest.guildRoster.test.js
Normal file
75
server/test/shardIngest.guildRoster.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// Protocol 4 membership routing: guild.roster (board state, possibly chunked) and
|
||||
// guild.leave (a real-time departure, logged like its guild.join counterpart).
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../utils/shardIngest')
|
||||
|
||||
function makeDeps() {
|
||||
const calls = { roster: [], memberRemove: [], appended: [], broadcast: [] }
|
||||
const noop = async () => {}
|
||||
return {
|
||||
calls,
|
||||
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||
shardState: {
|
||||
upsertGuildRoster: async (ev) => { calls.roster.push(ev) },
|
||||
removeGuildMember: async (ev) => { calls.memberRemove.push(ev) },
|
||||
upsertGuild: noop, removeGuild: noop,
|
||||
clearOnline: noop, upsertOnline: noop, setOffline: noop,
|
||||
addEconomySample: noop,
|
||||
},
|
||||
shardLinks: { removeByAccount: noop },
|
||||
uoLinkConfig: { recordStatus: noop },
|
||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||
pushDispatch: async () => {},
|
||||
log: { warn() {}, info() {}, error() {} },
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('guild.roster routes to upsertGuildRoster and is NOT logged', async () => {
|
||||
// It is board state like guild.update, and the one fat frame on the wire —
|
||||
// logging it would put a full membership snapshot in shard_events on every
|
||||
// membership change.
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'guild.roster', id: 7, seq: 0, more: false, total: 2,
|
||||
members: [{ serial: '0x1', name: 'Ada' }, { serial: '0x2', name: 'Bo' }], t: 1 },
|
||||
deps,
|
||||
)
|
||||
|
||||
assert.equal(deps.calls.roster.length, 1)
|
||||
assert.equal(deps.calls.roster[0].id, 7)
|
||||
assert.equal(deps.calls.roster[0].members.length, 2)
|
||||
assert.equal(r.logged, false)
|
||||
})
|
||||
|
||||
test('every frame of a chunked roster reaches the model, seq intact', async () => {
|
||||
// The sidecar reassembles for its own board, but the live feed and the /history
|
||||
// backfill both carry individual frames — so the model must see each one with its
|
||||
// seq, which is what tells it whether to clear the guild first.
|
||||
const deps = makeDeps()
|
||||
|
||||
for (const [seq, more, serial] of [[0, true, '0x1'], [1, true, '0x2'], [2, false, '0x3']]) {
|
||||
await shardIngest.ingest(
|
||||
{ kind: 'guild.roster', id: 7, seq, more, total: 3, members: [{ serial, name: serial }], t: 1 },
|
||||
deps,
|
||||
)
|
||||
}
|
||||
|
||||
assert.deepEqual(deps.calls.roster.map((e) => e.seq), [0, 1, 2])
|
||||
assert.deepEqual(deps.calls.roster.map((e) => e.more), [true, true, false])
|
||||
})
|
||||
|
||||
test('guild.leave is logged and broadcast, like guild.join', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'guild.leave', id: 7, name: 'The Cartographers', who: '0x2', t: 2 }, deps)
|
||||
|
||||
assert.equal(r.logged, true)
|
||||
assert.equal(deps.calls.appended.length, 1)
|
||||
assert.equal(deps.calls.appended[0].kind, 'guild.leave')
|
||||
assert.equal(deps.calls.broadcast.length, 1)
|
||||
assert.deepEqual(deps.calls.memberRemove.map((e) => e.who), ['0x2'])
|
||||
})
|
||||
414
server/test/shardItemArt.model.test.js
Normal file
414
server/test/shardItemArt.model.test.js
Normal file
@@ -0,0 +1,414 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const core = require('../core')
|
||||
const model = require('../model/shardAssets/shardItemArt.model')
|
||||
const db = require('../model/shardAssets/shardAssets.db')
|
||||
const bridge = require('../utils/assetBridge')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
|
||||
// The warm pass as a decision, with the shard and the database stubbed
|
||||
// (docs/link/v8.md §5, §11 — protocol 8, phase 5).
|
||||
//
|
||||
// Item art has no manifest, so almost everything the body import gets from a
|
||||
// hash diff this side has to get right by construction instead. Each test below
|
||||
// is a way that goes wrong quietly:
|
||||
//
|
||||
// - Asking an overlay that cannot answer. A phase-4 plugin serves the creature
|
||||
// catalogue and nothing else, and every static key it is sent is refused —
|
||||
// once per pass, forever, in the log, with no picture ever appearing.
|
||||
// - Re-fetching pictures the site already holds. There is no manifest to make
|
||||
// that obvious, so the only thing standing between a working install and a
|
||||
// pass that re-downloads its whole working set every five minutes is the
|
||||
// per-row catalogue id.
|
||||
// - NOT re-fetching after a client patch. The same field, read the other way.
|
||||
// - Writing a row for a key the shard has no art for. It would make the key
|
||||
// "held", and it would never be asked again — including after the operator
|
||||
// patches in the graphic that was missing.
|
||||
// - Spelling `static/3922/h0`. The shard refuses it outright (hue 0 means "not
|
||||
// hued"), so a disagreement here is a picture that never arrives.
|
||||
|
||||
const saved = {}
|
||||
|
||||
function stub({
|
||||
families = ['body', 'land', 'static'],
|
||||
wanted = [],
|
||||
fresh = new Set(),
|
||||
files = new Map(),
|
||||
fetched,
|
||||
catalog = 'cat-current',
|
||||
linked = true,
|
||||
} = {}) {
|
||||
saved.sourceFingerprint = bridge.sourceFingerprint
|
||||
saved.fetchAssets = bridge.fetchAssets
|
||||
saved.freshKeys = db.freshKeys
|
||||
saved.filesForKeys = db.filesForKeys
|
||||
saved.saveAssets = db.saveAssets
|
||||
saved.getSafe = uoLinkConfig.getSafe
|
||||
saved.query = core.query
|
||||
|
||||
const seen = { asked: [], saved: null, freshAsked: null, calls: 0 }
|
||||
|
||||
uoLinkConfig.getSafe = async () =>
|
||||
linked ? { enabled: true, baseUrl: 'http://127.0.0.1:8080' } : { enabled: false }
|
||||
|
||||
bridge.sourceFingerprint = async () => ({
|
||||
files: { 'art.mul': { size: 1, mtime: 2, sha256: 'x' } },
|
||||
extractorVersion: 2,
|
||||
hashing: false,
|
||||
complete: true,
|
||||
imaging: { ok: true },
|
||||
families,
|
||||
})
|
||||
|
||||
bridge.fetchAssets = async ({ keys }) => {
|
||||
seen.calls++
|
||||
seen.asked.push(keys)
|
||||
|
||||
// The catalogue probe asks for exactly one key and throws the answer away.
|
||||
if (keys.length === 1 && keys[0] === 'static/0' && !fetched?.assets?.has('static/0')) {
|
||||
return { assets: new Map(), missing: { absent: 1, unsupported: 0 }, pages: 1, catalog }
|
||||
}
|
||||
|
||||
return (
|
||||
fetched ?? { assets: new Map(), missing: { absent: 0, unsupported: 0 }, pages: 1, catalog }
|
||||
)
|
||||
}
|
||||
|
||||
// The derived set: what `SELECT DISTINCT item_id, hue FROM shard_vendor_items`
|
||||
// would return.
|
||||
core.query = async () => wanted
|
||||
|
||||
db.freshKeys = async (keys, askedCatalog) => {
|
||||
seen.freshAsked = { keys, catalog: askedCatalog }
|
||||
return fresh
|
||||
}
|
||||
db.filesForKeys = async () => files
|
||||
db.saveAssets = async (rows, meta) => {
|
||||
seen.saved = { rows, meta }
|
||||
return rows.length
|
||||
}
|
||||
|
||||
return seen
|
||||
}
|
||||
|
||||
function restore() {
|
||||
if (saved.sourceFingerprint) bridge.sourceFingerprint = saved.sourceFingerprint
|
||||
if (saved.fetchAssets) bridge.fetchAssets = saved.fetchAssets
|
||||
if (saved.freshKeys) db.freshKeys = saved.freshKeys
|
||||
if (saved.filesForKeys) db.filesForKeys = saved.filesForKeys
|
||||
if (saved.saveAssets) db.saveAssets = saved.saveAssets
|
||||
if (saved.getSafe) uoLinkConfig.getSafe = saved.getSafe
|
||||
if (saved.query) core.query = saved.query
|
||||
}
|
||||
|
||||
function useTempUploads(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'uo-items-'))
|
||||
const previous = core.uploads
|
||||
|
||||
Object.defineProperty(core, 'uploads', {
|
||||
configurable: true,
|
||||
get: () => ({ ...previous, UPLOAD_DIR: dir }),
|
||||
})
|
||||
|
||||
t.after(() => {
|
||||
Object.defineProperty(core, 'uploads', { configurable: true, get: () => previous })
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
const picture = (sha) => ({
|
||||
sha256: sha,
|
||||
bytes: 294,
|
||||
width: 22,
|
||||
height: 26,
|
||||
hue: null,
|
||||
partialHue: null,
|
||||
source: 'uop',
|
||||
png: Buffer.from('not really a png'),
|
||||
})
|
||||
|
||||
// ── keys ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test('hue 0 is the plain key, because the shard refuses /h0 for the same reason', () => {
|
||||
// The wire's hue 0 means "this item is not hued". If this spelled `/h0` the
|
||||
// shard would answer `unsupported` and the picture would never arrive; if the
|
||||
// shard accepted it, the identical PNG would be stored twice under two names
|
||||
// and diffed separately forever. The two sides agreeing is the whole point.
|
||||
assert.equal(model.staticKey(3922, 0), 'static/3922')
|
||||
assert.equal(model.staticKey(3922), 'static/3922')
|
||||
assert.equal(model.staticKey(3922, null), 'static/3922')
|
||||
assert.equal(model.staticKey(3922, 33), 'static/3922/h33')
|
||||
})
|
||||
|
||||
test('a key is refused rather than fabricated for input that is not an item id', () => {
|
||||
assert.equal(model.staticKey(-5), null)
|
||||
assert.equal(model.staticKey('frog'), null)
|
||||
assert.equal(model.staticKey(undefined), null)
|
||||
assert.equal(model.landKey(0x4000), null)
|
||||
assert.equal(model.landKey(3), 'land/3')
|
||||
})
|
||||
|
||||
// ── the overlay gate ───────────────────────────────────────────────────────
|
||||
|
||||
test('an overlay that serves only the creature catalogue is reported, not asked', async (t) => {
|
||||
// A phase-3 or phase-4 plugin. Every static key sent to it comes back refused,
|
||||
// so discovering this per request would mean a warn per pass forever and no
|
||||
// picture ever. It is one check, once, with a sentence naming the fix.
|
||||
const seen = stub({ families: ['body'], wanted: [{ item_id: 3922, hue: 0 }] })
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.warm()
|
||||
|
||||
assert.equal(result.status, 'unavailable')
|
||||
assert.equal(result.code, 'UNSUPPORTED')
|
||||
assert.match(result.reason, /does not serve item art/)
|
||||
assert.equal(seen.calls, 0, 'nothing should have been asked of the shard')
|
||||
})
|
||||
|
||||
test('no shard link is skipped, not failed', async (t) => {
|
||||
stub({ linked: false })
|
||||
t.after(restore)
|
||||
|
||||
assert.equal((await model.warm()).status, 'skipped')
|
||||
})
|
||||
|
||||
test('a host that cannot render images is the named NO_IMAGING state', async (t) => {
|
||||
stub()
|
||||
t.after(restore)
|
||||
|
||||
bridge.sourceFingerprint = async () => ({
|
||||
files: {},
|
||||
extractorVersion: 2,
|
||||
hashing: false,
|
||||
complete: true,
|
||||
imaging: { ok: false, reason: 'libgdiplus is not installed' },
|
||||
families: ['body', 'static'],
|
||||
})
|
||||
|
||||
const result = await model.warm()
|
||||
|
||||
assert.equal(result.status, 'unavailable')
|
||||
assert.equal(result.code, 'NO_IMAGING')
|
||||
})
|
||||
|
||||
// ── what gets asked for ────────────────────────────────────────────────────
|
||||
|
||||
test('only the keys we do not already hold under the shard’s current catalogue are fetched', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const seen = stub({
|
||||
wanted: [
|
||||
{ item_id: 3922, hue: 0 },
|
||||
{ item_id: 597, hue: 33 },
|
||||
{ item_id: 1, hue: 0 },
|
||||
],
|
||||
// 3922 is held and current; the other two are not.
|
||||
fresh: new Set(['static/3922']),
|
||||
fetched: {
|
||||
assets: new Map([
|
||||
['static/597/h33', picture('aaa')],
|
||||
['static/1', picture('bbb')],
|
||||
]),
|
||||
missing: { absent: 0, unsupported: 0 },
|
||||
pages: 1,
|
||||
catalog: 'cat-current',
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.warm()
|
||||
|
||||
assert.equal(result.status, 'imported')
|
||||
|
||||
// The first call is the catalogue probe; the second is the real fetch.
|
||||
const asked = seen.asked[seen.asked.length - 1]
|
||||
|
||||
assert.deepEqual(asked.sort(), ['static/1', 'static/597/h33'])
|
||||
assert.equal(
|
||||
seen.freshAsked.catalog,
|
||||
'cat-current',
|
||||
'staleness must be asked against the catalogue the shard answers under right now, ' +
|
||||
'or a client patch never invalidates anything',
|
||||
)
|
||||
})
|
||||
|
||||
test('every stored row records the catalogue it was fetched under', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const seen = stub({
|
||||
wanted: [{ item_id: 1, hue: 0 }],
|
||||
fetched: {
|
||||
assets: new Map([['static/1', picture('bbb')]]),
|
||||
missing: { absent: 0, unsupported: 0 },
|
||||
pages: 1,
|
||||
catalog: 'cat-after-patch',
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await model.warm()
|
||||
|
||||
// Without this field there is no way to answer "is this picture out of date?"
|
||||
// for a family that has no manifest — which is the entire §7 story on this side.
|
||||
assert.equal(seen.saved.rows.length, 1)
|
||||
assert.equal(seen.saved.rows[0].catalog, 'cat-after-patch')
|
||||
assert.equal(seen.saved.rows[0].family, 'static')
|
||||
})
|
||||
|
||||
test('the body catalogue’s meta singleton is never written by a warm pass', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const seen = stub({
|
||||
wanted: [{ item_id: 1, hue: 0 }],
|
||||
fetched: {
|
||||
assets: new Map([['static/1', picture('bbb')]]),
|
||||
missing: { absent: 0, unsupported: 0 },
|
||||
pages: 1,
|
||||
catalog: 'cat-current',
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await model.warm()
|
||||
|
||||
// `shard_asset_meta` is what an Update compares a BODY manifest against. A
|
||||
// warm pass writing there would tell the body import that a client it never
|
||||
// looked at is unchanged, and the creature catalogue would stop updating.
|
||||
assert.equal(seen.saved.meta, null)
|
||||
})
|
||||
|
||||
test('a key the shard has no art for produces no row, so it can be asked again', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const seen = stub({
|
||||
wanted: [
|
||||
{ item_id: 1, hue: 0 },
|
||||
{ item_id: 60000, hue: 0 },
|
||||
],
|
||||
fetched: {
|
||||
assets: new Map([['static/1', picture('bbb')]]),
|
||||
missing: { absent: 1, unsupported: 0 },
|
||||
pages: 1,
|
||||
catalog: 'cat-current',
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.warm()
|
||||
|
||||
assert.equal(result.absent, 1)
|
||||
assert.deepEqual(
|
||||
seen.saved.rows.map((r) => r.key),
|
||||
['static/1'],
|
||||
'an empty row would make the key held, and it would never be asked again — ' +
|
||||
'including after the operator patches in the graphic that was missing',
|
||||
)
|
||||
})
|
||||
|
||||
test('a pass is bounded, and says how much it left behind', async (t) => {
|
||||
useTempUploads(t)
|
||||
|
||||
const wanted = []
|
||||
for (let i = 1; i <= 10; i++) wanted.push({ item_id: i, hue: 0 })
|
||||
|
||||
const seen = stub({
|
||||
wanted,
|
||||
fetched: {
|
||||
assets: new Map([['static/1', picture('bbb')]]),
|
||||
missing: { absent: 0, unsupported: 0 },
|
||||
pages: 1,
|
||||
catalog: 'cat-current',
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
const result = await model.warm({ limit: 4 })
|
||||
|
||||
assert.equal(seen.asked[seen.asked.length - 1].length, 4)
|
||||
assert.equal(result.asked, 4)
|
||||
assert.equal(result.remaining, 6)
|
||||
})
|
||||
|
||||
test('a picture whose bytes changed replaces its file instead of shadowing it', async (t) => {
|
||||
const dir = useTempUploads(t)
|
||||
|
||||
const old = model.fileNameFor('static/1', 'old00000')
|
||||
fs.mkdirSync(model.artDir(), { recursive: true })
|
||||
fs.writeFileSync(path.join(model.artDir(), old), 'stale')
|
||||
|
||||
stub({
|
||||
wanted: [{ item_id: 1, hue: 0 }],
|
||||
files: new Map([['static/1', old]]),
|
||||
fetched: {
|
||||
assets: new Map([['static/1', picture('new00000')]]),
|
||||
missing: { absent: 0, unsupported: 0 },
|
||||
pages: 1,
|
||||
catalog: 'cat-after-patch',
|
||||
},
|
||||
})
|
||||
t.after(restore)
|
||||
|
||||
await model.warm()
|
||||
|
||||
const names = fs.readdirSync(path.join(dir, model.ART_SUBDIR))
|
||||
|
||||
// Content-addressed names mean a changed picture is a changed URL, so nothing
|
||||
// keeps serving last client's sprite from a cache — and the superseded file is
|
||||
// removed rather than left to accumulate one per client patch forever.
|
||||
assert.deepEqual(names, [model.fileNameFor('static/1', 'new00000')])
|
||||
})
|
||||
|
||||
// ── serving ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('decorate attaches a filename, never a URL, and null where there is none', async (t) => {
|
||||
stub({ files: new Map([['static/3922', 'uo-static-3922-abcd1234.png']]) })
|
||||
t.after(restore)
|
||||
|
||||
const rows = [
|
||||
{ itemId: 3922, hue: 0 },
|
||||
{ itemId: 597, hue: 33 },
|
||||
]
|
||||
|
||||
await model.decorate(rows)
|
||||
|
||||
// A filename, because the client is what knows where uploads are mounted —
|
||||
// the same contract `shard_spawn_creatures.art` already uses.
|
||||
assert.equal(rows[0].art, 'uo-static-3922-abcd1234.png')
|
||||
assert.equal(rows[1].art, null)
|
||||
})
|
||||
|
||||
test('decorate never throws a page away over a picture', async (t) => {
|
||||
stub()
|
||||
t.after(restore)
|
||||
|
||||
db.filesForKeys = async () => {
|
||||
throw new Error('the database is on fire')
|
||||
}
|
||||
|
||||
const rows = [{ itemId: 3922, hue: 0 }]
|
||||
|
||||
await model.decorate(rows)
|
||||
|
||||
assert.deepEqual(rows, [{ itemId: 3922, hue: 0 }], 'the row is returned unchanged, not lost')
|
||||
})
|
||||
|
||||
test('what a page asked for is remembered, including what it could not show', async (t) => {
|
||||
stub({ files: new Map() })
|
||||
t.after(restore)
|
||||
|
||||
const before = model.noticedCount()
|
||||
|
||||
await model.decorate([{ itemId: 12345, hue: 7 }])
|
||||
|
||||
// The character sheet is fetched live from the shard and stored nowhere, so
|
||||
// nothing on disk would ever name this key. Noticing it here is the only reason
|
||||
// a warm pass can find it.
|
||||
assert.ok(model.noticedCount() > before)
|
||||
assert.ok((await model.wantedKeys()).includes('static/12345/h7'))
|
||||
})
|
||||
@@ -261,3 +261,91 @@ test('the cliloc resolver is the path shapeItems resolves through', async () =>
|
||||
const found = await clilocs.resolveMany([1023721])
|
||||
assert.equal(found.get(1023721), 'quarter staff')
|
||||
})
|
||||
|
||||
// ── Protocol 5: owner account and fee state ────────────────────────────────
|
||||
|
||||
const V5_FEES = {
|
||||
exempt: false,
|
||||
newVendorSystem: true,
|
||||
chargePerPeriod: 148,
|
||||
funds: 2960,
|
||||
holdGold: 2960,
|
||||
bankAccount: 0,
|
||||
payIntervalSec: 86400,
|
||||
nextPayAt: '2026-09-01T00:00:00.000Z',
|
||||
periodsRemaining: 20,
|
||||
dismissalAt: '2026-09-21T00:00:00.000Z',
|
||||
}
|
||||
|
||||
test('flattenFrame lifts ownerAcct, the field that makes a shop resolvable to a person', () => {
|
||||
// ownerName has been on the frame since v3, but a character name joins to nothing:
|
||||
// shard_account_links is keyed by the game ACCOUNT.
|
||||
const row = market.flattenFrame({ ...FRAME, ownerAcct: 'darrow_acct', fees: V5_FEES })
|
||||
assert.equal(row.ownerAcct, 'darrow_acct')
|
||||
assert.equal(row.ownerName, 'Darrow', 'the character name is still carried too')
|
||||
})
|
||||
|
||||
test('flattenFrame normalises the fee block, dates included', () => {
|
||||
const row = market.flattenFrame({ ...FRAME, fees: V5_FEES })
|
||||
assert.equal(row.feesExempt, false)
|
||||
assert.equal(row.chargePerPeriod, 148)
|
||||
assert.equal(row.funds, 2960)
|
||||
assert.equal(row.payIntervalSec, 86400)
|
||||
assert.equal(row.periodsRemaining, 20)
|
||||
assert.ok(row.nextPayAt instanceof Date)
|
||||
assert.equal(row.dismissalAt.toISOString(), '2026-09-21T00:00:00.000Z')
|
||||
})
|
||||
|
||||
// The shard resolved dismissalAt against ServUO's two vendor systems, whose charge,
|
||||
// funds and pay interval all differ. Re-deriving it here would be a second
|
||||
// implementation of a rule that lives in PlayerVendor.PayTimer.
|
||||
test('flattenFrame trusts the shard dismissal date instead of recomputing it', () => {
|
||||
const row = market.flattenFrame({
|
||||
...FRAME,
|
||||
fees: { ...V5_FEES, dismissalAt: '2026-12-25T00:00:00.000Z' },
|
||||
})
|
||||
assert.equal(row.dismissalAt.toISOString(), '2026-12-25T00:00:00.000Z')
|
||||
})
|
||||
|
||||
// A commission vendor has no pay timer and is never dismissed for fees. That is a
|
||||
// different thing from having a long time left, and a surface rendering "never" has
|
||||
// to be able to tell them apart.
|
||||
test('an exempt vendor reports exempt with no schedule at all', () => {
|
||||
const row = market.flattenFrame({ ...FRAME, fees: { exempt: true } })
|
||||
assert.equal(row.feesExempt, true)
|
||||
assert.equal(row.dismissalAt, null)
|
||||
assert.equal(row.periodsRemaining, null)
|
||||
assert.equal(row.chargePerPeriod, null)
|
||||
})
|
||||
|
||||
// A pre-v5 overlay omits `fees` entirely, and a shard can be rolled back to one.
|
||||
// Nulls have to mean "this shard has not told me", never "this vendor is broke" —
|
||||
// the difference between silence and a false alarm in a rule that mails an owner.
|
||||
test('a pre-v5 frame yields nulls, not zeroes', () => {
|
||||
const row = market.flattenFrame(FRAME)
|
||||
assert.equal(row.feesExempt, false)
|
||||
for (const key of ['chargePerPeriod', 'funds', 'payIntervalSec', 'periodsRemaining']) {
|
||||
assert.equal(row[key], null, `${key} must be null, not 0`)
|
||||
}
|
||||
assert.equal(row.nextPayAt, null)
|
||||
assert.equal(row.dismissalAt, null)
|
||||
assert.equal(row.ownerAcct, null)
|
||||
})
|
||||
|
||||
test('an unparseable fee date is dropped rather than stored as an Invalid Date', () => {
|
||||
const row = market.flattenFrame({
|
||||
...FRAME,
|
||||
fees: { ...V5_FEES, dismissalAt: 'next tuesday', nextPayAt: null },
|
||||
})
|
||||
assert.equal(row.dismissalAt, null)
|
||||
assert.equal(row.nextPayAt, null)
|
||||
assert.equal(row.funds, 2960, 'one bad field must not discard the rest of the block')
|
||||
})
|
||||
|
||||
test('a malformed fees value is treated as absent, not as a crash', () => {
|
||||
for (const fees of ['', 0, 'nope', []]) {
|
||||
const row = market.flattenFrame({ ...FRAME, fees })
|
||||
assert.equal(row.feesExempt, false)
|
||||
assert.equal(row.dismissalAt, null)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -251,3 +251,74 @@ test('listGovernorHistory coerces started/ended timestamps to numbers and clamps
|
||||
assert.equal(typeof out[0].startedAt, 'number')
|
||||
assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0
|
||||
})
|
||||
|
||||
// ── Protocol 5: the decay schedule ─────────────────────────────────────────
|
||||
|
||||
test('upsertHouse flattens the nested schedule into its four columns', async () => {
|
||||
await shardState.upsertHouse({
|
||||
serial: 1,
|
||||
stage: 'IDOC',
|
||||
schedule: {
|
||||
dynamicDecay: true,
|
||||
nextStage: '2026-09-02T04:00:00.000Z',
|
||||
decayPeriodSec: 432000,
|
||||
estimatedCollapse: '2026-09-02T04:00:00.000Z',
|
||||
},
|
||||
})
|
||||
const [, fields] = calls.upsertHouse[0]
|
||||
assert.equal(fields.dynamic_decay, 1)
|
||||
assert.equal(fields.decay_period_sec, 432000)
|
||||
assert.ok(fields.next_stage instanceof Date)
|
||||
assert.equal(fields.estimated_collapse.toISOString(), '2026-09-02T04:00:00.000Z')
|
||||
})
|
||||
|
||||
// The whole point of the field: under dynamic decay ServUO draws each stage's
|
||||
// duration at random on entry, so the shard omits estimatedCollapse everywhere but
|
||||
// IDOC. A stored null has to mean "not knowable", which it cannot if a partial
|
||||
// schedule silently keeps the previous value.
|
||||
test('a schedule without a collapse time stores null, it does not keep the old one', async () => {
|
||||
await shardState.upsertHouse({
|
||||
serial: 1,
|
||||
stage: 'Greatly',
|
||||
schedule: { dynamicDecay: true, nextStage: '2026-09-01T00:00:00.000Z', decayPeriodSec: 432000 },
|
||||
})
|
||||
const [, fields] = calls.upsertHouse[0]
|
||||
assert.equal(fields.estimated_collapse, null)
|
||||
assert.ok('estimated_collapse' in fields, 'must be WRITTEN as null, not omitted')
|
||||
})
|
||||
|
||||
// A pre-v5 overlay sends no schedule at all, and a shard can be rolled back to one.
|
||||
// Every column is still written, so a dismissal date nobody is maintaining cannot
|
||||
// be left standing.
|
||||
test('a frame with no schedule nulls all four columns rather than omitting them', async () => {
|
||||
await shardState.upsertHouse({ serial: 1, stage: 'Fairly' })
|
||||
const [, fields] = calls.upsertHouse[0]
|
||||
for (const col of ['next_stage', 'estimated_collapse', 'decay_period_sec', 'dynamic_decay']) {
|
||||
assert.ok(col in fields, `${col} must be written`)
|
||||
assert.equal(fields[col], null)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unparseable schedule date is dropped, not stored as an Invalid Date', async () => {
|
||||
await shardState.upsertHouse({
|
||||
serial: 1,
|
||||
stage: 'IDOC',
|
||||
schedule: { nextStage: 'soon-ish', estimatedCollapse: '' },
|
||||
})
|
||||
const [, fields] = calls.upsertHouse[0]
|
||||
assert.equal(fields.next_stage, null)
|
||||
assert.equal(fields.estimated_collapse, null)
|
||||
})
|
||||
|
||||
// house.update writes owner_name from its own sweep. If house.decay coalesced a
|
||||
// missing ownerName to null, every decay transition on a pre-v5 shard would erase
|
||||
// a name the registry had already resolved.
|
||||
test('house.decay never erases an owner_name it was not given', async () => {
|
||||
await shardState.upsertHouse({ serial: 1, stage: 'IDOC', ownerAcct: 'cadmus' })
|
||||
const [, fields] = calls.upsertHouse[0]
|
||||
assert.ok(!('owner_name' in fields), 'owner_name must not be written when absent')
|
||||
|
||||
await shardState.upsertHouse({ serial: 1, stage: 'IDOC', ownerName: 'Cadmus' })
|
||||
const [, withName] = calls.upsertHouse[1]
|
||||
assert.equal(withName.owner_name, 'Cadmus')
|
||||
})
|
||||
|
||||
@@ -95,6 +95,58 @@ test('an unknown viewer level cannot see a gated kind or a locked field', async
|
||||
assert.equal('webId' in out.leader, false)
|
||||
})
|
||||
|
||||
// ── Protocol 6: the champion defeat ──────────────────────────────────
|
||||
|
||||
const KILL = {
|
||||
kind: 'champ.boss.killed',
|
||||
serial: '0x40012345',
|
||||
boss: 'Semidar',
|
||||
killer: { serial: '0x55', name: 'Aldric', acct: 'seed_002', player: true },
|
||||
damagers: [
|
||||
{ serial: '0x55', name: 'Aldric', acct: 'seed_002', webId: '7', player: true, damage: 900 },
|
||||
{ serial: '0x56', name: 'Bran', acct: 'seed_003', player: true, damage: 120 },
|
||||
],
|
||||
}
|
||||
|
||||
test('the kill is public and its damage table is not', () => {
|
||||
const config = visibility.compileDefaults()
|
||||
// The whole shape of this addition in one assertion: a champion falling is
|
||||
// content the public board is FOR, and a ranked roll of who was strong enough
|
||||
// to fell it is a performance record nobody published on purpose.
|
||||
assert.equal(visibility.kindVisibleTo('champ.boss.killed', 'anonymous', config), true)
|
||||
for (const level of ['anonymous', 'logged_in', 'player']) {
|
||||
const out = visibility.projectFeature('champs', KILL, level, config)
|
||||
assert.equal(out.boss, 'Semidar', `${level} sees which boss fell`)
|
||||
assert.equal('damagers' in out, false, `${level} must not see the damage table`)
|
||||
}
|
||||
assert.equal(visibility.projectFeature('champs', KILL, 'staff', config).damagers.length, 2)
|
||||
})
|
||||
|
||||
test('the killer rides the frame the way mob.killed already publishes one', () => {
|
||||
// Deliberately NOT a configurable field. It is one actor, announced in-game to
|
||||
// everyone present, and the same disclosure the public activity feed has made
|
||||
// through `mob.killed` since before this framework existed.
|
||||
const config = visibility.compileDefaults()
|
||||
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
|
||||
assert.equal(out.killer.name, 'Aldric')
|
||||
assert.equal('acct' in out.killer, false, 'rule 1 still applies inside it')
|
||||
})
|
||||
|
||||
test('an admin who lowers the damager rule still cannot see an account inside it', () => {
|
||||
// Rule 1 beats a field rule wherever the two meet, and a damager entry is an
|
||||
// actor object like any other. An admin who opens the table to everyone has
|
||||
// published character names, which is what they chose; they have not published
|
||||
// account names, which is not theirs to choose.
|
||||
const config = visibility.compileDefaults()
|
||||
config.champs.fields = { ...config.champs.fields, damagers: 'anonymous' }
|
||||
const out = visibility.projectFeature('champs', KILL, 'anonymous', config)
|
||||
assert.equal(out.damagers.length, 2)
|
||||
assert.equal(out.damagers[0].name, 'Aldric')
|
||||
assert.equal(out.damagers[0].damage, 900)
|
||||
assert.equal('acct' in out.damagers[0], false)
|
||||
assert.equal('webId' in out.damagers[0], false)
|
||||
})
|
||||
|
||||
// ── Rule 1: locked fields ──────────────────────────────────────────────────
|
||||
|
||||
test('acct and webId are stripped below admin regardless of feature config', () => {
|
||||
@@ -116,6 +168,52 @@ test('acct and webId are stripped below admin regardless of feature config', ()
|
||||
assert.equal(asAdmin.leader.webId, '42')
|
||||
})
|
||||
|
||||
test('acct and webId are stripped from every member of a guild roster (Protocol 4)', () => {
|
||||
// A roster is the first frame where the locked fields appear inside an ARRAY of
|
||||
// actors rather than one nested actor. The walker recurses into arrays, so this
|
||||
// should already hold — this test is here because it is the difference between a
|
||||
// public Guilds page listing character names and one publishing 150 account names.
|
||||
const config = visibility.compileDefaults()
|
||||
const frame = {
|
||||
kind: 'guild.roster',
|
||||
id: 7,
|
||||
total: 3,
|
||||
seq: 0,
|
||||
more: false,
|
||||
members: [
|
||||
{ serial: '0x1', name: 'Ada', acct: 'ada_acct', webId: '11', player: true },
|
||||
{ serial: '0x2', name: 'Bo', acct: 'bo_acct', player: true },
|
||||
{ serial: '0x3', name: 'Cy', player: true }, // a mobile with no account at all
|
||||
],
|
||||
}
|
||||
|
||||
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||
const out = visibility.projectFeature('guilds', frame, level, config)
|
||||
assert.equal(out.members.length, 3, `${level} still sees every member`)
|
||||
assert.deepEqual(out.members.map((m) => m.name), ['Ada', 'Bo', 'Cy'])
|
||||
for (const m of out.members) {
|
||||
assert.equal('acct' in m, false, `${level} must not see a member's acct`)
|
||||
assert.equal('webId' in m, false, `${level} must not see a member's webId`)
|
||||
}
|
||||
}
|
||||
|
||||
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
|
||||
assert.equal(asAdmin.members[0].acct, 'ada_acct')
|
||||
assert.equal(asAdmin.members[0].webId, '11')
|
||||
})
|
||||
|
||||
test('guild.roster and guild.leave are mapped, so neither falls closed to admin-only', () => {
|
||||
// Rule 2 fails an unmapped kind closed. That is the right default, but for these
|
||||
// two it would silently keep the public Guilds page from ever seeing a roster.
|
||||
const config = visibility.compileDefaults()
|
||||
for (const kind of ['guild.roster', 'guild.leave']) {
|
||||
assert.equal(
|
||||
visibility.kindVisibleTo(kind, 'anonymous', config), true,
|
||||
`${kind} should reach an anonymous viewer under the default guilds config`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('a stored rule trying to loosen a locked field is ignored', async () => {
|
||||
withRows([
|
||||
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
|
||||
@@ -308,10 +406,27 @@ const PRE_V3_PUBLIC_KINDS = [
|
||||
// pointedly not among them (its feature ships with stream off).
|
||||
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
|
||||
|
||||
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
|
||||
// v4 adds guild membership. Both ride the existing `guilds` feature, which is
|
||||
// already anonymous, so they join the public set — carrying character names and
|
||||
// serials, never acct/webId, which the locked-field rules strip by suffix even
|
||||
// inside the roster's member array (see the roster test above).
|
||||
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
|
||||
|
||||
// v6 adds the champion defeat. It rides the existing `champs` feature, which is
|
||||
// already anonymous, so the KIND is public — while the `damagers` table on it is
|
||||
// `staff` by field rule. That split is the point: a shard announces that its
|
||||
// champion fell without publishing a roll of who was strong enough to fell it.
|
||||
const V6_ADDED_PUBLIC_KINDS = ['champ.boss.killed']
|
||||
|
||||
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3, v4 and v6 additions', () => {
|
||||
assert.deepEqual(
|
||||
[...visibility.PUBLIC_KINDS].sort(),
|
||||
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
|
||||
[
|
||||
...PRE_V3_PUBLIC_KINDS,
|
||||
...V3_ADDED_PUBLIC_KINDS,
|
||||
...V4_ADDED_PUBLIC_KINDS,
|
||||
...V6_ADDED_PUBLIC_KINDS,
|
||||
].sort(),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -424,3 +539,109 @@ test('a link lookup failure downgrades rather than escalating', async () => {
|
||||
visibility.forgetUser(6)
|
||||
assert.equal(await visibility.viewerLevel({ user: { id: 6, role: 'player' } }), 'logged_in')
|
||||
})
|
||||
|
||||
// ── Protocol 5 ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Two new nested field groups and one new kind. All three exist as visibility
|
||||
// questions before they exist as features, which is the order this framework's
|
||||
// rule 2 is designed to force: a v5 field that nobody classified would either
|
||||
// leak (if it fell open) or be silently invisible (if it fell closed and nobody
|
||||
// noticed). These tests pin the three answers that were actually chosen.
|
||||
|
||||
test('a vendor fee block is admin-only, and it is the whole block', async () => {
|
||||
const config = await visibility.getConfig()
|
||||
// The frame as BridgeMarket emits it: the shop's public parts, plus the money.
|
||||
const frame = {
|
||||
serial: '0x40001234',
|
||||
shopName: "Darrow's Bargains",
|
||||
ownerName: 'Darrow',
|
||||
location: { map: 'Trammel', x: 1421, y: 1699, region: 'Britain' },
|
||||
fees: {
|
||||
exempt: false,
|
||||
chargePerPeriod: 148,
|
||||
funds: 2960,
|
||||
periodsRemaining: 20,
|
||||
dismissalAt: '2026-09-20T00:00:00.0000000Z',
|
||||
},
|
||||
}
|
||||
|
||||
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||
const out = visibility.projectFeature('market', frame, level, config)
|
||||
assert.equal('fees' in out, false, `fees reached ${level}`)
|
||||
// The rest of the shop is untouched — this is a field rule, not a feature one.
|
||||
assert.equal(out.shopName, "Darrow's Bargains", `${level} lost the shop name`)
|
||||
assert.equal(out.location.region, 'Britain', `${level} lost the location`)
|
||||
}
|
||||
|
||||
const asAdmin = visibility.projectFeature('market', frame, 'admin', config)
|
||||
assert.equal(asAdmin.fees.funds, 2960)
|
||||
assert.equal(asAdmin.fees.dismissalAt, '2026-09-20T00:00:00.0000000Z')
|
||||
})
|
||||
|
||||
// The nesting is the point, not a style choice: projectValue matches literal JSON
|
||||
// keys, so seven flat fee keys would be seven rules an admin has to keep in step
|
||||
// and a v6 field would default to visible. One nested key cannot drift.
|
||||
test('the fee rule is one nested key, so a new fee field inherits the gate', async () => {
|
||||
const config = await visibility.getConfig()
|
||||
const frame = { serial: '0x1', fees: { exempt: false, somethingAddedLater: 'secret' } }
|
||||
const out = visibility.projectFeature('market', frame, 'staff', config)
|
||||
assert.equal('fees' in out, false, 'a field added inside fees must not fall out of the gate')
|
||||
})
|
||||
|
||||
// The opposite call, and it is deliberate: the decay countdown is the public IDOC
|
||||
// page's entire content, and a house at IDOC is already announced in game.
|
||||
test('the decay schedule is anonymous by default but remains configurable', async () => {
|
||||
const frame = {
|
||||
serial: '0x1',
|
||||
to: 'IDOC',
|
||||
name: 'Marble Tower',
|
||||
schedule: {
|
||||
dynamicDecay: true,
|
||||
nextStage: '2026-09-02T04:00:00.0000000Z',
|
||||
decayPeriodSec: 432000,
|
||||
estimatedCollapse: '2026-09-02T04:00:00.0000000Z',
|
||||
},
|
||||
}
|
||||
|
||||
const config = await visibility.getConfig()
|
||||
const anon = visibility.projectFeature('houses', frame, 'anonymous', config)
|
||||
assert.equal(anon.schedule.estimatedCollapse, '2026-09-02T04:00:00.0000000Z')
|
||||
|
||||
// A shard that considers a precise collapse time an unfair advantage can raise it,
|
||||
// and raising the one nested rule takes the whole schedule with it.
|
||||
withRows([
|
||||
{
|
||||
feature: 'houses',
|
||||
enabled: true,
|
||||
audience: 'anonymous',
|
||||
stream: true,
|
||||
fieldRules: { schedule: 'staff' },
|
||||
},
|
||||
])
|
||||
const tightened = await visibility.getConfig()
|
||||
assert.equal('schedule' in visibility.projectFeature('houses', frame, 'player', tightened), false)
|
||||
assert.equal(
|
||||
visibility.projectFeature('houses', frame, 'staff', tightened).schedule.decayPeriodSec,
|
||||
432000,
|
||||
)
|
||||
// Tightening the schedule must not have disturbed the owner rules beside it.
|
||||
assert.equal(visibility.projectFeature('houses', frame, 'anonymous', tightened).name, 'Marble Tower')
|
||||
})
|
||||
|
||||
// Rule 2, exercised on the kind it was added for. account.login.result says whether
|
||||
// a password was accepted and from which IP; it is admin-only by OMISSION, and the
|
||||
// omission is the decision. If someone maps it to a feature to "make it visible",
|
||||
// this fails and says why.
|
||||
test('account.login.result is admin-only, like the attempt it completes', async () => {
|
||||
const config = await visibility.getConfig()
|
||||
assert.equal(
|
||||
visibility.KIND_FEATURE.has('account.login.result'),
|
||||
false,
|
||||
'mapping this kind to a feature would let an admin widen an IP + auth verdict below admin',
|
||||
)
|
||||
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||
assert.equal(visibility.kindVisibleTo('account.login.result', level, config), false)
|
||||
}
|
||||
assert.equal(visibility.kindVisibleTo('account.login.result', 'admin', config), true)
|
||||
assert.equal(visibility.PUBLIC_KINDS.has('account.login.result'), false)
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
parseDecoration,
|
||||
decodeEntities,
|
||||
} = require('../utils/spawnAtlasParse')
|
||||
|
||||
@@ -144,9 +145,14 @@ test('parsePoints: reads the kept fields and drops the rest', () => {
|
||||
assert.equal(covetous.minDelay, 300)
|
||||
assert.equal(covetous.maxDelay, 600)
|
||||
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
||||
// Dropped fields must not survive into the artifact — this is what keeps it
|
||||
// under 1 MB.
|
||||
assert.equal(covetous.uniqueId, undefined)
|
||||
// **The UniqueId is KEPT from Phase 12b**, having been dropped since the atlas
|
||||
// shipped. It is `XmlSpawner.UniqueId` — carried in the spawn files and on the
|
||||
// live spawner — so it is the only name for one particular spawner that exists
|
||||
// off the shard, and a property lease targets by it. A serial cannot do that
|
||||
// job: serials are assigned when the world is built and nothing here knows one.
|
||||
assert.equal(covetous.uniqueId, '001a34e5-0efa-46de-9c93-b6a163d96370')
|
||||
// The rest of the dropped fields still are. Triggering, refractory windows,
|
||||
// proximity and sounds are what the site has no use for.
|
||||
assert.equal(covetous.proximityTriggerSound, undefined)
|
||||
})
|
||||
|
||||
@@ -599,3 +605,49 @@ test('parsePoints: DelayInSec decides the unit, and both come out in seconds', (
|
||||
assert.equal(seconds.minDelay, 5)
|
||||
assert.equal(seconds.maxDelay, 10)
|
||||
})
|
||||
|
||||
// ── parseDecoration (Phase 12a) ───────────────────────────────
|
||||
|
||||
test('parseDecoration: reads the type off each header and ignores the placements', () => {
|
||||
const rows = parseDecoration(`# switch
|
||||
Static 0x108F
|
||||
5552 1864 11
|
||||
5399 1875 17
|
||||
|
||||
# crate
|
||||
LargeCrate 0x0E3C
|
||||
5408 607 45
|
||||
`)
|
||||
assert.deepEqual(rows, [
|
||||
{ type: 'Static', itemId: 0x108f },
|
||||
{ type: 'LargeCrate', itemId: 0x0e3c },
|
||||
])
|
||||
})
|
||||
|
||||
test('parseDecoration: a parenthesised property list is not part of the type', () => {
|
||||
// These are the shard's own decoration details — which way a door faces, what
|
||||
// hue a banner is — and an event author is choosing neither. Only the class
|
||||
// name is, because that is what the plugin constructs from.
|
||||
assert.deepEqual(parseDecoration('AnkhNorth 0x0004 (Hue=0x47E)'), [
|
||||
{ type: 'AnkhNorth', itemId: 4 },
|
||||
])
|
||||
assert.deepEqual(parseDecoration('ArmsAndWeaponsPrimer 0x0FEF (Name=a life of travel)'), [
|
||||
{ type: 'ArmsAndWeaponsPrimer', itemId: 0x0fef },
|
||||
])
|
||||
})
|
||||
|
||||
test('parseDecoration: a negative z on a placement line is not mistaken for a type', () => {
|
||||
// The real trap in this format: a coordinate line starts with a digit OR a
|
||||
// minus, so "not a comment" is not the test. A z of -12 is ordinary in every
|
||||
// dungeon file in the tree.
|
||||
assert.deepEqual(parseDecoration(`Static 0x07A4
|
||||
5558 1826 -12
|
||||
-5 -5 -5
|
||||
`), [{ type: 'Static', itemId: 0x07a4 }])
|
||||
})
|
||||
|
||||
test('parseDecoration: empty, comment-only and absent input all yield nothing', () => {
|
||||
assert.deepEqual(parseDecoration(''), [])
|
||||
assert.deepEqual(parseDecoration(null), [])
|
||||
assert.deepEqual(parseDecoration('# nothing but a comment\n\n'), [])
|
||||
})
|
||||
|
||||
@@ -39,11 +39,27 @@ function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {})
|
||||
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
|
||||
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
|
||||
|
||||
// Decoration, NESTED, because the real tree nests two deep in places
|
||||
// (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`) and a flat read would index a
|
||||
// fraction of it while looking like it worked.
|
||||
fs.mkdirSync(path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper'), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Data', 'Decoration', 'top.cfg'),
|
||||
'# a brazier\nBrazier 0x0E31\n100 100 0\n200 200 -5\n\nStatic 0x108F\n300 300 0\n',
|
||||
'utf8',
|
||||
)
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Data', 'Decoration', 'Deep', 'Deeper', 'nested.cfg'),
|
||||
'Brazier 0x0E31\n400 400 0\nLargeCrate 0x0E3C\n500 500 0\n',
|
||||
'utf8',
|
||||
)
|
||||
|
||||
for (const facet of facets) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Spawns', `${facet}.xml`),
|
||||
`<Spawns>
|
||||
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
|
||||
<Points><Name>${facet}A</Name><UniqueId>uid-${facet}-A</UniqueId>
|
||||
<Map>${facet}</Map><X>1100</X><Y>1100</Y>
|
||||
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
|
||||
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
|
||||
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
|
||||
@@ -97,6 +113,29 @@ function tempTree(options) {
|
||||
|
||||
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
|
||||
|
||||
test('buildAtlas: a point keeps the UniqueId a property lease targets', () => {
|
||||
// The field is asserted on the AGGREGATOR's output, not the parser's, which is
|
||||
// the whole point of this test. `parsePoints` produced it from Phase 12b
|
||||
// onwards and `PARSER_VERSION`'s own note said a point kept it, while the
|
||||
// mapping in `buildAtlas` rebuilt each point from an explicit field list that
|
||||
// omitted it — so `shard_spawn_points.unique_id` was NULL on every row, and
|
||||
// `listSpawners`, whose WHERE is `unique_id IS NOT NULL`, answered empty. That
|
||||
// left `uo.options.spawners` an empty dropdown and every Phase 12b
|
||||
// object-property lease unauthorable. Found by the Phase 16b released-artefact
|
||||
// walk, against a real tree whose files carry ~6,400 of these.
|
||||
//
|
||||
// The fixture above had no <UniqueId> at all until this test, which is exactly
|
||||
// why a green suite said nothing about it.
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const atlas = buildAtlas(root)
|
||||
const named = atlas.points.find((p) => p.name === 'SosariaA')
|
||||
assert.equal(named.uniqueId, 'uid-Sosaria-A')
|
||||
// And a point whose file names none is absent rather than empty-string, so the
|
||||
// DB layer's `unique_id IS NOT NULL AND <> ''` reads it the same way either way.
|
||||
const unnamed = atlas.points.find((p) => p.name === 'SosariaB')
|
||||
assert.ok(!unnamed.uniqueId)
|
||||
})
|
||||
|
||||
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
|
||||
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||||
const atlas = buildAtlas(root)
|
||||
@@ -397,3 +436,81 @@ test('refresh: an explicit path overrides the configured one', async () => {
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.deepEqual(result.addedFacets, ['Override'])
|
||||
})
|
||||
|
||||
// ── The decoration index (Phase 12a) ────────────────────────
|
||||
|
||||
test('decoration is read recursively and rolled up per type', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decor-'))
|
||||
try {
|
||||
writeTree(root)
|
||||
const atlas = buildAtlas(root)
|
||||
|
||||
// Sorted by type, and `uses` counts every header line across the whole tree
|
||||
// — the nested file's Brazier is the second use of the same type, not a
|
||||
// second type.
|
||||
assert.deepEqual(atlas.decor, [
|
||||
{ type: 'Brazier', itemId: 0x0e31, uses: 2 },
|
||||
{ type: 'LargeCrate', itemId: 0x0e3c, uses: 1 },
|
||||
{ type: 'Static', itemId: 0x108f, uses: 1 },
|
||||
])
|
||||
assert.equal(atlas.meta.counts.decor, 3)
|
||||
|
||||
// Every decoration file is fingerprinted like every other source, so an
|
||||
// operator editing one is a tree change the boot path notices.
|
||||
const labels = Object.keys(atlas.meta.source).filter((l) => l.startsWith('Data/Decoration/'))
|
||||
assert.deepEqual(labels.sort(), ['Data/Decoration/Deep/Deeper/nested.cfg', 'Data/Decoration/top.cfg'])
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('two spellings of one decoration type fold into one row', () => {
|
||||
// The Phase 16 acceptance walk's blocking finding. Stock ServUO 57.4's own
|
||||
// `Data/Decoration/` names four types under two casings each —
|
||||
// CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest,
|
||||
// SpinningWheelEastAddon/SpinningwheelEastAddon — and in every pair exactly one
|
||||
// is a real class; the other is a mis-cased line the shard's own loader resolves
|
||||
// anyway.
|
||||
//
|
||||
// A case-SENSITIVE Map keeps both. `shard_decor_types.type` is a PRIMARY KEY
|
||||
// under MariaDB's default `..._ai_ci` collation, which folds case, so the second
|
||||
// row raised `1062 Duplicate entry` and took the WHOLE atlas import transaction
|
||||
// down with it. The blast radius is not decoration: with no atlas, EVERY option
|
||||
// source answers empty and no world verb can be authored at all.
|
||||
//
|
||||
// Asserted on the count as well as the row, because the failure mode was two
|
||||
// rows that a database — not this function — would later refuse.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-decorcase-'))
|
||||
try {
|
||||
writeTree(root)
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Data', 'Decoration', 'miscased.cfg'),
|
||||
'checkerboard 0x0FA6\n600 600 0\nCheckerBoard 0x0FA6\n700 700 0\n',
|
||||
)
|
||||
const atlas = buildAtlas(root)
|
||||
|
||||
const boards = atlas.decor.filter((d) => d.type.toLowerCase() === 'checkerboard')
|
||||
assert.equal(boards.length, 1, 'two casings of one type must not be two rows')
|
||||
// First spelling seen wins, exactly as the first item id does. Which one
|
||||
// survives is cosmetic — the shard resolves either.
|
||||
assert.equal(boards[0].type, 'checkerboard')
|
||||
assert.equal(boards[0].uses, 2, 'both lines still count as uses of the one type')
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('a tree with no decoration at all still builds', () => {
|
||||
// Optional, like the champion file. A shard that has stripped its decoration
|
||||
// has a perfectly good atlas; the decoration verb simply has nothing to offer.
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-nodecor-'))
|
||||
try {
|
||||
writeTree(root)
|
||||
fs.rmSync(path.join(root, 'Data', 'Decoration'), { recursive: true, force: true })
|
||||
const atlas = buildAtlas(root)
|
||||
assert.deepEqual(atlas.decor, [])
|
||||
assert.equal(atlas.meta.counts.decor, 0)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
92
server/test/swaggerFragment.test.js
Normal file
92
server/test/swaggerFragment.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// The fragment generator's derivation, checked.
|
||||
//
|
||||
// `scripts/swaggerFragment.js` decides the prefix of every path core will publish
|
||||
// on this module's behalf, and it decides it from `server/index.js`'s own
|
||||
// registration call rather than from a table. That derivation is what these
|
||||
// assert. The generator's OUTPUT — whether those prefixes are the URLs a real
|
||||
// core serves — is `frozenManifest.js`'s question, because answering it needs a
|
||||
// core; here the question is whether the machinery reads the module correctly.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { mountedRouters, prefixPaths, TIER_BASE, SLOT_MOUNT } = require('../scripts/swaggerFragment')
|
||||
|
||||
test('every registered mount is discovered, and resolved to a source file', () => {
|
||||
const mounts = mountedRouters()
|
||||
|
||||
// Six: five `registerRoutes` prefixes plus the filled extension slot.
|
||||
assert.strictEqual(mounts.length, 6)
|
||||
for (const { file, prefix } of mounts) {
|
||||
assert.match(file, /server[\\/]router[\\/]/, 'a mounted router resolved to a file outside router/')
|
||||
assert.match(prefix, /^\/api\/v1\/(public|admin|player)\//)
|
||||
}
|
||||
})
|
||||
|
||||
test('the prefixes come from the registration, not from a list here', () => {
|
||||
// Change `registerRoutes` in server/index.js and this list changes with it —
|
||||
// which is the property being asserted. The manifest declares the same five
|
||||
// prefixes and the loader rejects a mismatch between the two, so this is the
|
||||
// third and last place they could disagree.
|
||||
const byPrefix = mountedRouters().map((m) => m.prefix).sort()
|
||||
assert.deepStrictEqual(byPrefix, [
|
||||
'/api/v1/admin/shard',
|
||||
'/api/v1/admin/uo-link',
|
||||
'/api/v1/admin/users/:id',
|
||||
'/api/v1/player/shard',
|
||||
'/api/v1/public/atlas',
|
||||
'/api/v1/public/shard',
|
||||
])
|
||||
})
|
||||
|
||||
test('the tier bases and the slot mount are §2.4\'s, spelled as core mounts them', () => {
|
||||
assert.deepStrictEqual(TIER_BASE, {
|
||||
public: '/api/v1/public',
|
||||
admin: '/api/v1/admin',
|
||||
player: '/api/v1/player',
|
||||
})
|
||||
assert.deepStrictEqual(SLOT_MOUNT, { 'admin.users.detail': '/api/v1/admin/users/:id' })
|
||||
})
|
||||
|
||||
test('re-rooting converts express params to OpenAPI\'s', () => {
|
||||
const paths = prefixPaths({ paths: { '/shard/link': { get: {} } } }, '/api/v1/admin/users/:id')
|
||||
assert.deepStrictEqual(Object.keys(paths), ['/api/v1/admin/users/{id}/shard/link'])
|
||||
})
|
||||
|
||||
test('re-rooting drops the trailing slash a collection route would produce', () => {
|
||||
// `router.get('/')` under a prefix concatenates to `/api/v1/public/shard/`, a
|
||||
// URL no client calls and the manifest does not record.
|
||||
const paths = prefixPaths({ paths: { '/': { get: {} } } }, '/api/v1/public/shard')
|
||||
assert.deepStrictEqual(Object.keys(paths), ['/api/v1/public/shard'])
|
||||
})
|
||||
|
||||
test('the prefix\'s own parameters are ordered ahead of the route\'s', () => {
|
||||
// swagger-autogen orders parameters by where they appeared in the path it saw,
|
||||
// and it only ever saw the tail — so without this every slot route would churn
|
||||
// the committed fragment by a reorder that means nothing.
|
||||
const paths = prefixPaths(
|
||||
{
|
||||
paths: {
|
||||
'/shard/link/{account}': {
|
||||
delete: { parameters: [{ name: 'account', in: 'path' }, { name: 'id', in: 'path' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/v1/admin/users/:id',
|
||||
)
|
||||
const params = paths['/api/v1/admin/users/{id}/shard/link/{account}'].delete.parameters
|
||||
assert.deepStrictEqual(params.map((p) => p.name), ['id', 'account'])
|
||||
})
|
||||
|
||||
test('a parameter the prefix does not name keeps its position', () => {
|
||||
const paths = prefixPaths(
|
||||
{
|
||||
paths: {
|
||||
'/thing': { get: { parameters: [{ name: 'limit' }, { name: 'offset' }, { name: 'id' }] } },
|
||||
},
|
||||
},
|
||||
'/api/v1/admin/users/:id',
|
||||
)
|
||||
const params = paths['/api/v1/admin/users/{id}/thing'].get.parameters
|
||||
assert.deepStrictEqual(params.map((p) => p.name), ['id', 'limit', 'offset'])
|
||||
})
|
||||
406
server/test/teamProvider.test.js
Normal file
406
server/test/teamProvider.test.js
Normal file
@@ -0,0 +1,406 @@
|
||||
// module-uo's Team provider (docs/website/TEAMS.md §2.3, MODULE_API.md 1.6.0).
|
||||
//
|
||||
// The tests that matter here are the REFUSALS. Core's contract is that module
|
||||
// unavailability becomes staleness and never emptiness, and this module is the
|
||||
// only thing that can honour it — an empty array from here is read as an
|
||||
// authoritative "there are none", and core makes destructive decisions from an
|
||||
// authoritative answer. Every state where this module cannot honestly claim to
|
||||
// know is asserted below, because each one is a plausible place for someone to
|
||||
// later "simplify" the guard away and get a plausible-looking empty list.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const core = require('../core')
|
||||
|
||||
// The provider reaches the database through core, which is initialised with a ctx
|
||||
// in production. A minimal one is enough here — the db layer is stubbed anyway.
|
||||
core.init({
|
||||
db: { query: async () => [] },
|
||||
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
moduleId: 'uo',
|
||||
})
|
||||
|
||||
const db = require('../model/teamProvider/teamProvider.db')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const uoLinkSocket = require('../utils/uoLinkSocket')
|
||||
const clilocs = require('../model/shardClilocs/shardClilocs.model')
|
||||
const visibility = require('../utils/shardVisibility')
|
||||
const provider = require('../model/teamProvider/teamProvider.model')
|
||||
|
||||
const saved = []
|
||||
function patch(mod, name, fn) {
|
||||
saved.push([mod, name, mod[name]])
|
||||
mod[name] = fn
|
||||
}
|
||||
|
||||
// The healthy default: configured, enabled, connected. Each test then breaks only
|
||||
// the thing it is about.
|
||||
function healthy() {
|
||||
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://127.0.0.1:7787', enabled: true }))
|
||||
patch(uoLinkSocket, 'getState', () => ({ connected: true, running: true }))
|
||||
// An operator who has never run the client extraction — the default. The standard
|
||||
// rank names must still resolve from the fallback table.
|
||||
patch(clilocs, 'resolveMany', async () => new Map())
|
||||
patch(db, 'listGuildLeaders', async () => [])
|
||||
}
|
||||
|
||||
const guild = (extra = {}) => ({
|
||||
id: 1, name: 'The Silver Hand', abbr: 'TSH', alliance: null,
|
||||
members: 2, online: 1, leader_serial: '0x1', leader_name: 'Aldric', leader_acct: 'aldric', ...extra,
|
||||
})
|
||||
|
||||
const member = (extra = {}) => ({
|
||||
serial: '0x1', name: 'Aldric', acct: 'aldric', web_id: null, is_player: 1,
|
||||
rank: 1, rank_cliloc: 1062962, rank_name: null,
|
||||
linked_user_id: null, is_online: 0, ...extra,
|
||||
})
|
||||
|
||||
beforeEach(healthy)
|
||||
afterEach(() => {
|
||||
while (saved.length) {
|
||||
const [mod, name, fn] = saved.pop()
|
||||
mod[name] = fn
|
||||
}
|
||||
})
|
||||
|
||||
// ── The refusals ───────────────────────────────────────────────────────────
|
||||
|
||||
test('no uo-link configured refuses, on all three methods', async () => {
|
||||
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: null, enabled: false }))
|
||||
patch(db, 'listGuilds', async () => { throw new Error('must not be read') })
|
||||
|
||||
for (const answer of [await provider.getTeams(), await provider.getTeamMembers('1'), await provider.getTeamLeaders('1')]) {
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /no uo-link configured/)
|
||||
assert.equal(answer.teams, undefined)
|
||||
assert.equal(answer.members, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('a disabled integration refuses rather than reporting a frozen board', async () => {
|
||||
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://x', enabled: false }))
|
||||
const answer = await provider.getTeams()
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /disabled/)
|
||||
})
|
||||
|
||||
test('a disconnected socket refuses, even though the board is still there', async () => {
|
||||
// The tempting mistake, stated as a test: the board is durable and survives an
|
||||
// outage, so serving it looks harmless. Core cannot tell a board five minutes
|
||||
// stale from one five days stale, and it archives Teams and departs members
|
||||
// from a complete answer.
|
||||
patch(uoLinkSocket, 'getState', () => ({ connected: false, running: true }))
|
||||
patch(db, 'listGuilds', async () => [guild()])
|
||||
|
||||
const answer = await provider.getTeams()
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /not connected/)
|
||||
assert.equal(answer.teams, undefined, 'a stale board must not arrive as authoritative')
|
||||
})
|
||||
|
||||
test('a database error refuses instead of throwing at core', async () => {
|
||||
patch(db, 'listGuilds', async () => { throw new Error('table gone') })
|
||||
const answer = await provider.getTeams()
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /table gone/)
|
||||
})
|
||||
|
||||
test('a guild absent from the board refuses rather than reporting an empty roster', async () => {
|
||||
patch(db, 'findGuild', async () => [])
|
||||
const members = await provider.getTeamMembers('99')
|
||||
assert.equal(members.ok, false)
|
||||
assert.match(members.reason, /not on the board/)
|
||||
|
||||
const leaders = await provider.getTeamLeaders('99')
|
||||
assert.equal(leaders.ok, false)
|
||||
})
|
||||
|
||||
test('a roster that has not arrived yet refuses — the board count is what tells us', async () => {
|
||||
// Protocol 4's roster arrives on its own frames, separately from the
|
||||
// guild.update that creates the board row, so there is a real window where a
|
||||
// 155-member guild has no roster rows. Reporting that as an empty roster would
|
||||
// depart every member.
|
||||
patch(db, 'findGuild', async () => [guild({ members: 155 })])
|
||||
patch(db, 'listGuildMembers', async () => [])
|
||||
|
||||
const answer = await provider.getTeamMembers('1')
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /has not arrived yet/)
|
||||
assert.match(answer.reason, /155/, 'the count is in the message, because it is the evidence')
|
||||
})
|
||||
|
||||
test('a guild the board says is genuinely empty reports an empty roster', async () => {
|
||||
// The other side of the same coin: when the board itself says zero, an empty
|
||||
// roster is the truth and withholding it would freeze a disbanding guild's
|
||||
// membership forever.
|
||||
patch(db, 'findGuild', async () => [guild({ members: 0 })])
|
||||
patch(db, 'listGuildMembers', async () => [])
|
||||
|
||||
const answer = await provider.getTeamMembers('1')
|
||||
assert.equal(answer.ok, true)
|
||||
assert.deepEqual(answer.members, [])
|
||||
})
|
||||
|
||||
// ── The good answers ───────────────────────────────────────────────────────
|
||||
|
||||
test('a guild becomes a Team keyed on its persistent ServUO id', async () => {
|
||||
// The id survives a rename, which is what lets core apply its rename rule
|
||||
// instead of seeing an unrelated new guild.
|
||||
patch(db, 'listGuilds', async () => [guild()])
|
||||
const answer = await provider.getTeams()
|
||||
|
||||
assert.equal(answer.ok, true)
|
||||
assert.equal(answer.complete, true)
|
||||
assert.deepEqual(answer.teams, [
|
||||
{ externalId: '1', name: 'The Silver Hand', abbr: 'TSH', meta: null },
|
||||
])
|
||||
})
|
||||
|
||||
test('an alliance rides along as opaque meta', async () => {
|
||||
patch(db, 'listGuilds', async () => [guild({ alliance: 'The Concord' })])
|
||||
const { teams } = await provider.getTeams()
|
||||
assert.deepEqual(teams[0].meta, { alliance: 'The Concord' })
|
||||
})
|
||||
|
||||
test('the external id is a string, so core never compares a number to one', async () => {
|
||||
patch(db, 'listGuilds', async () => [guild({ id: 42 })])
|
||||
const { teams } = await provider.getTeams()
|
||||
assert.equal(teams[0].externalId, '42')
|
||||
})
|
||||
|
||||
test('a roster maps to the member shape core expects', async () => {
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [
|
||||
member({ serial: '0x1', name: 'Aldric', rank: 4, rank_cliloc: 1062959, is_online: 1 }),
|
||||
member({ serial: '0x2', name: 'Bree', acct: null, rank: 1, is_online: 0 }),
|
||||
])
|
||||
|
||||
const { members } = await provider.getTeamMembers('1')
|
||||
assert.equal(members.length, 2)
|
||||
assert.equal(members[0].memberKey, '0x1')
|
||||
assert.equal(members[0].displayName, 'Aldric')
|
||||
assert.equal(members[0].online, true)
|
||||
assert.equal(members[0].leader, true, 'rank 4 is Leader')
|
||||
assert.equal(members[1].leader, false)
|
||||
assert.equal(members[1].online, false)
|
||||
})
|
||||
|
||||
// ── Rank (the Protocol 4 amendment) ────────────────────────────────────────
|
||||
|
||||
test('several members can be leaders at once', async () => {
|
||||
// The whole reason the wire grew a per-member rank: the board carries one
|
||||
// leader_serial, so before this only a single leader could ever be reported.
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [
|
||||
member({ serial: '0x1', rank: 4 }),
|
||||
member({ serial: '0x2', rank: 4 }),
|
||||
member({ serial: '0x3', rank: 3 }),
|
||||
])
|
||||
|
||||
const { members } = await provider.getTeamMembers('1')
|
||||
assert.deepEqual(members.filter((m) => m.leader).map((m) => m.memberKey), ['0x1', '0x2'])
|
||||
})
|
||||
|
||||
test('getTeamLeaders returns everyone at rank 4, not just the board’s one', async () => {
|
||||
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
|
||||
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
|
||||
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1', '0x2'])
|
||||
})
|
||||
|
||||
test('the board’s leader is kept even when no roster row has rank yet', async () => {
|
||||
// A shard whose roster has not been re-emitted since the amendment has no ranks
|
||||
// stored. The founder-leader comes from a different frame and must not be lost
|
||||
// by moving to ranks.
|
||||
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x9' })])
|
||||
patch(db, 'listGuildLeaders', async () => [])
|
||||
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x9'])
|
||||
})
|
||||
|
||||
test('the board’s leader is not duplicated when they also hold rank 4', async () => {
|
||||
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
|
||||
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
|
||||
const { leaders } = await provider.getTeamLeaders('1')
|
||||
assert.equal(new Set(leaders).size, leaders.length)
|
||||
})
|
||||
|
||||
test('a NULL rank is not a leader — "not known" is not "leads this guild"', async () => {
|
||||
// The shard withholds the rank for a staff account, because ServUO's GuildRank
|
||||
// getter reports Leader for anyone at GameMaster or above whatever their real
|
||||
// rank. Reading the absence as leadership would republish exactly that lie.
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: null, rank_cliloc: null })])
|
||||
|
||||
const { members } = await provider.getTeamMembers('1')
|
||||
assert.equal(members[0].leader, false)
|
||||
assert.equal(members[0].rankLabel, null)
|
||||
})
|
||||
|
||||
test('a standard rank resolves to its name without a cliloc table', async () => {
|
||||
// The operator may never have run the client extraction, and a roster should
|
||||
// still read "Warlord" rather than nothing.
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [
|
||||
member({ serial: '0x1', rank: 4, rank_cliloc: 1062959 }),
|
||||
member({ serial: '0x2', rank: 3, rank_cliloc: 1062960 }),
|
||||
member({ serial: '0x3', rank: 0, rank_cliloc: 1062963 }),
|
||||
])
|
||||
|
||||
const { members } = await provider.getTeamMembers('1')
|
||||
assert.deepEqual(members.map((m) => m.rankLabel), ['Leader', 'Warlord', 'Ronin'])
|
||||
})
|
||||
|
||||
test('the operator’s cliloc table wins over the built-in names', async () => {
|
||||
// A localised or edited client should name the ranks, not this module's English
|
||||
// fallback.
|
||||
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Kriegsherr']]))
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
|
||||
|
||||
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Kriegsherr')
|
||||
})
|
||||
|
||||
test('a custom rank’s literal name beats both', async () => {
|
||||
// A shard that replaced RankDefinition.Ranks sends a string instead of a cliloc,
|
||||
// and its own naming has to survive.
|
||||
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Warlord']]))
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [
|
||||
member({ serial: '0x1', rank: 3, rank_cliloc: 1062960, rank_name: 'Sword-Captain' }),
|
||||
])
|
||||
|
||||
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Sword-Captain')
|
||||
})
|
||||
|
||||
test('a failing cliloc lookup falls back rather than failing the roster', async () => {
|
||||
patch(clilocs, 'resolveMany', async () => { throw new Error('cliloc table missing') })
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
|
||||
|
||||
const answer = await provider.getTeamMembers('1')
|
||||
assert.equal(answer.ok, true, 'a label is decoration; losing it must not lose the roster')
|
||||
assert.equal(answer.members[0].rankLabel, 'Warlord')
|
||||
})
|
||||
|
||||
test('an unknown cliloc leaves the label null rather than inventing one', async () => {
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 2, rank_cliloc: 9999999 })])
|
||||
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, null)
|
||||
})
|
||||
|
||||
test('a member with no account at all is fine and unlinked', async () => {
|
||||
// §2.3 of the protocol spec: acct is genuinely optional — a PlayerMobile can
|
||||
// have no Account, and the local test world contains such mobiles.
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [member({ acct: null, web_id: null, linked_user_id: null })])
|
||||
const { members } = await provider.getTeamMembers('1')
|
||||
assert.equal(members[0].userId, null)
|
||||
})
|
||||
|
||||
test('userId comes from the roster’s web_id first, then the link table', async () => {
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [
|
||||
member({ serial: '0xA', web_id: '7', linked_user_id: 99 }), // roster wins
|
||||
member({ serial: '0xB', web_id: null, linked_user_id: 12 }), // fallback
|
||||
member({ serial: '0xC', web_id: '0', linked_user_id: null }), // neither
|
||||
])
|
||||
const { members } = await provider.getTeamMembers('1')
|
||||
assert.equal(members[0].userId, 7, 'what the shard itself asserted at roster time')
|
||||
assert.equal(members[1].userId, 12, 'the fallback for a row that predates the link')
|
||||
assert.equal(members[2].userId, null)
|
||||
})
|
||||
|
||||
test('web_id arrives as a string from the wire and is coerced', async () => {
|
||||
patch(db, 'findGuild', async () => [guild()])
|
||||
patch(db, 'listGuildMembers', async () => [member({ web_id: '42' })])
|
||||
const { members } = await provider.getTeamMembers('1')
|
||||
assert.equal(members[0].userId, 42)
|
||||
assert.equal(typeof members[0].userId, 'number')
|
||||
})
|
||||
|
||||
test('a guild with no leader anywhere reports none rather than guessing', async () => {
|
||||
patch(db, 'findGuild', async () => [guild({ leader_serial: null })])
|
||||
patch(db, 'listGuildLeaders', async () => [])
|
||||
const answer = await provider.getTeamLeaders('1')
|
||||
assert.equal(answer.ok, true)
|
||||
assert.deepEqual(answer.leaders, [])
|
||||
})
|
||||
|
||||
test('an empty board is an authoritative empty list — the shard really has no guilds', async () => {
|
||||
// Distinct from every refusal above: the socket is connected and the board is
|
||||
// readable, so "no guilds" is a fact. Core still quarantines it before acting.
|
||||
patch(db, 'listGuilds', async () => [])
|
||||
const answer = await provider.getTeams()
|
||||
assert.equal(answer.ok, true)
|
||||
assert.deepEqual(answer.teams, [])
|
||||
})
|
||||
|
||||
// ── projectRoster (TEAMS.md §3.3) ──────────────────────────────────────────
|
||||
//
|
||||
// The refusal semantics INVERT here and that is the point of these tests. For
|
||||
// the three methods above, a refusal means "change nothing" and an empty array
|
||||
// would be destructive. For this one, core fails CLOSED — a refusal withholds the
|
||||
// roster — so the dangerous answer is the opposite: returning every key because
|
||||
// the config could not be read would publish a roster an operator gated to staff.
|
||||
|
||||
const rows = [{ member_key: '0x1' }, { member_key: '0x2' }]
|
||||
|
||||
function guilds(feature) {
|
||||
patch(visibility, 'getConfig', async () => ({ guilds: feature }))
|
||||
}
|
||||
|
||||
test('a viewer at or above the audience sees every row', async () => {
|
||||
guilds({ enabled: true, audience: 'anonymous' })
|
||||
const answer = await provider.projectRoster('1', rows, null)
|
||||
assert.equal(answer.ok, true)
|
||||
assert.deepEqual(answer.members, ['0x1', '0x2'])
|
||||
})
|
||||
|
||||
test('a viewer below the audience sees none — authoritatively, not as a refusal', async () => {
|
||||
// `ok: true` with an empty list is the correct answer here: this module KNOWS
|
||||
// the viewer may see nothing. Core renders an empty roster rather than an
|
||||
// error, which is what a gated shard is supposed to look like.
|
||||
guilds({ enabled: true, audience: 'staff' })
|
||||
const answer = await provider.projectRoster('1', rows, { userId: 7, role: 'player' })
|
||||
assert.equal(answer.ok, true)
|
||||
assert.deepEqual(answer.members, [])
|
||||
})
|
||||
|
||||
test('an admin clears every audience', async () => {
|
||||
guilds({ enabled: true, audience: 'admin' })
|
||||
const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
|
||||
assert.deepEqual(answer.members, ['0x1', '0x2'])
|
||||
})
|
||||
|
||||
test('a disabled guilds feature hides the roster from everyone, staff included', async () => {
|
||||
// The switch means "this shard does not publish guild data", not "publish it
|
||||
// quietly to staff".
|
||||
guilds({ enabled: false, audience: 'anonymous' })
|
||||
const answer = await provider.projectRoster('1', rows, { userId: 1, role: 'admin' })
|
||||
assert.equal(answer.ok, true)
|
||||
assert.deepEqual(answer.members, [])
|
||||
})
|
||||
|
||||
test('an unreadable visibility config REFUSES rather than publishing', async () => {
|
||||
// The inversion, stated. Core reads this as "withhold", which is the only safe
|
||||
// reading of "I could not work out who is allowed to look".
|
||||
patch(visibility, 'getConfig', async () => { throw new Error('pool down') })
|
||||
const answer = await provider.projectRoster('1', rows, null)
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.reason, /visibility could not be resolved/)
|
||||
})
|
||||
|
||||
test('an absent viewer is anonymous, not an error', async () => {
|
||||
guilds({ enabled: true, audience: 'logged_in' })
|
||||
const answer = await provider.projectRoster('1', rows, null)
|
||||
assert.equal(answer.ok, true)
|
||||
assert.deepEqual(answer.members, [], 'anonymous does not meet logged_in')
|
||||
})
|
||||
|
||||
test('rows with no member key are dropped rather than answered as blanks', async () => {
|
||||
guilds({ enabled: true, audience: 'anonymous' })
|
||||
const answer = await provider.projectRoster('1', [{ member_key: '0x1' }, { member_key: null }], null)
|
||||
assert.deepEqual(answer.members, ['0x1'])
|
||||
})
|
||||
413
server/test/treeBridge.test.js
Normal file
413
server/test/treeBridge.test.js
Normal file
@@ -0,0 +1,413 @@
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const zlib = require('zlib')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Installs the `ctx` core would have handed over — treeBridge takes a logger
|
||||
// from it at call time, so a test that skips this dies on the first log line.
|
||||
require('./_setup')
|
||||
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const treeBridge = require('../utils/treeBridge')
|
||||
const { buildFrom, readFrom } = require('../utils/spawnAtlasSource')
|
||||
|
||||
// The atlas source walk over the bridge (docs/link/v8.md §10 — protocol 8,
|
||||
// phase 7), driven against a stub that behaves the way `BridgeTree.cs` does.
|
||||
//
|
||||
// The test that matters most is the LAST one: the same synthetic tree, read off
|
||||
// a disk and read over the bridge, must produce a byte-identical atlas. Every
|
||||
// other test here is one specific way a walk can end in something that LOOKS
|
||||
// imported — which is the failure mode this whole family is shaped around, since
|
||||
// XML is forgiving enough that a tree reassembled wrong still parses and simply
|
||||
// has fewer spawns in it.
|
||||
|
||||
const saved = {}
|
||||
|
||||
function restore() {
|
||||
for (const [name, fn] of Object.entries(saved)) {
|
||||
if (fn) uoLinkClient[name] = fn
|
||||
}
|
||||
}
|
||||
|
||||
after(restore)
|
||||
|
||||
const ok = (data) => ({ ok: true, status: 200, data })
|
||||
const fail = (status, data) => ({ ok: false, status, data })
|
||||
const sha = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
|
||||
|
||||
// ── A stub shard ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Chunks and gzips exactly as the overlay does, so the reader under test is
|
||||
// exercised against the wire shape rather than against a convenience.
|
||||
|
||||
function serveTree(files, { chunkBytes = 64, catalog = 'cafebabe12345678', tweak = {} } = {}) {
|
||||
saved.getAssetManifest = saved.getAssetManifest ?? uoLinkClient.getAssetManifest
|
||||
saved.fetchAssets = saved.fetchAssets ?? uoLinkClient.fetchAssets
|
||||
|
||||
const chunksOf = (bytes) => Math.max(1, Math.ceil(bytes.length / chunkBytes))
|
||||
|
||||
const rows = files.map(([label, bytes]) => ({
|
||||
key: `tree/${label}`,
|
||||
label,
|
||||
bytes: bytes.length,
|
||||
mtime: 1700000000000,
|
||||
chunks: chunksOf(bytes),
|
||||
sha256: sha(bytes),
|
||||
}))
|
||||
|
||||
const byLabel = new Map(files)
|
||||
const calls = { manifest: 0, fetch: 0 }
|
||||
|
||||
uoLinkClient.getAssetManifest = async ({ family, cursor } = {}) => {
|
||||
calls.manifest++
|
||||
assert.equal(family, 'tree', 'the walk must name its family')
|
||||
assert.equal(cursor ?? null, null, 'this stub answers in one page')
|
||||
if (tweak.manifestReply) return tweak.manifestReply(rows, catalog)
|
||||
return ok({
|
||||
kind: 'assets.manifest.ok',
|
||||
family: 'tree',
|
||||
catalog,
|
||||
chunkBytes,
|
||||
total: rows.length,
|
||||
rows,
|
||||
more: false,
|
||||
cut: 'end',
|
||||
})
|
||||
}
|
||||
|
||||
uoLinkClient.fetchAssets = async ({ keys, catalog: asked } = {}) => {
|
||||
calls.fetch++
|
||||
assert.equal(asked, catalog, 'a fetch must assert the catalog it was listed under')
|
||||
|
||||
const out = []
|
||||
|
||||
for (const key of keys) {
|
||||
const slash = key.lastIndexOf('/')
|
||||
const label = key.slice('tree/'.length, slash)
|
||||
const chunk = Number(key.slice(slash + 2))
|
||||
const bytes = byLabel.get(label)
|
||||
|
||||
if (!bytes) {
|
||||
out.push({ key, status: 'absent', reason: 'no such file' })
|
||||
continue
|
||||
}
|
||||
|
||||
const raw = bytes.subarray(chunk * chunkBytes, (chunk + 1) * chunkBytes)
|
||||
|
||||
out.push({
|
||||
key,
|
||||
status: 'ok',
|
||||
label,
|
||||
chunk,
|
||||
chunks: chunksOf(bytes),
|
||||
offset: chunk * chunkBytes,
|
||||
bytes: raw.length,
|
||||
sha256: sha(raw),
|
||||
gzip: zlib.gzipSync(raw).toString('base64'),
|
||||
})
|
||||
}
|
||||
|
||||
if (tweak.fetchRows) tweak.fetchRows(out)
|
||||
|
||||
return ok({
|
||||
kind: 'assets.fetch.ok',
|
||||
family: 'tree',
|
||||
catalog,
|
||||
rows: out,
|
||||
more: false,
|
||||
cut: 'end',
|
||||
...(tweak.fetchEnvelope || {}),
|
||||
})
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
const FILES = [
|
||||
['Data/Regions.xml', Buffer.from('<ServerRegions><Region /></ServerRegions>', 'utf8')],
|
||||
['Spawns/Sosaria.xml', Buffer.from('<Spawns>' + 'x'.repeat(400) + '</Spawns>', 'utf8')],
|
||||
]
|
||||
|
||||
// ── The walk ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('a chunked, gzipped tree reassembles to the exact bytes the shard holds', async () => {
|
||||
const calls = serveTree(FILES)
|
||||
|
||||
const { files } = await treeBridge.readSources()
|
||||
|
||||
assert.equal(files.length, 2)
|
||||
assert.equal(calls.manifest, 1, 'one manifest call')
|
||||
|
||||
for (const [label, bytes] of FILES) {
|
||||
const got = files.find((f) => f.label === label)
|
||||
assert.ok(got, `${label} came back`)
|
||||
assert.equal(got.text, bytes.toString('utf8'))
|
||||
assert.equal(got.bytes, bytes.length)
|
||||
assert.equal(got.sha256, sha(bytes))
|
||||
}
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('chunks are placed by their declared index, not by the order they arrive in', async () => {
|
||||
// The rows come back in the order they were asked for today. A reader that
|
||||
// appended them would agree with this test until the day something reorders a
|
||||
// page — and then produce a file that still parses and is quietly wrong.
|
||||
serveTree(FILES, { tweak: { fetchRows: (rows) => rows.reverse() } })
|
||||
|
||||
const { files } = await treeBridge.readSources()
|
||||
const spawns = files.find((f) => f.label === 'Spawns/Sosaria.xml')
|
||||
|
||||
assert.equal(spawns.text, FILES[1][1].toString('utf8'))
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a chunk the shard refuses fails the import rather than shortening a file', async () => {
|
||||
serveTree(FILES, {
|
||||
tweak: {
|
||||
fetchRows: (rows) => {
|
||||
rows[rows.length - 1] = { key: rows[rows.length - 1].key, status: 'absent', reason: 'gone' }
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), /refused .*absent: gone/)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a missing chunk is named, with which one and out of how many', async () => {
|
||||
serveTree(FILES, { tweak: { fetchRows: (rows) => rows.splice(2, 1) } })
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), /missing chunk 1 of/)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a chunk that does not match its own hash is refused', async () => {
|
||||
serveTree(FILES, {
|
||||
tweak: {
|
||||
fetchRows: (rows) => {
|
||||
rows[1].gzip = zlib.gzipSync(Buffer.from('not what was hashed')).toString('base64')
|
||||
rows[1].bytes = 19
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), /does not match its own hash/)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a file whose reassembly does not match its manifest hash is refused', async () => {
|
||||
// Every chunk is individually honest and the whole is not — which is what a
|
||||
// dropped or duplicated chunk looks like from here.
|
||||
serveTree(FILES, {
|
||||
tweak: {
|
||||
manifestReply: (rows, catalog) =>
|
||||
ok({
|
||||
kind: 'assets.manifest.ok',
|
||||
family: 'tree',
|
||||
catalog,
|
||||
chunkBytes: 64,
|
||||
total: rows.length,
|
||||
rows: rows.map((r) => ({ ...r, sha256: r.sha256.replace(/^./, '0') })),
|
||||
more: false,
|
||||
cut: 'end',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), /does not match the hash its manifest row carried/)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a tree that moves mid-read is refused rather than stitched together', async () => {
|
||||
serveTree(FILES, { tweak: { fetchEnvelope: { catalog: 'deadbeefdeadbeef' } } })
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), {
|
||||
code: 'SOURCE_CHANGED',
|
||||
})
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a short page that did not end the walk is refused', async () => {
|
||||
serveTree(FILES, { tweak: { fetchEnvelope: { more: false, cut: 'budget' } } })
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), { code: 'INCOMPLETE' })
|
||||
restore()
|
||||
})
|
||||
|
||||
test('403 names the tree switch, not the asset switch', async () => {
|
||||
// The two consents are different settings with different fixes, and sending an
|
||||
// operator to Bridge.AssetsEnabled when the answer is Bridge.TreeEnabled costs
|
||||
// them an afternoon.
|
||||
saved.getAssetManifest = saved.getAssetManifest ?? uoLinkClient.getAssetManifest
|
||||
uoLinkClient.getAssetManifest = async () => fail(403, { reason: 'not served' })
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), {
|
||||
code: 'DISABLED',
|
||||
message: /Bridge\.TreeEnabled/,
|
||||
})
|
||||
restore()
|
||||
})
|
||||
|
||||
test('the manifest alone answers the drift gate, with no file bytes at all', async () => {
|
||||
const calls = serveTree(FILES)
|
||||
|
||||
const listing = await treeBridge.manifest()
|
||||
const fingerprint = treeBridge.fingerprintOf(listing.files)
|
||||
|
||||
assert.equal(calls.fetch, 0, 'nothing was fetched to answer "has anything changed"')
|
||||
assert.deepEqual(Object.keys(fingerprint).sort(), [
|
||||
'Data/Regions.xml',
|
||||
'Spawns/Sosaria.xml',
|
||||
])
|
||||
assert.equal(fingerprint['Spawns/Sosaria.xml'], sha(FILES[1][1]))
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a zero-byte file crosses as one chunk carrying a real gzip stream', async () => {
|
||||
// Stock ServUO 57.4 ships TWO empty decoration files, so this is the ordinary
|
||||
// case rather than an edge one — and it is the case .NET gets wrong on its own:
|
||||
// `GZipStream` writes the gzip header lazily, so zero bytes in produces zero
|
||||
// bytes out, which is not a gzip stream at all. The overlay answers with a
|
||||
// literal empty member; a reader that accepted an empty payload instead would
|
||||
// have hidden the bug rather than caught it.
|
||||
const empty = Buffer.alloc(0)
|
||||
|
||||
serveTree([
|
||||
['Data/Regions.xml', Buffer.from('<ServerRegions />', 'utf8')],
|
||||
['Data/Decoration/nothing.cfg', empty],
|
||||
])
|
||||
|
||||
const { files } = await treeBridge.readSources()
|
||||
const blank = files.find((f) => f.label === 'Data/Decoration/nothing.cfg')
|
||||
|
||||
assert.equal(blank.bytes, 0)
|
||||
assert.equal(blank.text, '')
|
||||
assert.equal(blank.sha256, sha(empty))
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
test('an empty payload for a chunk is refused, whatever the row declares', async () => {
|
||||
serveTree(FILES, { tweak: { fetchRows: (rows) => { rows[0].gzip = '' } } })
|
||||
|
||||
await assert.rejects(() => treeBridge.readSources(), /Could not decompress/)
|
||||
restore()
|
||||
})
|
||||
|
||||
test('a tree-only shard tells the client-file readers so, rather than looking empty', async () => {
|
||||
// Phase 7 opened `assets.sources` to a shard that serves ONLY its configuration
|
||||
// tree, so a 200 from it stopped meaning "the client files are on offer". Both
|
||||
// client-file readers have to say DISABLED rather than read the empty file list
|
||||
// as "your UO client has no cliloc.enu", which sends an operator to their client
|
||||
// install for a setting that lives on their shard.
|
||||
const clilocBridge = require('../utils/clilocBridge')
|
||||
const assetBridge2 = require('../utils/assetBridge')
|
||||
|
||||
saved.getAssetSources = saved.getAssetSources ?? uoLinkClient.getAssetSources
|
||||
uoLinkClient.getAssetSources = async () =>
|
||||
ok({
|
||||
kind: 'assets.sources.ok',
|
||||
extractorVersion: 3,
|
||||
assetsEnabled: false,
|
||||
treeEnabled: true,
|
||||
imaging: { ok: true },
|
||||
families: ['tree'],
|
||||
files: [],
|
||||
more: false,
|
||||
cut: 'end',
|
||||
complete: true,
|
||||
})
|
||||
|
||||
await assert.rejects(() => clilocBridge.fingerprint(), {
|
||||
code: 'DISABLED',
|
||||
message: /Bridge\.AssetsEnabled/,
|
||||
})
|
||||
await assert.rejects(() => assetBridge2.sourceFingerprint(), {
|
||||
code: 'DISABLED',
|
||||
message: /Bridge\.AssetsEnabled/,
|
||||
})
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
// ── The parity test ────────────────────────────────────────────────────────
|
||||
|
||||
test('the same tree read off a disk and read over the bridge builds the same atlas', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-parity-'))
|
||||
|
||||
const tree = [
|
||||
[
|
||||
'Data/Regions.xml',
|
||||
'<?xml version="1.0"?><ServerRegions>'
|
||||
+ '<region type="Region"><name>Yew</name><map>Sosaria</map>'
|
||||
+ '<rect x="100" y="100" width="200" height="200" /></region>'
|
||||
+ '</ServerRegions>',
|
||||
],
|
||||
[
|
||||
'Data/Locations/Sosaria.xml',
|
||||
'<?xml version="1.0"?><locations><location><name>Yew Bank</name>'
|
||||
+ '<x>150</x><y>150</y><z>0</z></location></locations>',
|
||||
],
|
||||
[
|
||||
'Spawns/Sosaria.xml',
|
||||
'<?xml version="1.0"?><Spawns>'
|
||||
+ Array.from({ length: 40 }, (_, i) =>
|
||||
`<Spawn Name="s${i}" X="${120 + i}" Y="${130 + i}" Map="Sosaria" Count="3" `
|
||||
+ 'Running="True" MinDelay="00:05:00" MaxDelay="00:10:00" SpawnRange="5" '
|
||||
+ 'HomeRange="5"><Object>Lizardman</Object></Spawn>').join('')
|
||||
+ '</Spawns>',
|
||||
],
|
||||
['Config/ChampionSpawns.xml', '<?xml version="1.0"?><champions />'],
|
||||
['Data/Decoration/top.cfg', 'Brazier 0x0E31\n100 100 0\n'],
|
||||
['Data/Decoration/Deep/nested.cfg', 'LargeCrate 0x0E3C\n500 500 0\n'],
|
||||
]
|
||||
|
||||
for (const [label, text] of tree) {
|
||||
const file = path.join(root, label.replace(/\//g, path.sep))
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, text, 'utf8')
|
||||
}
|
||||
|
||||
const fromDisk = await buildFrom({ kind: 'fs', root })
|
||||
|
||||
// A chunk size small enough that the spawn file alone is dozens of chunks,
|
||||
// because a one-chunk-per-file test proves nothing about reassembly.
|
||||
serveTree(tree.map(([label, text]) => [label, Buffer.from(text, 'utf8')]), { chunkBytes: 37 })
|
||||
|
||||
const fromBridge = await buildFrom({ kind: 'bridge' })
|
||||
|
||||
// `generatedAt` is a timestamp and the only field that legitimately differs.
|
||||
delete fromDisk.meta.generatedAt
|
||||
delete fromBridge.meta.generatedAt
|
||||
|
||||
// **Serialised, not deepEqual.** `deepEqual` ignores object key order, and key
|
||||
// order is precisely what differed between the two readers on a real tree —
|
||||
// which a live walk caught and this test, written first, did not.
|
||||
assert.equal(JSON.stringify(fromBridge), JSON.stringify(fromDisk))
|
||||
assert.deepEqual(fromBridge, fromDisk)
|
||||
|
||||
// And the source fingerprints agree, which is what makes switching backends on
|
||||
// an existing install NOT look like a change to the drift gate.
|
||||
assert.deepEqual(fromBridge.meta.source, fromDisk.meta.source)
|
||||
|
||||
restore()
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('readFrom hands both backends back in one shape', async () => {
|
||||
serveTree(FILES)
|
||||
|
||||
const bridged = await readFrom({ kind: 'bridge' })
|
||||
|
||||
assert.deepEqual(Object.keys(bridged), ['files'])
|
||||
assert.deepEqual(Object.keys(bridged.files[0]).sort(), ['bytes', 'label', 'sha256', 'text'])
|
||||
|
||||
restore()
|
||||
})
|
||||
957
server/test/uoEventActions.test.js
Normal file
957
server/test/uoEventActions.test.js
Normal file
@@ -0,0 +1,957 @@
|
||||
// module-uo's event verbs, wave 1 (EVENTS_PLAN.md Phase 9).
|
||||
//
|
||||
// The declarations are data plus three `perform()`s, so most of this suite is
|
||||
// about the *shapes* core will check and the failure paths a live rig cannot be
|
||||
// made to produce on demand — a sidecar that answers 409, a shard that restarts
|
||||
// between two steps, a crier line one character over the cap.
|
||||
//
|
||||
// **The first test is the one the whole phase rests on.** Every other property
|
||||
// here — "a broadcast is sent once", "a failed post is retried" — is a claim
|
||||
// about what the MODULE decided, and the module only gets to decide when its
|
||||
// client answers before core's dispatch deadline. Assert the relationship, not
|
||||
// the numbers, or the day someone tunes one of them the suite stays green while
|
||||
// the behaviour inverts.
|
||||
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
require('./_setup')
|
||||
const actions = require('../config/uoEventActions')
|
||||
|
||||
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
||||
|
||||
let calls
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = {
|
||||
broadcast: [], crier: [], crierDel: [], news: [], newsDel: [],
|
||||
spawn: [], despawn: [], owned: [],
|
||||
}
|
||||
for (const name of [
|
||||
'adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews',
|
||||
'spawnWorld', 'ownedWorld', 'despawnWorld',
|
||||
]) {
|
||||
saved[name] = uoLinkClient[name]
|
||||
}
|
||||
saved.getSafe = uoLinkConfig.getSafe
|
||||
saved.listRegions = shardAtlas.listRegions
|
||||
saved.listLandmarks = shardAtlas.listLandmarks
|
||||
saved.searchCreatures = shardAtlas.searchCreatures
|
||||
saved.listDecorTypes = shardAtlas.listDecorTypes
|
||||
saved.getDecorType = shardAtlas.getDecorType
|
||||
|
||||
uoLinkClient.adminBroadcast = async (b) => { calls.broadcast.push(b); return { ok: true, status: 200 } }
|
||||
uoLinkClient.postTownCrier = async (b) => { calls.crier.push(b); return { ok: true, status: 200 } }
|
||||
uoLinkClient.deleteTownCrier = async (id) => { calls.crierDel.push(id); return { ok: true, status: 200 } }
|
||||
uoLinkClient.postNews = async (b) => { calls.news.push(b); return { ok: true, status: 200 } }
|
||||
uoLinkClient.deleteNews = async (id) => { calls.newsDel.push(id); return { ok: true, status: 200 } }
|
||||
// Phase 12a. Two serials back by default, so a spawn produces a resource list
|
||||
// longer than one and the per-serial ledger shape is what the suite exercises.
|
||||
uoLinkClient.spawnWorld = async (b) => {
|
||||
calls.spawn.push(b)
|
||||
const n = b.count || 1
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { serials: Array.from({ length: n }, (_, i) => `0x4000000${i}`) },
|
||||
}
|
||||
}
|
||||
uoLinkClient.ownedWorld = async (b) => {
|
||||
calls.owned.push(b)
|
||||
return { ok: true, status: 200, data: { owned: [{ serial: '0x40000000', what: 'creature' }] } }
|
||||
}
|
||||
uoLinkClient.despawnWorld = async (b) => {
|
||||
calls.despawn.push(b)
|
||||
return { ok: true, status: 200, data: { removed: b.serials || [], gone: [], refused: [] } }
|
||||
}
|
||||
uoLinkConfig.getSafe = async () => ({ bootId: 'boot-1' })
|
||||
// Phase 11b. `uo.participation.open` resolves its `place` param against the
|
||||
// atlas, so the dry-run sweep below reaches this rather than the database.
|
||||
// Two landmarks, because Phase 12a's gate verb resolves a SECOND place: its
|
||||
// destination. One would make the dry-run sweep below pass for the wrong
|
||||
// reason, by never exercising the leg that can name a different point.
|
||||
shardAtlas.listLandmarks = async () => [
|
||||
{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 },
|
||||
{ facet: 'Felucca', name: 'Yew', x: 542, y: 982, z: 0 },
|
||||
]
|
||||
shardAtlas.listDecorTypes = async () => [{ type: 'Brazier', itemId: 0x0E31, uses: 42 }]
|
||||
shardAtlas.getDecorType = async (type) =>
|
||||
type === 'Brazier' ? { type: 'Brazier', itemId: 0x0E31, uses: 42 } : null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of ['adminBroadcast', 'postTownCrier', 'deleteTownCrier', 'postNews', 'deleteNews']) {
|
||||
uoLinkClient[name] = saved[name]
|
||||
}
|
||||
uoLinkConfig.getSafe = saved.getSafe
|
||||
shardAtlas.listRegions = saved.listRegions
|
||||
shardAtlas.listLandmarks = saved.listLandmarks
|
||||
shardAtlas.searchCreatures = saved.searchCreatures
|
||||
shardAtlas.listDecorTypes = saved.listDecorTypes
|
||||
shardAtlas.getDecorType = saved.getDecorType
|
||||
for (const name of ['spawnWorld', 'ownedWorld', 'despawnWorld']) {
|
||||
uoLinkClient[name] = saved[name]
|
||||
}
|
||||
})
|
||||
|
||||
// ── The rule everything else depends on ────────────────────────────────────
|
||||
|
||||
test('every action outlives the sidecar client, so the module classifies its own failures', () => {
|
||||
// `dispatch.classify()` answers `retry` for a budget timeout unconditionally
|
||||
// and never asks the action. If core's deadline can fire before the client
|
||||
// gives up, `retry: false` below is unreachable and a broadcast is retried.
|
||||
for (const action of actions.ACTIONS) {
|
||||
assert.ok(
|
||||
action.budgetMs > uoLinkClient.TIMEOUT_MS,
|
||||
`${action.id} budgetMs (${action.budgetMs}) must exceed uoLinkClient.TIMEOUT_MS (${uoLinkClient.TIMEOUT_MS})`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// ── The declarations, against the checks core will run ─────────────────────
|
||||
|
||||
test('the declarations satisfy the shape core validates them with', () => {
|
||||
const RISKS = ['notify', 'inspect', 'change', 'irreversible']
|
||||
const REVERSIBLE = ['none', 'self', 'ledger', 'override']
|
||||
const PARAM_TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url']
|
||||
|
||||
for (const a of actions.ACTIONS) {
|
||||
assert.ok(a.id.startsWith('uo.'), `${a.id} must be namespaced to this module`)
|
||||
assert.ok(a.label && a.description, `${a.id} needs a label and a description`)
|
||||
assert.ok(RISKS.includes(a.risk), `${a.id} has an unknown risk class`)
|
||||
assert.ok(REVERSIBLE.includes(a.reversible), `${a.id} has an unknown reversible class`)
|
||||
assert.equal(typeof a.perform, 'function')
|
||||
|
||||
// `revert` is required iff ledger, and forbidden otherwise — a revert on a
|
||||
// non-ledgering action is an undo core will never call.
|
||||
assert.equal(
|
||||
typeof a.revert === 'function',
|
||||
a.reversible === 'ledger',
|
||||
`${a.id} revert() must be present exactly when reversible is 'ledger'`,
|
||||
)
|
||||
// `reconcile` is optional, but only meaningful where something is ledgered.
|
||||
if (a.reconcile !== undefined) {
|
||||
assert.equal(typeof a.reconcile, 'function')
|
||||
assert.ok(a.reversible === 'ledger' || a.reversible === 'override', `${a.id} reconciles but ledgers nothing`)
|
||||
}
|
||||
if (a.cost !== undefined) assert.equal(typeof a.cost, 'function')
|
||||
|
||||
const names = new Set()
|
||||
for (const p of a.params) {
|
||||
assert.ok(!names.has(p.name), `${a.id} declares ${p.name} twice`)
|
||||
names.add(p.name)
|
||||
assert.ok(PARAM_TYPES.includes(p.type), `${a.id}.${p.name} has an unsupported type "${p.type}"`)
|
||||
// Required on every param including the optional ones: it is the authoring
|
||||
// placeholder, and an unattended world write typed into a blank box is how
|
||||
// a typo gets scheduled.
|
||||
assert.ok(
|
||||
p.example !== undefined && p.example !== null && p.example !== '',
|
||||
`${a.id}.${p.name} needs an example`,
|
||||
)
|
||||
assert.ok(p.description, `${a.id}.${p.name} needs a description`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every dimension a cost names is one this module declares', () => {
|
||||
const declared = new Set(actions.BUDGETS.map((b) => b.id))
|
||||
// Phase 12a's six and Phase 12b's seventh are all the MODULE's (org lead,
|
||||
// 2026-09-07): core meters what a module declares and holds no UO knowledge, so
|
||||
// a `uo.` dimension core knew about would be a leak of this game into the engine.
|
||||
//
|
||||
// `uo.rewards` counts ITEMS rather than grants: a step giving 500 gold to forty
|
||||
// people and one giving a candle to forty people are not the same imposition, and
|
||||
// a count of grants would price them identically.
|
||||
assert.deepEqual(
|
||||
[...declared],
|
||||
[
|
||||
'uo.broadcasts',
|
||||
'uo.creatures',
|
||||
'uo.bosses',
|
||||
'uo.npcs',
|
||||
'uo.decor',
|
||||
'uo.gate.minutes',
|
||||
'uo.rewards',
|
||||
],
|
||||
)
|
||||
for (const b of actions.BUDGETS) {
|
||||
assert.ok(b.id.startsWith('uo.'), 'a budget dimension must be namespaced')
|
||||
assert.ok(b.label && b.unit, 'a dimension is rendered as a label and a unit beside a number')
|
||||
}
|
||||
|
||||
// Every dimension a cost names must be one the module declared, or core is
|
||||
// asked to bound something nothing defines.
|
||||
const cost = byId('uo.broadcast').cost({})
|
||||
assert.deepEqual(cost, { 'uo.broadcasts': 1 })
|
||||
for (const id of Object.keys(cost)) assert.ok(declared.has(id), `${id} is spent but never declared`)
|
||||
|
||||
// The keyed verbs deliberately spend nothing: a repeat REPLACES under the same
|
||||
// id, so there is no runaway for a cap to bound.
|
||||
assert.equal(byId('uo.towncrier.post').cost, undefined)
|
||||
assert.equal(byId('uo.news.post').cost, undefined)
|
||||
|
||||
// Phase 12a. Asserted across EVERY action rather than one at a time, because
|
||||
// the failure this catches is a typo in one dimension name out of six, which
|
||||
// core answers by refusing the whole registration at load.
|
||||
for (const action of actions.ACTIONS) {
|
||||
if (typeof action.cost !== 'function') continue
|
||||
const params = {}
|
||||
for (const p of action.params) params[p.name] = p.example
|
||||
for (const id of Object.keys(action.cost(params))) {
|
||||
assert.ok(declared.has(id), `${action.id} spends "${id}", which nothing declares`)
|
||||
}
|
||||
}
|
||||
|
||||
// A gate is priced in MINUTES, not in gates. One standing all day and twelve
|
||||
// standing five minutes each are not the same imposition on a world, and a
|
||||
// count would price them identically.
|
||||
assert.deepEqual(byId('uo.gate.open').cost({ durationMinutes: 120 }), { 'uo.gate.minutes': 120 })
|
||||
assert.deepEqual(byId('uo.creature.spawn').cost({ count: 8 }), { 'uo.creatures': 8 })
|
||||
})
|
||||
|
||||
// ── uo.broadcast: retried, because protocol 6 made that safe ───────────────
|
||||
|
||||
test('a broadcast is retried on a transient failure and never on a permanent one', async () => {
|
||||
const broadcast = byId('uo.broadcast')
|
||||
// Wave 1 asserted the opposite of this — every failure terminal, including the
|
||||
// two that are plainly transient — because nothing on the wire could stop a
|
||||
// retry announcing to everyone twice. Protocol 6 puts an idempotency key on the
|
||||
// command and the shard refuses the repeat, so the trade that test recorded is
|
||||
// no longer one that has to be made.
|
||||
//
|
||||
// 425 is the new status in this list: `bridge.busy`, the shard saying a command
|
||||
// under this key is still in flight. Transient by construction.
|
||||
const TRANSIENT = new Set([0, 425, 503, 504])
|
||||
for (const status of [0, 400, 401, 403, 409, 425, 503, 504]) {
|
||||
uoLinkClient.adminBroadcast = async () => ({ ok: false, status, error: `status ${status}` })
|
||||
const result = await broadcast.perform({ runId: 7, params: { text: 'hear ye' }, verify: false })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.retry, TRANSIENT.has(status), `a ${status} retries iff it is transient`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every write carries the step idempotency key, unchanged', async () => {
|
||||
// The key is what makes the retry above safe, so a verb that dropped it would
|
||||
// silently restore the wave-1 hazard while every other assertion still passed.
|
||||
// Asserted per verb rather than once, because each builds its own body.
|
||||
const KEY = 'a'.repeat(40)
|
||||
const seen = {}
|
||||
|
||||
uoLinkClient.adminBroadcast = async (body) => { seen.broadcast = body; return { ok: true } }
|
||||
uoLinkClient.postTownCrier = async (body) => { seen.crier = body; return { ok: true } }
|
||||
uoLinkClient.postNews = async (body) => { seen.news = body; return { ok: true } }
|
||||
|
||||
await byId('uo.broadcast').perform({
|
||||
runId: 7, idempotencyKey: KEY, params: { text: 'hear ye' }, verify: false,
|
||||
})
|
||||
await byId('uo.towncrier.post').perform({
|
||||
runId: 7, idempotencyKey: KEY, params: { lines: 'hear ye' }, verify: false,
|
||||
})
|
||||
await byId('uo.news.post').perform({
|
||||
runId: 7, idempotencyKey: KEY, params: { title: 'A thing', body: 'happened' }, verify: false,
|
||||
})
|
||||
|
||||
assert.equal(seen.broadcast.idempotencyKey, KEY)
|
||||
assert.equal(seen.crier.idempotencyKey, KEY)
|
||||
assert.equal(seen.news.idempotencyKey, KEY)
|
||||
// The two keyed verbs post under an id DERIVED from the key. Both travel: the
|
||||
// id is what makes a repeat replace, the key is what stops it re-announcing.
|
||||
assert.equal(seen.crier.id, `evt-${KEY}`)
|
||||
assert.equal(seen.news.id, `evt-${KEY}`)
|
||||
})
|
||||
|
||||
test("the shard's own words reach the run log, not just a status code", async () => {
|
||||
// **The rig found this.** The sidecar refuses a broadcast with
|
||||
// `{"reason":"admin write plane disabled"}` and `legError` looks for
|
||||
// `data.message`, so the run console read "sidecar responded 403" for a cause
|
||||
// the shard had already explained in a sentence. A staff member clicking a
|
||||
// button knows what they switched off; an event that ran at four in the morning
|
||||
// leaves the run log as the only place anyone will learn why.
|
||||
uoLinkClient.adminBroadcast = async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
data: { kind: 'admin.error', reason: 'admin write plane disabled' },
|
||||
error: 'sidecar responded 403',
|
||||
})
|
||||
const result = await byId('uo.broadcast').perform({ runId: 1, params: { text: 'hear ye' }, verify: false })
|
||||
assert.match(result.error, /admin write plane disabled/)
|
||||
// And NOT the double-announce clause: a 403 will not succeed on any attempt, so
|
||||
// pointing an operator at a policy decision misdirects them away from the
|
||||
// switch they actually have to flip.
|
||||
assert.doesNotMatch(result.error, /announce twice/)
|
||||
assert.equal(result.retry, false)
|
||||
})
|
||||
|
||||
test('a permanent refusal of a keyed verb is not retried either', async () => {
|
||||
// Same distinction on the other side: the keyed verbs DO retry a transient, and
|
||||
// must not burn three attempts on a refusal that cannot change.
|
||||
uoLinkClient.postTownCrier = async () => ({ ok: false, status: 403, data: { reason: 'admin write plane disabled' } })
|
||||
const result = await byId('uo.towncrier.post').perform({
|
||||
runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines: 'hear ye' }, verify: false,
|
||||
})
|
||||
assert.equal(result.retry, false)
|
||||
assert.match(result.error, /admin write plane disabled/)
|
||||
})
|
||||
|
||||
test('a broadcast names its run in the shard audit, not a staff member', async () => {
|
||||
await byId('uo.broadcast').perform({ runId: 42, params: { text: 'hear ye', hue: 1153 }, verify: false })
|
||||
assert.equal(calls.broadcast.length, 1)
|
||||
assert.equal(calls.broadcast[0].actor, 'event:42')
|
||||
assert.equal(calls.broadcast[0].hue, 1153)
|
||||
})
|
||||
|
||||
test('an over-long broadcast is refused by the DRY RUN, before anything is sent', async () => {
|
||||
const broadcast = byId('uo.broadcast')
|
||||
const text = 'x'.repeat(actions.MAX_BROADCAST_LEN + 1)
|
||||
|
||||
const dry = await broadcast.perform({ runId: 1, params: { text }, verify: true })
|
||||
assert.equal(dry.ok, false)
|
||||
assert.equal(dry.retry, false)
|
||||
assert.match(dry.error, new RegExp(String(actions.MAX_BROADCAST_LEN)))
|
||||
|
||||
const live = await broadcast.perform({ runId: 1, params: { text }, verify: false })
|
||||
assert.equal(live.ok, false)
|
||||
assert.deepEqual(calls.broadcast, [], 'nothing may reach the shard once the cap is breached')
|
||||
})
|
||||
|
||||
test('a dry run sends nothing at all', async () => {
|
||||
for (const action of actions.ACTIONS) {
|
||||
const params = {}
|
||||
for (const p of action.params) if (p.required) params[p.name] = p.example
|
||||
const result = await action.perform({ runId: 1, stepId: 1, idempotencyKey: 'k'.repeat(40), params, verify: true })
|
||||
assert.equal(result.ok, true, `${action.id} refused its own example params`)
|
||||
assert.equal(result.resources, undefined, `${action.id} reported a resource it never created`)
|
||||
}
|
||||
assert.deepEqual(
|
||||
[calls.broadcast.length, calls.crier.length, calls.news.length],
|
||||
[0, 0, 0],
|
||||
'a dry run reached the shard',
|
||||
)
|
||||
})
|
||||
|
||||
// ── The keyed verbs: one id, stable across a retry ─────────────────────────
|
||||
|
||||
test('the crier and the news gump post under a run-stable id a retry replaces', async () => {
|
||||
const key = 'a1b2c3'.padEnd(40, '0')
|
||||
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
|
||||
await byId('uo.towncrier.post').perform({ runId: 3, idempotencyKey: key, params: { lines: 'hear ye' }, verify: false })
|
||||
|
||||
assert.equal(calls.crier.length, 2)
|
||||
assert.equal(calls.crier[0].id, calls.crier[1].id, 'a retry must replace, not stack')
|
||||
assert.equal(calls.crier[0].id, `evt-${key}`)
|
||||
// The sidecar's own cap on the id column.
|
||||
assert.ok(calls.crier[0].id.length <= 64)
|
||||
})
|
||||
|
||||
test('an event article cannot collide with a website post in the news gump', async () => {
|
||||
// `newsGump.js` posts site articles under the bare post id and re-pushes that
|
||||
// whole set on every reconnect. An event article numbered into the same space
|
||||
// would silently be a collision with a post, in whichever direction wrote last.
|
||||
await byId('uo.news.post').perform({
|
||||
runId: 9,
|
||||
idempotencyKey: 'f'.repeat(40),
|
||||
params: { title: 'The Fair', body: 'Merchants gather.' },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(calls.news.length, 1)
|
||||
assert.doesNotMatch(calls.news[0].id, /^\d+$/, 'an event article must not be numbered like a post')
|
||||
assert.match(calls.news[0].id, /^evt-/)
|
||||
assert.match(calls.news[0].body, /<CENTER>The Fair<\/CENTER>/)
|
||||
assert.equal(calls.news[0].announce, true, 'announce defaults on, as the gump does')
|
||||
})
|
||||
|
||||
test('the keyed verbs DO retry, because a repeat replaces', async () => {
|
||||
for (const [id, stub] of [['uo.towncrier.post', 'postTownCrier'], ['uo.news.post', 'postNews']]) {
|
||||
const params = { lines: 'hear ye', title: 'The Fair', body: 'Merchants gather.' }
|
||||
// The announce leg's own classification of this transport, reused rather
|
||||
// than re-decided: a config or data problem is terminal, the rest transient.
|
||||
for (const [status, retry] of [[400, false], [401, false], [403, false], [409, false], [503, true], [504, true], [0, true]]) {
|
||||
uoLinkClient[stub] = async () => ({ ok: false, status, error: `status ${status}` })
|
||||
const result = await byId(id).perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params, verify: false })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.retry, retry, `${id} misclassified a ${status}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('a crier post is refused before it is sent when it is not eight short lines', async () => {
|
||||
const crier = byId('uo.towncrier.post')
|
||||
const cases = [
|
||||
['', /empty/],
|
||||
[' \n ', /empty/],
|
||||
[Array.from({ length: actions.MAX_CRIER_LINES + 1 }, (_, i) => `line ${i}`).join('\n'), /criers carry/],
|
||||
['x'.repeat(actions.MAX_CRIER_LINE_LEN + 1), /capped at/],
|
||||
]
|
||||
for (const [lines, expected] of cases) {
|
||||
const result = await crier.perform({ runId: 1, idempotencyKey: 'k'.repeat(40), params: { lines }, verify: false })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.retry, false, 'a badly shaped message is just as badly shaped next minute')
|
||||
assert.match(result.error, expected)
|
||||
}
|
||||
assert.deepEqual(calls.crier, [])
|
||||
})
|
||||
|
||||
test('blank lines are dropped rather than counted against the cap', () => {
|
||||
// A textarea an operator has pressed enter in twice still holds two lines.
|
||||
const parsed = actions.crierLines('hear ye\n\n \nseek the herald\n')
|
||||
assert.equal(parsed.ok, true)
|
||||
assert.deepEqual(parsed.lines, ['hear ye', 'seek the herald'])
|
||||
})
|
||||
|
||||
test('a crier duration is taken in minutes and bounded at the sidecar cap', async () => {
|
||||
const crier = byId('uo.towncrier.post')
|
||||
const base = { runId: 1, idempotencyKey: 'k'.repeat(40), verify: false }
|
||||
|
||||
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 90 } })
|
||||
assert.equal(calls.crier[0].durationSec, 5400)
|
||||
|
||||
await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 60 * 48 } })
|
||||
assert.equal(calls.crier[1].durationSec, 86400, 'a duration past the sidecar cap is clamped, not refused')
|
||||
|
||||
// Left out entirely, so the sidecar applies its own default rather than the
|
||||
// module inventing one.
|
||||
await crier.perform({ ...base, params: { lines: 'hear ye' } })
|
||||
assert.equal(calls.crier[2].durationSec, undefined)
|
||||
|
||||
const bad = await crier.perform({ ...base, params: { lines: 'hear ye', durationMinutes: 'soon' } })
|
||||
assert.equal(bad.ok, false)
|
||||
assert.equal(bad.retry, false)
|
||||
})
|
||||
|
||||
// ── Giving it back ─────────────────────────────────────────────────────────
|
||||
|
||||
test('a resource that is already gone is a successful revert', async () => {
|
||||
// §L: "gone, and that is fine". A crier line whose duration ran out is a 404,
|
||||
// and it is the outcome teardown wanted.
|
||||
uoLinkClient.deleteTownCrier = async () => ({ ok: false, status: 404 })
|
||||
uoLinkClient.deleteNews = async () => ({ ok: false, status: 404 })
|
||||
|
||||
for (const id of ['uo.towncrier.post', 'uo.news.post']) {
|
||||
const result = await byId(id).revert({ runId: 1, resources: [{ kind: 'x', ref: 'evt-1' }] })
|
||||
assert.equal(result.ok, true)
|
||||
assert.ok(!result.failed || !result.failed.length)
|
||||
}
|
||||
})
|
||||
|
||||
test('a revert names the resources that did not come back', async () => {
|
||||
uoLinkClient.deleteTownCrier = async (id) => {
|
||||
calls.crierDel.push(id)
|
||||
return id === 'evt-bad' ? { ok: false, status: 503 } : { ok: true, status: 200 }
|
||||
}
|
||||
const result = await byId('uo.towncrier.post').revert({
|
||||
runId: 1,
|
||||
resources: [{ ref: 'evt-ok' }, { ref: 'evt-bad' }],
|
||||
})
|
||||
// `ok: true` with a `failed` list, not `ok: false`: the group was worked, and
|
||||
// one member of it is outstanding. Core keeps the row and tries it again.
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual(result.failed, ['evt-bad'])
|
||||
assert.deepEqual(calls.crierDel, ['evt-ok', 'evt-bad'], 'one failure must not stop the group')
|
||||
})
|
||||
|
||||
// ── reconcile: the boot stamp ──────────────────────────────────────────────
|
||||
|
||||
test('a resource stamped with the current boot is still in force', async () => {
|
||||
const resources = [
|
||||
{ kind: 'towncrier', ref: 'evt-a', payload: { bootId: 'boot-1' } },
|
||||
{ kind: 'towncrier', ref: 'evt-b', payload: { bootId: 'boot-0' } },
|
||||
]
|
||||
const result = await actions.reconcileByBootId({ resources })
|
||||
assert.equal(result.ok, true)
|
||||
// Only the row from the boot that is still running. Core orphans the other —
|
||||
// which is the honest sentence: it vanished while nobody was looking, rather
|
||||
// than core having put it back.
|
||||
assert.deepEqual(result.inForce, ['evt-a'])
|
||||
})
|
||||
|
||||
test('a resource with no stamp is reported in force, because "I do not know" is not "it is gone"', async () => {
|
||||
const result = await actions.reconcileByBootId({
|
||||
resources: [{ ref: 'evt-old', payload: null }, { ref: 'evt-older', payload: {} }],
|
||||
})
|
||||
assert.deepEqual(result.inForce, ['evt-old', 'evt-older'])
|
||||
})
|
||||
|
||||
test('with no shard boot to compare against, reconcile declines rather than orphaning everything', async () => {
|
||||
uoLinkConfig.getSafe = async () => ({ bootId: null })
|
||||
const result = await actions.reconcileByBootId({ resources: [{ ref: 'evt-a', payload: { bootId: 'boot-1' } }] })
|
||||
// Core treats anything that is not an explicit answer as unanswered and leaves
|
||||
// the ledger alone. An `ok: true, inForce: []` here would abandon every live row
|
||||
// on a website that came up before its sidecar did.
|
||||
assert.equal(result.ok, false)
|
||||
})
|
||||
|
||||
test('a write with an unreadable config still happens, and simply carries no stamp', async () => {
|
||||
uoLinkConfig.getSafe = async () => { throw new Error('pool is down') }
|
||||
const result = await byId('uo.towncrier.post').perform({
|
||||
runId: 1,
|
||||
idempotencyKey: 'k'.repeat(40),
|
||||
params: { lines: 'hear ye' },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(result.ok, true, 'a config read must not fail a world write')
|
||||
assert.equal(result.resources[0].payload.bootId, null)
|
||||
})
|
||||
|
||||
// ── Option sources ─────────────────────────────────────────────────────────
|
||||
|
||||
const source = (id) => actions.OPTION_SOURCES.find((s) => s.id === id)
|
||||
|
||||
test('every option source is namespaced and answers', () => {
|
||||
for (const s of actions.OPTION_SOURCES) {
|
||||
assert.ok(s.id.startsWith('uo.options.'), `${s.id} must be namespaced`)
|
||||
assert.ok(s.label && s.description)
|
||||
assert.equal(typeof s.resolve, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
test('a place is named by its facet, because two facets both have a Britain', async () => {
|
||||
shardAtlas.listRegions = async () => [
|
||||
{ facet: 'Felucca', name: 'Britain' },
|
||||
{ facet: 'Trammel', name: 'Britain' },
|
||||
]
|
||||
const options = await source('uo.options.regions').resolve()
|
||||
assert.equal(new Set(options.map((o) => o.value)).size, 2, 'two different places must not share a value')
|
||||
assert.deepEqual(options[0], { value: 'Felucca/Britain', label: 'Britain', group: 'Felucca' })
|
||||
})
|
||||
|
||||
test('a landmark groups by the atlas grouping where it has one, the facet otherwise', async () => {
|
||||
shardAtlas.listLandmarks = async () => [
|
||||
{ facet: 'Felucca', name: 'Despise', group: 'Dungeons' },
|
||||
{ facet: 'Felucca', name: 'Cove', group: null },
|
||||
]
|
||||
const options = await source('uo.options.landmarks').resolve()
|
||||
assert.deepEqual(options.map((o) => o.group), ['Dungeons', 'Felucca'])
|
||||
})
|
||||
|
||||
test('a creature option carries the type the shard can build, not the atlas slug', async () => {
|
||||
// Changed in Phase 12a, and the reason is the point of the source existing.
|
||||
// Wave 1 declared it before anything consumed it and used the slug — unique,
|
||||
// stable, and unusable: the shard constructs from a ServUO class name, and
|
||||
// `orc-brute` is not one. The atlas's `name` IS the raw type token from the
|
||||
// spawn files, so the fix was to stop discarding the half that works.
|
||||
shardAtlas.searchCreatures = async ({ limit }) => {
|
||||
assert.equal(limit, actions.MAX_OPTIONS, 'the source must bound what it asks the atlas for')
|
||||
return { creatures: [{ slug: 'orcbrute', name: 'OrcBrute' }] }
|
||||
}
|
||||
assert.deepEqual(await source('uo.options.creatures').resolve(), [
|
||||
{ value: 'OrcBrute', label: 'OrcBrute' },
|
||||
])
|
||||
})
|
||||
|
||||
test('decoration options come from the shard\'s own decoration files', async () => {
|
||||
const options = await source('uo.options.decor').resolve()
|
||||
assert.deepEqual(options, [{ value: 'Brazier', label: 'Brazier' }])
|
||||
})
|
||||
|
||||
test('an atlas larger than the dropdown bound is truncated and said so', async () => {
|
||||
const { ctx } = require('./_setup')
|
||||
shardAtlas.listRegions = async () =>
|
||||
Array.from({ length: actions.MAX_OPTIONS + 5 }, (_, i) => ({ facet: 'Felucca', name: `Region ${i}` }))
|
||||
const options = await source('uo.options.regions').resolve()
|
||||
assert.equal(options.length, actions.MAX_OPTIONS)
|
||||
// Silently serving 2000 of 2005 is the defect the bound would otherwise
|
||||
// introduce: an author cannot find the landmark they are looking for and
|
||||
// nothing anywhere says why.
|
||||
const warned = ctx.logs
|
||||
.filter((l) => l.namespace === 'uo-events')
|
||||
.flatMap((l) => l.log.warn.calls)
|
||||
.some(([message]) => /truncated/.test(message))
|
||||
assert.ok(warned, 'a truncated source must leave a log line naming itself')
|
||||
})
|
||||
|
||||
// ── A landmark option value names ONE landmark (Phase 16b) ────────────────
|
||||
|
||||
test('two landmarks sharing a name are two different options, and both resolve', async () => {
|
||||
// A stock 57.4 tree has 558 landmarks under 320 distinct `facet/name` pairs:
|
||||
// `Trammel/Entrance` is 23 different dungeons. The source emitted `facet/name`
|
||||
// and `landmarkPoint` resolved with `.find()`, so 22 of the 23 were unreachable
|
||||
// — an author who picked "Entrance — Destard" got Blighted Grove, with a
|
||||
// successful run and no warning. The group was already the disambiguator and it
|
||||
// was shown to the eye while being left out of the value.
|
||||
//
|
||||
// Asserted as an INEQUALITY between two resolved points rather than against a
|
||||
// literal value string, so it survives someone changing the value's format
|
||||
// again as long as the two options still address two places.
|
||||
shardAtlas.listLandmarks = async () => [
|
||||
{ facet: 'Felucca', name: 'Entrance', group: 'Blighted Grove', x: 586, y: 1643, z: 0 },
|
||||
{ facet: 'Felucca', name: 'Entrance', group: 'Destard', x: 1176, y: 2637, z: 0 },
|
||||
]
|
||||
|
||||
const source = actions.OPTION_SOURCES.find((s) => s.id === 'uo.options.landmarks')
|
||||
const options = await source.resolve({})
|
||||
assert.equal(options.length, 2)
|
||||
assert.equal(new Set(options.map((o) => o.value)).size, 2, 'both options must be addressable')
|
||||
|
||||
const points = []
|
||||
for (const option of options) {
|
||||
const result = await byId('uo.creature.spawn').perform({
|
||||
runId: 41,
|
||||
idempotencyKey: `L${option.value}`.padEnd(40, 'x'),
|
||||
params: { place: option.value, creature: 'Orc', count: 1 },
|
||||
verify: true,
|
||||
})
|
||||
assert.equal(result.ok, true, `${option.value} must resolve`)
|
||||
points.push(option.value)
|
||||
}
|
||||
assert.notEqual(points[0], points[1])
|
||||
})
|
||||
|
||||
test('a place published before the group was carried still resolves', async () => {
|
||||
// Every event published before the fix stores `facet/name`, and a published
|
||||
// version is immutable — so a parse that stopped understanding the two-part
|
||||
// form would break those runs rather than correct them. It keeps the old
|
||||
// first-match read, which is imprecise in exactly the way it always was.
|
||||
shardAtlas.listLandmarks = async () => [
|
||||
{ facet: 'Felucca', name: 'Entrance', group: 'Blighted Grove', x: 586, y: 1643, z: 0 },
|
||||
{ facet: 'Felucca', name: 'Entrance', group: 'Destard', x: 1176, y: 2637, z: 0 },
|
||||
// A name carrying a slash reads as three parts too; the two-part read is what
|
||||
// resolves it, which is why the three-part attempt must not answer for it.
|
||||
{ facet: 'Felucca', name: 'Odd/Name', group: null, x: 10, y: 20, z: 0 },
|
||||
]
|
||||
|
||||
for (const place of ['Felucca/Entrance', 'Felucca/Odd/Name']) {
|
||||
const result = await byId('uo.creature.spawn').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: `P${place}`.padEnd(40, 'x'),
|
||||
params: { place, creature: 'Orc', count: 1 },
|
||||
verify: true,
|
||||
})
|
||||
assert.equal(result.ok, true, `${place} must still resolve`)
|
||||
}
|
||||
|
||||
// And a three-part value whose group is gone REFUSES rather than silently
|
||||
// landing somewhere else. That is the honest answer: it asked for one place.
|
||||
const gone = await byId('uo.creature.spawn').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'G'.repeat(40),
|
||||
params: { place: 'Felucca/Renamed/Entrance', creature: 'Orc', count: 1 },
|
||||
verify: true,
|
||||
})
|
||||
assert.equal(gone.ok, false)
|
||||
assert.match(gone.error, /no landmark called/)
|
||||
})
|
||||
|
||||
// ── The world verbs (Phase 12a) ───────────────────────────────
|
||||
|
||||
test('a spawn files one ledger row per serial, not one per call', async () => {
|
||||
// Per serial, because a group half of which a player killed has to reconcile
|
||||
// per creature. One row per call would make teardown all-or-nothing over eight
|
||||
// orcs of which six are gone, which is neither true nor useful.
|
||||
const result = await byId('uo.creature.spawn').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'c'.repeat(40),
|
||||
params: { place: 'Felucca/Britain', creature: 'Orc', count: 3 },
|
||||
verify: false,
|
||||
})
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.resources.length, 3)
|
||||
for (const resource of result.resources) {
|
||||
assert.equal(resource.kind, actions.OWNED_KIND)
|
||||
assert.equal(resource.payload.runId, '7')
|
||||
assert.equal(resource.payload.what, 'creature')
|
||||
assert.equal(resource.payload.type, 'Orc')
|
||||
}
|
||||
|
||||
// The place is resolved to a point HERE, so the shard is never handed a
|
||||
// facet/name it would have to know how to read.
|
||||
assert.equal(calls.spawn.length, 1)
|
||||
assert.deepEqual(
|
||||
{ map: calls.spawn[0].map, x: calls.spawn[0].x, y: calls.spawn[0].y },
|
||||
{ map: 'Felucca', x: 1496, y: 1628 },
|
||||
)
|
||||
})
|
||||
|
||||
test('a boss is a creature plus multipliers, and is refused above the ceiling', async () => {
|
||||
const boss = byId('uo.boss.spawn')
|
||||
const params = {
|
||||
place: 'Felucca/Britain',
|
||||
creature: 'OrcCaptain',
|
||||
name: 'Gruk the Unbroken',
|
||||
hitsMultiplier: 3,
|
||||
damageMultiplier: 1.5,
|
||||
}
|
||||
|
||||
assert.equal((await boss.perform({ runId: 7, idempotencyKey: 'b'.repeat(40), params, verify: false })).ok, true)
|
||||
assert.equal(calls.spawn[0].what, 'boss')
|
||||
assert.equal(calls.spawn[0].hitsMultiplier, 3)
|
||||
assert.equal(calls.spawn[0].damageMultiplier, 1.5)
|
||||
// Absent, not zero: a multiplier nobody set must not arrive as a number the
|
||||
// shard would then apply.
|
||||
assert.equal(calls.spawn[0].statMultiplier, undefined)
|
||||
|
||||
const tooMuch = await boss.perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'b'.repeat(40),
|
||||
params: { ...params, hitsMultiplier: actions.MAX_BOSS_MULTIPLIER + 1 },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(tooMuch.ok, false)
|
||||
assert.equal(tooMuch.retry, false, 'a ceiling will not move on a retry')
|
||||
assert.equal(calls.spawn.length, 1, 'nothing may reach the shard once it is refused here')
|
||||
|
||||
// Named, because an unnamed boss is just a hard orc — and because the name is
|
||||
// what an operator reads in the ledger afterwards.
|
||||
const unnamed = await boss.perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'b'.repeat(40),
|
||||
params: { ...params, name: ' ' },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(unnamed.ok, false)
|
||||
})
|
||||
|
||||
test('an oracle\'s dialogue is parsed from one textarea, and a bad row is named', async () => {
|
||||
const parsed = actions.oracleLines('fire, flame = It burns beneath the keep.\n gate = At dusk. ')
|
||||
assert.deepEqual(parsed, {
|
||||
ok: true,
|
||||
rows: [
|
||||
{ keywords: 'fire,flame', text: 'It burns beneath the keep.' },
|
||||
{ keywords: 'gate', text: 'At dusk.' },
|
||||
],
|
||||
})
|
||||
|
||||
// Split on the FIRST `=`, so an answer may contain one.
|
||||
assert.deepEqual(actions.oracleLines('sum = 2 = 2 is four').rows, [
|
||||
{ keywords: 'sum', text: '2 = 2 is four' },
|
||||
])
|
||||
|
||||
assert.equal(actions.oracleLines('just some prose').ok, false)
|
||||
assert.equal(actions.oracleLines('fire =').ok, false, 'a keyword with nothing to say is a mistake')
|
||||
assert.equal(actions.oracleLines('= something').ok, false, 'something to say with no keyword is too')
|
||||
|
||||
const tooMany = actions.oracleLines(
|
||||
Array.from({ length: actions.MAX_ORACLE_LINES + 1 }, (_, i) => `w${i} = t${i}`).join('\n'),
|
||||
)
|
||||
assert.equal(tooMany.ok, false)
|
||||
})
|
||||
|
||||
test('an oracle with nothing to say is refused before it is stood up', async () => {
|
||||
// `required: true` on the greeting catches an ABSENT field, at the edge, and
|
||||
// this catches the one holding nothing but spaces — which reaches `perform`
|
||||
// looking exactly like a filled-in form.
|
||||
const result = await byId('uo.npc.place').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'n'.repeat(40),
|
||||
params: { place: 'Felucca/Britain', name: 'Marisa', greeting: ' ' },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.retry, false)
|
||||
assert.match(result.error, /silence/)
|
||||
assert.deepEqual(calls.spawn, [])
|
||||
})
|
||||
|
||||
test('a keyword line reaches the shard as keywords and text, and nothing executable', async () => {
|
||||
// The whole argument for not building this on `XmlSpawner2.XmlDialog`, which
|
||||
// implements exactly this vocabulary and one field more: an `Action` string
|
||||
// that runs commands. What crosses here is what an oracle SAYS.
|
||||
const result = await byId('uo.npc.place').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'n'.repeat(40),
|
||||
params: {
|
||||
place: 'Felucca/Britain',
|
||||
name: 'Marisa',
|
||||
greeting: 'You have questions.',
|
||||
lines: 'fire, flame = It burns beneath the keep.',
|
||||
sex: 'female',
|
||||
},
|
||||
verify: false,
|
||||
})
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual(calls.spawn[0].lines, [
|
||||
{ keywords: 'fire,flame', text: 'It burns beneath the keep.' },
|
||||
])
|
||||
assert.equal(calls.spawn[0].sex, 'female')
|
||||
for (const key of Object.keys(calls.spawn[0])) {
|
||||
assert.notEqual(key, 'action', 'nothing executable may cross to the shard')
|
||||
}
|
||||
})
|
||||
|
||||
test('a gate crosses as a DURATION, and names both ends as points', async () => {
|
||||
const result = await byId('uo.gate.open').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'g'.repeat(40),
|
||||
params: { place: 'Felucca/Britain', destination: 'Felucca/Yew', durationMinutes: 120 },
|
||||
verify: false,
|
||||
})
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
const sent = calls.spawn[0]
|
||||
// A duration, never an absolute time: an absolute deadline computed here and
|
||||
// honoured there is measured against two clocks, and a shard ten minutes fast
|
||||
// would collect the gate the instant it opened.
|
||||
assert.equal(sent.holdMs, 120 * 60_000)
|
||||
assert.equal(sent.untilMs, undefined, 'an absolute deadline must not cross')
|
||||
assert.deepEqual(sent.target, { map: 'Felucca', x: 542, y: 982 })
|
||||
|
||||
const tooLong = await byId('uo.gate.open').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'g'.repeat(40),
|
||||
params: {
|
||||
place: 'Felucca/Britain',
|
||||
destination: 'Felucca/Yew',
|
||||
durationMinutes: actions.MAX_GATE_MINUTES + 1,
|
||||
},
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(tooLong.ok, false)
|
||||
assert.equal(tooLong.retry, false)
|
||||
})
|
||||
|
||||
test('teardown reports a refused serial as failed, and a killed creature as done', async () => {
|
||||
const resources = [
|
||||
{ kind: 'world', ref: '0x40000000', payload: {} },
|
||||
{ kind: 'world', ref: '0x40000001', payload: {} },
|
||||
]
|
||||
|
||||
// `gone` is not a failure. A creature a player killed is the point of having
|
||||
// spawned it, and §L already says "gone, and that is fine" is a successful
|
||||
// revert — so a run does not end `incomplete` because its event worked.
|
||||
uoLinkClient.despawnWorld = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { removed: ['0x40000000'], gone: ['0x40000001'], refused: [] },
|
||||
})
|
||||
assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), { ok: true })
|
||||
|
||||
// `refused` IS. The shard denies this run ever owned it, so nothing will ever
|
||||
// delete it through this path: the row must land unresolved with a reason
|
||||
// rather than be quietly marked reverted.
|
||||
uoLinkClient.despawnWorld = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { removed: ['0x40000000'], gone: [], refused: ['0x40000001'] },
|
||||
})
|
||||
assert.deepEqual(await actions.revertOwned({ runId: 7, resources }), {
|
||||
ok: true,
|
||||
failed: ['0x40000001'],
|
||||
})
|
||||
|
||||
// An unreachable shard has not said anything about anything.
|
||||
uoLinkClient.despawnWorld = async () => ({ ok: false, status: 503, data: null })
|
||||
assert.equal((await actions.revertOwned({ runId: 7, resources })).ok, false)
|
||||
})
|
||||
|
||||
test('the despawn carries NO idempotency key, whatever core hands revert()', async () => {
|
||||
// The Phase 16 acceptance walk's critical finding, as the test that would have
|
||||
// caught it. `revertOwned` used to forward core's `idempotencyKey` onto the
|
||||
// despawn — and core's key is the STEP's, the one `placeOwned` spawned under.
|
||||
// The shard's at-most-once store is keyed on the key ALONE
|
||||
// (`BridgeIdempotency.Intercept` does `_byKey.TryGetValue(key, …)`, with no
|
||||
// reference to which command carried it), so the despawn was taken for a repeat
|
||||
// and answered with the SPAWN's stored reply. `OnDespawn` never ran. Core read
|
||||
// `ok` with no `refused` and marked every row `reverted` while the shard still
|
||||
// held every object — teardown of all five world verbs was a no-op that
|
||||
// reported success.
|
||||
//
|
||||
// Every other stub in this file ignores the body, which is why the suite was
|
||||
// green throughout. This one asserts on the body, and it asserts ABSENCE — the
|
||||
// property that matters — rather than pinning the rest of the shape.
|
||||
let sent = null
|
||||
uoLinkClient.despawnWorld = async (body) => {
|
||||
sent = body
|
||||
return { ok: true, status: 200, data: { removed: ['0x40000000'], gone: [], refused: [] } }
|
||||
}
|
||||
|
||||
await actions.revertOwned({
|
||||
runId: 7,
|
||||
resources: [{ kind: 'world', ref: '0x40000000', payload: {} }],
|
||||
// Core passes this on every call (MODULE_API.md), and it must not reach the wire.
|
||||
idempotencyKey: 'the-step-key-the-spawn-went-out-under',
|
||||
})
|
||||
|
||||
assert.ok(sent, 'despawnWorld was not called')
|
||||
assert.equal(
|
||||
Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'),
|
||||
false,
|
||||
'the despawn must not carry an idempotency key — the shard would replay the spawn',
|
||||
)
|
||||
|
||||
// MODULE_API.md: revert is sometimes called with the key and an EMPTY list,
|
||||
// meaning "a command went out under this key and core never learned what it
|
||||
// did". No serials is the shard's own idiom for "everything this run owns",
|
||||
// which is the correct sweep for exactly that case.
|
||||
sent = null
|
||||
await actions.revertOwned({ runId: 7, resources: [], idempotencyKey: 'lost-dispatch' })
|
||||
assert.deepEqual(sent.serials, [])
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'), false)
|
||||
})
|
||||
|
||||
test('reconcile ASKS the shard, because these resources survive a restart', async () => {
|
||||
// The one property that separates this from every other resource in the file.
|
||||
// A crier line lives in shard memory, so a changed `bootId` IS proof it is
|
||||
// gone; a spawned creature is in the world SAVE and survives the restart the
|
||||
// boot stamp would report it lost by.
|
||||
const resources = [
|
||||
{ kind: 'world', ref: '0x40000000', payload: {} },
|
||||
{ kind: 'world', ref: '0x40000001', payload: {} },
|
||||
]
|
||||
|
||||
assert.deepEqual(await actions.reconcileOwned({ runId: 7, resources }), {
|
||||
ok: true,
|
||||
inForce: ['0x40000000'],
|
||||
})
|
||||
assert.deepEqual(calls.owned, [{ runId: '7' }])
|
||||
|
||||
// "I could not ask" must never be read as "it is gone": an unanswered group
|
||||
// leaves every row alone rather than orphaning the lot.
|
||||
uoLinkClient.ownedWorld = async () => ({ ok: false, status: 504, data: null })
|
||||
assert.equal((await actions.reconcileOwned({ runId: 7, resources })).ok, false)
|
||||
})
|
||||
|
||||
test('every world verb declares the same undo contract', async () => {
|
||||
// Five declarations sharing one spread object, asserted rather than assumed:
|
||||
// a verb that quietly lost its `reconcile` would leave its rows unanswered for
|
||||
// the life of the run, and nothing would report it — which is exactly the hole
|
||||
// Phase 11b found in `core.lease`.
|
||||
for (const id of ['uo.creature.spawn', 'uo.boss.spawn', 'uo.npc.place', 'uo.gate.open', 'uo.decor.place']) {
|
||||
const action = byId(id)
|
||||
assert.equal(action.risk, 'change', `${id} must be a world change`)
|
||||
assert.equal(action.reversible, 'ledger', `${id} owns what it made`)
|
||||
assert.equal(typeof action.revert, 'function', `${id} has no undo`)
|
||||
assert.equal(typeof action.reconcile, 'function', `${id} can never be asked what it still holds`)
|
||||
assert.ok(action.budgetMs > 12000, `${id} must outlast the client's own timeout`)
|
||||
assert.equal(typeof action.cost, 'function', `${id} is capped by nothing`)
|
||||
}
|
||||
})
|
||||
|
||||
test('decoration carries the graphic, and a type this shard never decorates with is refused', async () => {
|
||||
const decor = byId('uo.decor.place')
|
||||
|
||||
const ok = await decor.perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'd'.repeat(40),
|
||||
params: { place: 'Felucca/Britain', item: 'Brazier', count: 2 },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(ok.ok, true)
|
||||
assert.equal(ok.resources.length, 2)
|
||||
|
||||
// **The item id crosses, and it has to.** Measured on ServUO 57.4, `Static`
|
||||
// accounts for 5031 decoration placements under 1992 DIFFERENT graphics,
|
||||
// because for that class the graphic is the identity: a bare `new Static()`
|
||||
// is never the paving stone the author picked. 131 of 313 types carry more
|
||||
// than one id.
|
||||
assert.equal(calls.spawn[0].type, 'Brazier')
|
||||
assert.equal(calls.spawn[0].itemId, 0x0e31)
|
||||
|
||||
// Resolving through the atlas is also the boundary: the verb places what this
|
||||
// shard's own decoration files name, which is tighter than "any item that is
|
||||
// not a container" and is the rule the decision actually took.
|
||||
const unknown = await decor.perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'd'.repeat(40),
|
||||
params: { place: 'Felucca/Britain', item: 'BlackrockCrate', count: 1 },
|
||||
verify: false,
|
||||
})
|
||||
assert.equal(unknown.ok, false)
|
||||
assert.equal(unknown.retry, false)
|
||||
assert.match(unknown.error, /never mention/)
|
||||
assert.equal(calls.spawn.length, 1)
|
||||
})
|
||||
349
server/test/uoEventBorrowed.test.js
Normal file
349
server/test/uoEventBorrowed.test.js
Normal file
@@ -0,0 +1,349 @@
|
||||
// module-uo's half of protocol 7 part b (EVENTS_PLAN.md Phase 12b).
|
||||
//
|
||||
// What an event BORROWS — five targeted leases over two planes — and the two
|
||||
// one-shots that are neither borrowed nor owned.
|
||||
//
|
||||
// The tests below are the places where the obvious implementation is subtly the
|
||||
// wrong one and nothing would fail if it were written the other way:
|
||||
//
|
||||
// • every callable of a targeted lease must PASS THE TARGET ON. A read that
|
||||
// dropped it would answer about the wrong spawner, and a restore that
|
||||
// dropped it would write a baseline onto one
|
||||
// • a target the shard can no longer read is a REFUSAL at apply time, never a
|
||||
// value: taking the lease anyway records a fictional baseline and later
|
||||
// writes it onto whatever next holds that id
|
||||
// • a target that vanished mid-run is a SUCCESSFUL restore, not a failure —
|
||||
// there is nothing to give back, and reporting it failed leaves a ledger row
|
||||
// unresolved for ever over an object that is gone
|
||||
// • `inForce()` reads the frame's `holds`, which is the only thing that can
|
||||
// answer for a targeted key: there is no list of spawners to walk
|
||||
// • a grant that reached NOBODY is a success, because an event nobody attended
|
||||
// still happened — while a run the shard was never told to count is a 404
|
||||
// • a non-stackable granted in quantity is refused at BOTH ends
|
||||
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
require('./_setup')
|
||||
const actions = require('../config/uoEventActions')
|
||||
|
||||
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
||||
const leaseById = (id) => actions.LEASES.find((l) => l.id === id)
|
||||
|
||||
const STUBBED = ['getLeases', 'applyLease', 'releaseLease', 'grantItem', 'saveWorld']
|
||||
|
||||
let calls
|
||||
let frame
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = { leases: [], apply: [], release: [], grant: [], save: [] }
|
||||
frame = {
|
||||
leases: [{ key: 'Spawner.MaxCount', kind: 'property', current: '3', held: false }],
|
||||
holds: [],
|
||||
}
|
||||
for (const name of STUBBED) saved[name] = uoLinkClient[name]
|
||||
saved.listSpawners = shardAtlas.listSpawners
|
||||
|
||||
uoLinkClient.getLeases = async (q) => {
|
||||
calls.leases.push(q)
|
||||
return { ok: true, status: 200, data: frame }
|
||||
}
|
||||
uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } }
|
||||
uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } }
|
||||
uoLinkClient.grantItem = async (b) => {
|
||||
calls.grant.push(b)
|
||||
return { ok: true, status: 200, data: { granted: 2, missed: [] } }
|
||||
}
|
||||
uoLinkClient.saveWorld = async (b) => { calls.save.push(b); return { ok: true, status: 200, data: {} } }
|
||||
shardAtlas.listSpawners = async (opts) => {
|
||||
calls.spawners = opts
|
||||
return [
|
||||
{ uniqueId: 'uid-1', name: 'fel orc fort', facet: 'Felucca', region: 'Britain', maxCount: 9 },
|
||||
{ uniqueId: 'uid-2', name: null, facet: 'Trammel', region: null, landmark: null, maxCount: 1 },
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of STUBBED) uoLinkClient[name] = saved[name]
|
||||
shardAtlas.listSpawners = saved.listSpawners
|
||||
})
|
||||
|
||||
// ── The targeted leases ────────────────────────────────────────────────────
|
||||
|
||||
test('every callable carries the target through to the shard', async () => {
|
||||
// The one thing that cannot be got wrong quietly. Core composes the ledger ref
|
||||
// as `<lease id>#<target>` and hands the target back on every call; a callable
|
||||
// that ignored it would read, apply to and restore whichever spawner the shard
|
||||
// happened to answer about, and nothing here or there would report an error.
|
||||
const lease = leaseById('uo.spawner.maxcount')
|
||||
const target = '003f11b8-9bfa-4587-991e-ca263004efe6'
|
||||
|
||||
const read = await lease.read({ target })
|
||||
assert.deepEqual(read, { ok: true, value: '3' })
|
||||
assert.deepEqual(calls.leases[0], { key: 'Spawner.MaxCount', target })
|
||||
|
||||
await lease.apply('30', new Date(Date.now() + 600_000), { target })
|
||||
assert.equal(calls.apply[0].key, 'Spawner.MaxCount')
|
||||
assert.equal(calls.apply[0].target, target)
|
||||
// A DURATION, not the deadline — 11b's rule, unchanged by targeting. A shard
|
||||
// whose clock runs fast would restore an absolute deadline the instant it
|
||||
// took it.
|
||||
assert.ok(calls.apply[0].holdMs > 0 && calls.apply[0].holdMs <= 600_000)
|
||||
|
||||
await lease.restore('3', { expected: '30', target })
|
||||
assert.deepEqual(calls.release[0], {
|
||||
key: 'Spawner.MaxCount',
|
||||
target,
|
||||
expected: '30',
|
||||
baseline: '3',
|
||||
})
|
||||
})
|
||||
|
||||
test('a target the shard cannot read refuses the lease rather than defaulting', async () => {
|
||||
// The failure this guards is silent and permanent: a lease taken over a
|
||||
// spawner that is not there records whatever came back as the baseline, and
|
||||
// teardown then WRITES that baseline onto whatever next holds the id.
|
||||
frame.leases = [{ key: 'Spawner.MaxCount', unreadable: "nothing on this shard has serial 0x99" }]
|
||||
const refused = await leaseById('uo.spawner.maxcount').read({ target: '0x99' })
|
||||
assert.equal(refused.ok, false)
|
||||
assert.match(refused.error, /nothing on this shard has serial/)
|
||||
|
||||
// A row with neither a value nor a reason is refused too. The shard should
|
||||
// always send one of them, and "it sent neither" must not read as zero.
|
||||
frame.leases = [{ key: 'Spawner.MaxCount' }]
|
||||
const empty = await leaseById('uo.spawner.maxcount').read({ target: 'uid-1' })
|
||||
assert.equal(empty.ok, false)
|
||||
assert.match(empty.error, /could not read/)
|
||||
})
|
||||
|
||||
test('a target that vanished mid-run is a successful restore, not a failure', async () => {
|
||||
// 12a's `gone` in the lease plane's vocabulary. Somebody deleted the spawner
|
||||
// while the run held it: there is nothing to give back and nothing is owed.
|
||||
// Reported as a failure it would sit in the ledger unresolved for ever, over
|
||||
// an object that no longer exists — and every sweep would try again.
|
||||
uoLinkClient.releaseLease = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { kind: 'lease.ok', released: true, targetGone: true, reason: 'that object has been deleted' },
|
||||
})
|
||||
const done = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' })
|
||||
assert.deepEqual(done, { ok: true })
|
||||
})
|
||||
|
||||
test('drift is still drift, and is still not an error', async () => {
|
||||
// Unchanged from 11b and asserted again because targeting rewrote the whole
|
||||
// callable: core records drift as a distinct SUCCESSFUL outcome, so an error
|
||||
// here would put the row on the retry ladder and eventually report the lease
|
||||
// as vanished rather than as somebody having moved it.
|
||||
uoLinkClient.releaseLease = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { kind: 'lease.drifted', current: '12' },
|
||||
})
|
||||
const drifted = await leaseById('uo.spawner.maxcount').restore('3', { expected: '30', target: 'uid-1' })
|
||||
assert.deepEqual(drifted, { ok: false, drifted: true, current: '12' })
|
||||
})
|
||||
|
||||
test('inForce reads the holds list, which is the only thing that can answer', async () => {
|
||||
// A catalog walk can enumerate the KEYS but never the holds on a targeted one
|
||||
// — there is no list of spawners to walk — so the frame carries every hold the
|
||||
// shard has, and this is what reads it.
|
||||
const lease = leaseById('uo.spawner.maxcount')
|
||||
|
||||
assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: false })
|
||||
|
||||
frame.holds = [{ key: 'Spawner.MaxCount', target: 'uid-1', runId: '7' }]
|
||||
assert.deepEqual(await lease.inForce({ target: 'uid-1' }), { ok: true, held: true })
|
||||
// ...and it is the hold on THIS target, not any hold on the key. A run holding
|
||||
// one spawner must not make every other spawner look leased.
|
||||
assert.deepEqual(await lease.inForce({ target: 'uid-2' }), { ok: true, held: false })
|
||||
})
|
||||
|
||||
test('a shard that cannot answer is never read as "the lease is gone"', async () => {
|
||||
// Core's posture everywhere: "I could not ask" must not be recorded as "it is
|
||||
// gone", because the second orphans the row and stops teardown ever trying.
|
||||
uoLinkClient.getLeases = async () => ({ ok: false, status: 503, data: null })
|
||||
const answer = await leaseById('uo.spawner.maxcount').inForce({ target: 'uid-1' })
|
||||
assert.equal(answer.ok, false)
|
||||
})
|
||||
|
||||
test('the seasonal lease is a three-value enum over eight events', () => {
|
||||
// §G called `SeasonalEventSystem.GetEntry(type).Status` "a nine-value enum" and
|
||||
// had it backwards: `EventStatus` has three values, `EventType` has nine
|
||||
// entries — and one of those nine is excluded, so it is eight.
|
||||
const lease = leaseById('uo.seasonal.status')
|
||||
assert.equal(lease.type, 'string')
|
||||
assert.deepEqual(lease.values, ['Inactive', 'Active', 'Seasonal'])
|
||||
assert.equal(actions.SEASONAL_EVENTS.length, 8)
|
||||
// TreasuresOfTokuno reads its own era rather than this status, so leasing it
|
||||
// would apply cleanly and change nothing — §N10's "a capability that lies",
|
||||
// and the one instance no runtime probe can catch.
|
||||
assert.ok(!actions.SEASONAL_EVENTS.includes('TreasuresOfTokuno'))
|
||||
})
|
||||
|
||||
test('every targeted lease bounds what it can hold', () => {
|
||||
// §F requires a range on the numeric types because, unlike a cap, a bad lease
|
||||
// value is in force the moment it is applied. Restated over the five because
|
||||
// they are built by a shared factory: one missing bound would be missing in a
|
||||
// way no single declaration shows.
|
||||
for (const lease of actions.LEASES) {
|
||||
if (lease.id === 'uo.playercaps.skillcap') continue
|
||||
assert.ok(lease.maxDurationMs > 0, `${lease.id} has no duration bound`)
|
||||
if (lease.type === 'int' || lease.type === 'float') {
|
||||
assert.ok(Number.isFinite(lease.min) && Number.isFinite(lease.max), `${lease.id} has no range`)
|
||||
assert.ok(lease.min <= lease.max, `${lease.id} has min above max`)
|
||||
}
|
||||
if (lease.type === 'string') {
|
||||
assert.ok(Array.isArray(lease.values) && lease.values.length, `${lease.id} has no value set`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── The spawner source ─────────────────────────────────────────────────────
|
||||
|
||||
test('the spawner source searches, and says so', async () => {
|
||||
// The first source with more entries than a dropdown holds: 6,707 spawn points
|
||||
// against MAX_OPTIONS' 2,000. A flat list would drop two thirds of the world
|
||||
// and say nothing about which two thirds.
|
||||
const source = actions.OPTION_SOURCES.find((s) => s.id === 'uo.options.spawners')
|
||||
assert.equal(source.searchable, true)
|
||||
|
||||
const rows = await source.resolve({ q: 'orc' })
|
||||
assert.equal(calls.spawners.q, 'orc')
|
||||
assert.equal(calls.spawners.limit, actions.SPAWNER_OPTIONS)
|
||||
|
||||
// The value is the UniqueId, because it is the only name for one particular
|
||||
// spawner that exists off the shard.
|
||||
assert.deepEqual(rows[0], { value: 'uid-1', label: 'fel orc fort', group: 'Britain' })
|
||||
// A nameless spawner still answers, labelled by its id. It is still a spawner
|
||||
// somebody may need to turn down, and dropping it would be a dropdown quietly
|
||||
// missing rows again.
|
||||
assert.deepEqual(rows[1], { value: 'uid-2', label: 'uid-2', group: 'Trammel' })
|
||||
})
|
||||
|
||||
// ── The one-shots ──────────────────────────────────────────────────────────
|
||||
|
||||
test('a grant sends a run and never a recipient list', async () => {
|
||||
// The shard has held this run's participation ledger since it opened, keyed by
|
||||
// the same serials core stores as `member_key`. Sending a list would put it on
|
||||
// the wire twice with a window in which the two disagree — and would have
|
||||
// needed a core surface handing a module core's own participants.
|
||||
const out = await byId('uo.item.grant').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'k',
|
||||
params: { item: 'gold', amount: 500, where: 'bank' },
|
||||
})
|
||||
assert.equal(out.ok, true)
|
||||
assert.deepEqual(calls.grant[0], {
|
||||
runId: 7,
|
||||
item: 'gold',
|
||||
amount: 500,
|
||||
hue: undefined,
|
||||
name: undefined,
|
||||
where: 'bank',
|
||||
idempotencyKey: 'k',
|
||||
})
|
||||
assert.equal(out.detail.granted, 2)
|
||||
})
|
||||
|
||||
test('a grant that reached nobody is a success', async () => {
|
||||
// An event nobody attended still happened. Reported as a failure the run would
|
||||
// retry against a ledger that will be just as empty next time, and pause. The
|
||||
// shard draws the distinction that matters: a run it was never told to count
|
||||
// is a 404, which fails below.
|
||||
uoLinkClient.grantItem = async () => ({ ok: true, status: 200, data: { granted: 0, missed: [] } })
|
||||
const out = await byId('uo.item.grant').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'k',
|
||||
params: { item: 'gold', amount: 1 },
|
||||
})
|
||||
assert.equal(out.ok, true)
|
||||
assert.equal(out.detail.granted, 0)
|
||||
|
||||
uoLinkClient.grantItem = async () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
data: { reason: 'run 7 has no participation ledger open on this shard' },
|
||||
})
|
||||
const missing = await byId('uo.item.grant').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'k',
|
||||
params: { item: 'gold', amount: 1 },
|
||||
})
|
||||
assert.equal(missing.ok, false)
|
||||
// 404 is permanent: the ledger will not appear because we asked again.
|
||||
assert.equal(missing.retry, false)
|
||||
})
|
||||
|
||||
test('a non-stackable granted in quantity is refused before the wire', async () => {
|
||||
// Five cloaks would be five items — five chances to overflow a backpack
|
||||
// halfway through with no way to say which half landed. Refused here so the
|
||||
// author sees it on the form, and refused again on the shard because this copy
|
||||
// of the allowlist is the one that can be wrong.
|
||||
const out = await byId('uo.item.grant').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'k',
|
||||
params: { item: 'cloak', amount: 3 },
|
||||
})
|
||||
assert.equal(out.ok, false)
|
||||
assert.equal(out.retry, false)
|
||||
assert.match(out.error, /does not stack/)
|
||||
assert.equal(calls.grant.length, 0)
|
||||
|
||||
const unknown = await byId('uo.item.grant').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'k',
|
||||
params: { item: 'castle', amount: 1 },
|
||||
})
|
||||
assert.equal(unknown.ok, false)
|
||||
assert.equal(unknown.retry, false)
|
||||
assert.equal(calls.grant.length, 0)
|
||||
})
|
||||
|
||||
test('a grant is retryable, and protocol 6 is the reason', async () => {
|
||||
// §G called a grant un-retryable because a lost acknowledgement and a grant
|
||||
// that never applied were the same event — the argument that made
|
||||
// `uo.broadcast` answer `retry: false` in Phase 9. An idempotency key closes
|
||||
// it: a repeat is answered by the original reply, so a retried grant cannot be
|
||||
// one winner receiving two.
|
||||
uoLinkClient.grantItem = async () => ({ ok: false, status: 503, data: null })
|
||||
const out = await byId('uo.item.grant').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'k',
|
||||
params: { item: 'gold', amount: 1 },
|
||||
})
|
||||
assert.equal(out.ok, false)
|
||||
assert.notEqual(out.retry, false)
|
||||
// And the action declares itself irreversible, which is the honest class: the
|
||||
// world is altered and cannot be put back.
|
||||
assert.equal(byId('uo.item.grant').risk, 'irreversible')
|
||||
assert.equal(byId('uo.item.grant').reversible, 'none')
|
||||
})
|
||||
|
||||
test('a save refused for coming too soon is retried, not abandoned', async () => {
|
||||
// 429 is the shard's rate limit and is the one refusal on this plane that
|
||||
// waiting fixes. It is deliberately not in PERMANENT_STATUSES, so a phase
|
||||
// boundary is retried rather than dropped.
|
||||
assert.ok(!actions.PERMANENT_STATUSES.has(429))
|
||||
uoLinkClient.saveWorld = async () => ({
|
||||
ok: false,
|
||||
status: 429,
|
||||
data: { reason: 'this shard saves at most every 300 seconds, and the last save was 12 seconds ago' },
|
||||
})
|
||||
const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' })
|
||||
assert.equal(out.ok, false)
|
||||
assert.notEqual(out.retry, false)
|
||||
})
|
||||
|
||||
test('a save reports only that it started', async () => {
|
||||
// What actually happened rides `world.save.before`/`after` on the event stream.
|
||||
// Asserting anything more here would be asserting something the reply does not
|
||||
// know.
|
||||
const out = await byId('uo.world.save').perform({ idempotencyKey: 'k' })
|
||||
assert.deepEqual(out, { ok: true, detail: { started: true } })
|
||||
assert.deepEqual(calls.save[0], { idempotencyKey: 'k' })
|
||||
})
|
||||
382
server/test/uoEventLeaseParticipation.test.js
Normal file
382
server/test/uoEventLeaseParticipation.test.js
Normal file
@@ -0,0 +1,382 @@
|
||||
// module-uo's half of protocol 6 part b (EVENTS_PLAN.md Phase 11b).
|
||||
//
|
||||
// One lease and two participation verbs. What is worth asserting here is not that
|
||||
// the calls happen — a rig proves that better — but the handful of places where
|
||||
// the obvious implementation is subtly the wrong one, and where nothing would fail
|
||||
// if it were written the other way:
|
||||
//
|
||||
// • a lease's `restore()` must turn `lease.drifted` into `{ drifted: true }`
|
||||
// rather than an error, because core records drift as a distinct SUCCESSFUL
|
||||
// outcome and an error would put the row on the retry ladder instead
|
||||
// • `inForce()` must not be a comparison against `read()` — a changed value is
|
||||
// drift, which teardown reports, and orphaning the row first destroys it
|
||||
// • `apply()` must send a DURATION, not the deadline, or a shard whose clock is
|
||||
// fast restores the lease the instant it takes it
|
||||
// • `uo.participation.open` must NOT reconcile by boot stamp, which every other
|
||||
// resource in this module does — the ledger is persisted in the world save
|
||||
// precisely so that it survives the restart the stamp would report it lost by
|
||||
// • a `userId` is a foreign key and a character serial is not, so an unresolved
|
||||
// one is undefined rather than coerced
|
||||
|
||||
const { test, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const uoLinkClient = require('../utils/uoLinkClient')
|
||||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
require('./_setup')
|
||||
const actions = require('../config/uoEventActions')
|
||||
|
||||
const byId = (id) => actions.ACTIONS.find((a) => a.id === id)
|
||||
const lease = () => actions.LEASES.find((l) => l.id === 'uo.playercaps.skillcap')
|
||||
|
||||
const STUBBED = [
|
||||
'getLeases',
|
||||
'applyLease',
|
||||
'releaseLease',
|
||||
'openParticipation',
|
||||
'snapshotParticipation',
|
||||
'closeParticipation',
|
||||
]
|
||||
|
||||
let calls
|
||||
const saved = {}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = { apply: [], release: [], open: [], snapshot: [], close: [] }
|
||||
for (const name of STUBBED) saved[name] = uoLinkClient[name]
|
||||
saved.listLandmarks = shardAtlas.listLandmarks
|
||||
|
||||
uoLinkClient.getLeases = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] },
|
||||
})
|
||||
uoLinkClient.applyLease = async (b) => { calls.apply.push(b); return { ok: true, status: 200, data: {} } }
|
||||
uoLinkClient.releaseLease = async (b) => { calls.release.push(b); return { ok: true, status: 200, data: {} } }
|
||||
uoLinkClient.openParticipation = async (b) => { calls.open.push(b); return { ok: true, status: 200, data: {} } }
|
||||
uoLinkClient.snapshotParticipation = async (b) => {
|
||||
calls.snapshot.push(b)
|
||||
return { ok: true, status: 200, data: { participants: [] } }
|
||||
}
|
||||
uoLinkClient.closeParticipation = async (b) => { calls.close.push(b); return { ok: true, status: 200, data: {} } }
|
||||
|
||||
shardAtlas.listLandmarks = async () => [{ facet: 'Felucca', name: 'Britain', x: 1496, y: 1628, z: 10 }]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of STUBBED) uoLinkClient[name] = saved[name]
|
||||
shardAtlas.listLandmarks = saved.listLandmarks
|
||||
})
|
||||
|
||||
// ── The lease ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('the lease satisfies the shape core validates it with', () => {
|
||||
const l = lease()
|
||||
assert.ok(l.id.startsWith('uo.'), 'a lease is namespaced to its module')
|
||||
assert.ok(l.label && l.description)
|
||||
assert.equal(l.type, 'float')
|
||||
// Required for the numeric types, and unlike a cap a bad lease value is in
|
||||
// force the moment it is applied.
|
||||
assert.ok(Number.isFinite(l.min) && Number.isFinite(l.max) && l.min < l.max)
|
||||
assert.ok(Number.isInteger(l.maxDurationMs) && l.maxDurationMs > 0)
|
||||
for (const fn of ['read', 'apply', 'restore', 'inForce']) {
|
||||
assert.equal(typeof l[fn], 'function', `a lease needs ${fn}()`)
|
||||
}
|
||||
})
|
||||
|
||||
test('apply sends a DURATION, because a deadline is measured against two clocks', async () => {
|
||||
const until = new Date(Date.now() + 90 * 60_000)
|
||||
const answer = await lease().apply(1200, until)
|
||||
|
||||
assert.equal(answer.ok, true)
|
||||
const sent = calls.apply[0]
|
||||
// The number the shard arms its timer off. Computed here from the deadline, so
|
||||
// a shard running ten minutes fast holds the lease for ninety minutes of its
|
||||
// own time rather than restoring it the instant it takes it.
|
||||
assert.ok(Math.abs(sent.holdMs - 90 * 60_000) < 2000, `holdMs was ${sent.holdMs}`)
|
||||
// And the absolute time still rides along, for a console that wants to say when
|
||||
// the hold ends in terms the operator's own clock agrees with.
|
||||
assert.equal(sent.untilMs, until.getTime())
|
||||
// The action hands the value on unchanged; `uoLinkClient.applyLease` is what
|
||||
// renders it as TEXT, which is the wire's contract for every lease type: `1200`
|
||||
// and `1200.0` are one number to a JSON parser and two different strings to a
|
||||
// compare-and-set.
|
||||
assert.equal(sent.value, 1200)
|
||||
})
|
||||
|
||||
test('a deadline that has already passed is refused rather than sent as a negative hold', async () => {
|
||||
const answer = await lease().apply(1200, new Date(Date.now() - 60_000))
|
||||
assert.equal(answer.ok, false)
|
||||
assert.match(answer.error, /already passed/)
|
||||
assert.equal(calls.apply.length, 0)
|
||||
})
|
||||
|
||||
test('drift comes back as drifted, not as an error', async () => {
|
||||
// The distinction core acts on. `cleanup.js` records `drifted` as its own
|
||||
// outcome — the module did exactly what it was asked and found somebody else's
|
||||
// value in place — while an error would put the row on the retry ladder and
|
||||
// eventually spend its attempts on a situation only a human can resolve.
|
||||
uoLinkClient.releaseLease = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { kind: 'lease.drifted', key: 'PlayerCaps.SkillCap', current: '1300' },
|
||||
})
|
||||
|
||||
const answer = await lease().restore('1000', { expected: '1200' })
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.drifted, true)
|
||||
assert.equal(answer.current, '1300')
|
||||
assert.equal(answer.error, undefined)
|
||||
})
|
||||
|
||||
test('restore sends both what it applied and what to put back', async () => {
|
||||
await lease().restore('1000', { expected: '1200' })
|
||||
// Core's `restore(baseline, { expected })` carries no key of its own -- teardown
|
||||
// is core's own sweep rather than a step dispatch -- so neither does this.
|
||||
assert.deepEqual(calls.release[0], {
|
||||
key: 'PlayerCaps.SkillCap',
|
||||
expected: '1200',
|
||||
baseline: '1000',
|
||||
})
|
||||
})
|
||||
|
||||
test('inForce asks whether the shard still HOLDS it, not whether the value still matches', async () => {
|
||||
// The reason this callable exists at all. A shard reporting a value that is not
|
||||
// what the run applied is reporting DRIFT, which teardown delivers through
|
||||
// `restore()` so the ledger row lands `drifted` with the current value beside
|
||||
// it. Answering "not in force" here would orphan the row first and tell the
|
||||
// operator the lease vanished rather than that somebody moved it.
|
||||
uoLinkClient.getLeases = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1300', held: true }] },
|
||||
})
|
||||
assert.deepEqual(await lease().inForce(), { ok: true, held: true })
|
||||
|
||||
// And a shard that restarted: a config lease is memory-only there by design, so
|
||||
// the value is back at baseline AND the record is gone. This is the case core
|
||||
// could not see before this phase.
|
||||
uoLinkClient.getLeases = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { leases: [{ key: 'PlayerCaps.SkillCap', current: '1000', held: false }] },
|
||||
})
|
||||
assert.deepEqual(await lease().inForce(), { ok: true, held: false })
|
||||
})
|
||||
|
||||
test('a shard that cannot answer leaves the ledger alone', async () => {
|
||||
uoLinkClient.getLeases = async () => ({ ok: false, status: 503, error: 'shard not connected' })
|
||||
const answer = await lease().inForce()
|
||||
assert.equal(answer.ok, false)
|
||||
// `ok: false` is what core reads as "I could not ask", and it keeps believing
|
||||
// its own ledger. Never `held: false`, which would orphan a live lease the
|
||||
// first time a sidecar was slow.
|
||||
assert.equal(answer.held, undefined)
|
||||
assert.equal((await lease().read()).ok, false)
|
||||
})
|
||||
|
||||
// ── Participation ──────────────────────────────────────────────────────────
|
||||
|
||||
test('open resolves a named place to the point the shard counts around', async () => {
|
||||
const answer = await byId('uo.participation.open').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Britain', radius: 40, durationMinutes: 120 },
|
||||
})
|
||||
|
||||
assert.equal(answer.ok, true)
|
||||
assert.deepEqual(calls.open[0], {
|
||||
runId: 42,
|
||||
map: 'Felucca',
|
||||
x: 1496,
|
||||
y: 1628,
|
||||
radius: 40,
|
||||
holdMs: 7_200_000,
|
||||
idempotencyKey: 'k-1',
|
||||
})
|
||||
assert.deepEqual(answer.resources, [
|
||||
{ kind: 'participation', ref: '42', payload: { runId: 42, place: 'Felucca/Britain', radius: 40 } },
|
||||
])
|
||||
})
|
||||
|
||||
test('a place the atlas does not know is a refusal an author can read, not a retry', async () => {
|
||||
const answer = await byId('uo.participation.open').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Atlantis', radius: 40 },
|
||||
})
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.retry, false)
|
||||
assert.match(answer.error, /no landmark called "Atlantis"/)
|
||||
assert.equal(calls.open.length, 0)
|
||||
})
|
||||
|
||||
test('an area outside the bound is refused before anything is sent', async () => {
|
||||
for (const radius of [0, -1, actions.MAX_AREA_RADIUS + 1, 1.5]) {
|
||||
const answer = await byId('uo.participation.open').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Britain', radius },
|
||||
})
|
||||
assert.equal(answer.ok, false, String(radius))
|
||||
assert.equal(answer.retry, false, String(radius))
|
||||
}
|
||||
assert.equal(calls.open.length, 0)
|
||||
})
|
||||
|
||||
test('a dry run checks the place and the radius and opens nothing', async () => {
|
||||
const good = await byId('uo.participation.open').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Britain', radius: 40 },
|
||||
verify: true,
|
||||
})
|
||||
assert.deepEqual(good, { ok: true })
|
||||
assert.equal(calls.open.length, 0)
|
||||
|
||||
// And it is a real check rather than an unconditional yes: the failure an
|
||||
// author most wants caught before the night of the event is a place that is not
|
||||
// on this shard's map.
|
||||
const bad = await byId('uo.participation.open').perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Atlantis', radius: 40 },
|
||||
verify: true,
|
||||
})
|
||||
assert.equal(bad.ok, false)
|
||||
})
|
||||
|
||||
test('the ledger is NOT reconciled by boot stamp, unlike everything else here', async () => {
|
||||
// The phase's one genuine divergence from wave 1. `reconcileByBootId` works
|
||||
// because a crier line and a news article live in shard memory, so a changed
|
||||
// `bootId` IS the proof they are gone. A participation ledger is written into
|
||||
// the world save specifically so that it survives a restart — reporting it lost
|
||||
// on a boot change would orphan the one resource the phase persisted.
|
||||
const open = byId('uo.participation.open')
|
||||
assert.notEqual(open.reconcile, actions.reconcileByBootId)
|
||||
// No stamp on the resource either, so nothing downstream can be tempted to
|
||||
// compare one.
|
||||
const answer = await open.perform({
|
||||
runId: 42,
|
||||
idempotencyKey: 'k-1',
|
||||
params: { place: 'Felucca/Britain', radius: 40 },
|
||||
})
|
||||
assert.equal(answer.resources[0].payload.bootId, undefined)
|
||||
|
||||
// It asks instead, and only an explicit 404 takes a row out.
|
||||
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] })
|
||||
|
||||
uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 404, data: {} })
|
||||
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: [] })
|
||||
|
||||
// A shard that is down has not said the ledger is gone.
|
||||
uoLinkClient.snapshotParticipation = async () => ({ ok: false, status: 503, data: {} })
|
||||
assert.deepEqual(await open.reconcile({ resources: [{ ref: '42' }] }), { ok: true, inForce: ['42'] })
|
||||
})
|
||||
|
||||
test('a run the shard has already forgotten is a successful revert', async () => {
|
||||
// §L: "gone, and that is fine". A shard that restarted past its grace window,
|
||||
// or a second teardown attempt, must not leave a row failing forever.
|
||||
uoLinkClient.closeParticipation = async () => ({ ok: false, status: 404, data: {} })
|
||||
assert.deepEqual(await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }), { ok: true })
|
||||
|
||||
uoLinkClient.closeParticipation = async () => ({ ok: false, status: 503, data: {} })
|
||||
assert.deepEqual(
|
||||
await byId('uo.participation.open').revert({ resources: [{ ref: '42' }] }),
|
||||
{ ok: true, failed: ['42'] },
|
||||
)
|
||||
})
|
||||
|
||||
test('collect files the tally as participants, keyed by character serial', async () => {
|
||||
uoLinkClient.snapshotParticipation = async (b) => {
|
||||
calls.snapshot.push(b)
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
participants: [
|
||||
{
|
||||
serial: '0x400150E8',
|
||||
name: 'Darrow',
|
||||
acct: 'seed_001',
|
||||
webId: '17',
|
||||
seconds: 3600,
|
||||
minutes: '60.00',
|
||||
kills: 3,
|
||||
score: '75.0000',
|
||||
firstMs: 1788550182074,
|
||||
},
|
||||
// No account link: the shard reports no webId, and there is nothing to
|
||||
// resolve. Most characters are this one.
|
||||
{
|
||||
serial: '0x1',
|
||||
name: 'Nobody',
|
||||
seconds: 60,
|
||||
minutes: '1.00',
|
||||
kills: 0,
|
||||
score: '1.0000',
|
||||
firstMs: 1788550182074,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||
|
||||
assert.equal(answer.ok, true)
|
||||
assert.equal(calls.snapshot[0].idempotencyKey, 'k-2')
|
||||
assert.deepEqual(answer.participants.map((p) => p.memberKey), ['0x400150E8', '0x1'])
|
||||
// The one field core will not take on trust: it is a foreign key into `users`,
|
||||
// so a serial passed here would either fail the insert or attribute somebody's
|
||||
// attendance to a stranger.
|
||||
assert.equal(answer.participants[0].userId, 17)
|
||||
assert.equal(answer.participants[1].userId, undefined)
|
||||
// The score is opaque to core; the components are carried so a results table
|
||||
// can say why somebody scored what they did.
|
||||
assert.deepEqual(answer.participants[0].meta, {
|
||||
name: 'Darrow', seconds: 3600, minutes: '60.00', kills: 3,
|
||||
})
|
||||
})
|
||||
|
||||
test('a webId that is not a positive integer resolves to nothing at all', () => {
|
||||
for (const bad of [null, undefined, '', 'abc', '0', '-3', '1.5', {}]) {
|
||||
assert.equal(actions.webUserId(bad), undefined, JSON.stringify(bad))
|
||||
}
|
||||
assert.equal(actions.webUserId('17'), 17)
|
||||
assert.equal(actions.webUserId(17), 17)
|
||||
})
|
||||
|
||||
test('a busy shard is retried, because the work is happening', async () => {
|
||||
// 425 is `bridge.busy`: a snapshot of this run is already walking across Core
|
||||
// ticks. Transient by construction, and deliberately not in PERMANENT_STATUSES.
|
||||
uoLinkClient.snapshotParticipation = async () => ({
|
||||
ok: false,
|
||||
status: 425,
|
||||
data: { kind: 'bridge.busy', reason: 'a command under this key is in flight' },
|
||||
})
|
||||
const answer = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||
assert.equal(answer.ok, false)
|
||||
assert.equal(answer.retry, true)
|
||||
|
||||
// Where the event plane simply being switched off is not: 403 is an operator's
|
||||
// deliberate refusal and will still be true in sixty seconds.
|
||||
uoLinkClient.snapshotParticipation = async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
data: { kind: 'participation.error', reason: 'the event plane is disabled on this shard' },
|
||||
})
|
||||
const off = await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2' })
|
||||
assert.equal(off.retry, false)
|
||||
// And the shard's own words reach the run log, because for an event that ran at
|
||||
// four in the morning that log is the only place anyone will learn why.
|
||||
assert.match(off.error, /event plane is disabled/)
|
||||
})
|
||||
|
||||
test('a dry run of collect reads nothing', async () => {
|
||||
assert.deepEqual(
|
||||
await byId('uo.participation.collect').perform({ runId: 42, idempotencyKey: 'k-2', verify: true }),
|
||||
{ ok: true },
|
||||
)
|
||||
assert.equal(calls.snapshot.length, 0)
|
||||
})
|
||||
607
server/utils/assetBridge.js
Normal file
607
server/utils/assetBridge.js
Normal file
@@ -0,0 +1,607 @@
|
||||
// The Asset Bridge client (docs/link/v8.md §5, §6, §8 — protocol 8, phase 3).
|
||||
//
|
||||
// Three walks over the same request/reply path `clilocBridge.js` already uses,
|
||||
// and everything that file says about the envelope holds here unchanged: only
|
||||
// `cut: 'end'` means finished, the cursor must advance, and 425 is the ordinary
|
||||
// answer during an import rather than an error.
|
||||
//
|
||||
// What is different is what each walk is FOR.
|
||||
//
|
||||
// ── `readManifest` — what the shard could serve, without the pixels ────────
|
||||
//
|
||||
// §6's stage 2. Every row is `{ key, sha256, bytes, width, height }`, so the
|
||||
// site can diff against what it already holds and ask for only the keys whose
|
||||
// hash moved. On the ordinary case — a shard restart that changed nothing —
|
||||
// that diff is empty and no pixels cross at all.
|
||||
//
|
||||
// This family pages on the shard's WALL CLOCK, not on bytes. Its rows are about
|
||||
// ninety bytes and the whole catalogue is one page by the byte budget, but
|
||||
// producing that page means decoding hundreds of sprites and the sidecar waits
|
||||
// ten seconds for a reply. So `cut: 'limit'` is the normal page ending here,
|
||||
// where for clilocs it would have signalled something wrong.
|
||||
//
|
||||
// ── `fetchAssets` — the pixels, for keys we chose ─────────────────────────
|
||||
//
|
||||
// Each row carries a base64 PNG. The shard encodes it: `System.Drawing` is
|
||||
// already in its decode path, so PNG costs it no new dependency, and having the
|
||||
// hash cover exactly the bytes we store is what makes the next Update a diff.
|
||||
//
|
||||
// **`catalog` is passed on every fetch and it is not optional in practice.** It
|
||||
// is an id the shard derives from the client files themselves, so handing it back
|
||||
// makes the shard refuse if those files moved since the manifest was read.
|
||||
// Without it an operator patching their client mid-import produces one asset set
|
||||
// stitched out of two, with no error anywhere — the same failure `clilocBridge`
|
||||
// guards against by comparing (size, mtime) across pages.
|
||||
//
|
||||
// ── `resolveBodies` — the atlas's creatures, by class name ────────────────
|
||||
//
|
||||
// §8. The shard constructs each type and reads `Body.BodyID`, which is the only
|
||||
// thing that is correct for a shard's own custom creatures. That runs on its Core
|
||||
// thread, so the batch is small and the shard REFUSES an over-long list rather
|
||||
// than truncating it — hence the chunking here, and hence a chunk size that is a
|
||||
// constant rather than "as many as fit".
|
||||
|
||||
// Required as a namespace, not destructured: a test that stubs the sidecar
|
||||
// replaces these on the module object, and a destructured copy taken at load
|
||||
// time would keep calling the real one.
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const log = require('../core').logger('asset-bridge')
|
||||
|
||||
/** The only family phase 3 serves. §5's key scheme covers statics and land later. */
|
||||
const FAMILY = 'body'
|
||||
|
||||
// Chunk size for the body pass. The shard's own cap defaults to 100 and it
|
||||
// refuses rather than truncates, so this must stay at or under it — a mismatch
|
||||
// here does not degrade, it fails every chunk.
|
||||
const BODY_CHUNK = 100
|
||||
|
||||
// Chunk size for a fetch request. The shard cuts the PAGE by byte budget within
|
||||
// whatever it is handed, so this only bounds how large a single request is; a
|
||||
// chunk of 400 one-kilobyte sprites is a couple of pages.
|
||||
const FETCH_CHUNK = 400
|
||||
|
||||
// Bounds on each walk. None is expected to be reached — the catalogue is under a
|
||||
// thousand rows — and each exists so that a shard answering nonsense costs a
|
||||
// bounded amount of time rather than an unbounded amount of memory.
|
||||
const MAX_PAGES = 200
|
||||
const MAX_ROWS = 100000
|
||||
|
||||
// 425 is flow control, not failure: the shard's asset plane serves one request at
|
||||
// a time because its outbound queue is bounded in lines rather than bytes. During
|
||||
// an import a page coming back busy is expected, so it is retried with a backoff
|
||||
// rather than failing the walk.
|
||||
const BUSY_RETRIES = 6
|
||||
const BUSY_BACKOFF_MS = [200, 400, 800, 1600, 3200, 5000]
|
||||
|
||||
class AssetBridgeError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message)
|
||||
this.name = 'AssetBridgeError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Map a sidecar response onto one of this module's codes.
|
||||
*
|
||||
* Deliberately the same vocabulary `clilocBridge.describeFailure` uses, because
|
||||
* the admin panel reports them side by side and an operator should not have to
|
||||
* learn two names for "you have not switched this on".
|
||||
*
|
||||
* 422 is the one that means something different here: on the cliloc path it is a
|
||||
* file the shard cannot decode, and on this one it is *also* the mid-import guard
|
||||
* firing — the client files moved between the manifest and the fetch.
|
||||
*/
|
||||
function describeFailure(res, what) {
|
||||
const reason = res?.data?.reason || res?.error || `sidecar responded ${res?.status}`
|
||||
|
||||
switch (res?.status) {
|
||||
case 403:
|
||||
return new AssetBridgeError(
|
||||
`The shard is refusing to serve client assets (Bridge.AssetsEnabled is off): ${reason}`,
|
||||
'DISABLED',
|
||||
)
|
||||
case 404:
|
||||
return new AssetBridgeError(`The shard has no ${what}: ${reason}`, 'NO_SOURCE')
|
||||
case 409:
|
||||
return new AssetBridgeError(
|
||||
`The sidecar refused the protocol version this build declares: ${reason}`,
|
||||
'PROTOCOL',
|
||||
)
|
||||
case 422:
|
||||
return new AssetBridgeError(reason, 'SOURCE_CHANGED')
|
||||
case 425:
|
||||
return new AssetBridgeError(
|
||||
'The shard stayed busy serving another asset request',
|
||||
'BUSY',
|
||||
)
|
||||
case 503:
|
||||
// The named `NO_IMAGING` outcome arrives this way: a Linux shard host with
|
||||
// no libgdiplus cannot render a sprite at all, and §4.4 requires that be an
|
||||
// actionable sentence rather than a stack trace. The shard's own wording
|
||||
// already names the package and the command, so it is passed through.
|
||||
return new AssetBridgeError(reason, /libgdiplus/i.test(reason) ? 'NO_IMAGING' : 'SHARD_DOWN')
|
||||
case 504:
|
||||
return new AssetBridgeError(`The shard did not answer: ${reason}`, 'SHARD_DOWN')
|
||||
default:
|
||||
return new AssetBridgeError(reason, 'UNAVAILABLE')
|
||||
}
|
||||
}
|
||||
|
||||
/** One call, with the 425 backoff. `send` returns the client's `{ ok, ... }`. */
|
||||
async function withBusyRetry(send, what) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const res = await send()
|
||||
if (res.ok) return res.data
|
||||
|
||||
if (res.status === 425 && attempt < BUSY_RETRIES) {
|
||||
await sleep(BUSY_BACKOFF_MS[Math.min(attempt, BUSY_BACKOFF_MS.length - 1)])
|
||||
continue
|
||||
}
|
||||
|
||||
throw describeFailure(res, what)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared page-envelope checks (§3.4).
|
||||
*
|
||||
* Every one of these is a way a walk can end in something that LOOKS like a
|
||||
* complete import and is not, which is why they are assertions rather than
|
||||
* warnings: a truncated catalogue is indistinguishable downstream from a client
|
||||
* that simply has fewer creatures.
|
||||
*/
|
||||
function checkPage(page, { arrayName, cursor, pages, noun = 'asset' }) {
|
||||
if (!page || !Array.isArray(page[arrayName])) {
|
||||
throw new AssetBridgeError(
|
||||
`The shard sent a ${noun} page with no ${arrayName} array`,
|
||||
'MALFORMED',
|
||||
)
|
||||
}
|
||||
|
||||
if (!page.more) {
|
||||
if (page.cut !== 'end') {
|
||||
throw new AssetBridgeError(
|
||||
`The shard stopped sending ${noun} rows after ${pages} page(s) (cut: ${page.cut || 'unknown'})`,
|
||||
'INCOMPLETE',
|
||||
)
|
||||
}
|
||||
return { done: true }
|
||||
}
|
||||
|
||||
if (!page.cursor || page.cursor === cursor) {
|
||||
throw new AssetBridgeError(
|
||||
`The shard asked for another ${noun} page without advancing its cursor (${page.cursor || 'none'})`,
|
||||
'STUCK',
|
||||
)
|
||||
}
|
||||
|
||||
return { done: false, cursor: page.cursor }
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 of a buffer, lowercase hex.
|
||||
*
|
||||
* Here rather than in each caller because the shard's `BridgeAssets.Sha256Hex`
|
||||
* is one function on its side too, and a hash that has to match across a wire
|
||||
* should have exactly one spelling at each end.
|
||||
*/
|
||||
function sha256Of(buffer) {
|
||||
return require('crypto').createHash('sha256').update(buffer).digest('hex')
|
||||
}
|
||||
|
||||
// The client files the body catalogue is derived from. `assets.sources` reports
|
||||
// every file the shard can see; these are the ones that decide a sprite.
|
||||
//
|
||||
// `body.def` and `bodyconv.def` are in the list and it would be easy to leave
|
||||
// them out — they hold no pixels. They decide WHICH record a body id resolves to,
|
||||
// so an operator editing one changes what every affected creature looks like
|
||||
// while every anim file stays byte-identical. That is precisely the drift a
|
||||
// content hash of the art files cannot see.
|
||||
const SOURCE_FILES = [
|
||||
'anim.idx', 'anim.mul',
|
||||
'anim2.idx', 'anim2.mul',
|
||||
'anim3.idx', 'anim3.mul',
|
||||
'anim4.idx', 'anim4.mul',
|
||||
'anim5.idx', 'anim5.mul',
|
||||
'body.def', 'bodyconv.def',
|
||||
'verdata.mul',
|
||||
]
|
||||
|
||||
/**
|
||||
* Stage 1 of the import gate (§6): have the client files this family reads
|
||||
* changed at all?
|
||||
*
|
||||
* Returns `{ files, extractorVersion, hashing, complete, imaging }` where `files`
|
||||
* is a `{ name: { size, mtime, sha256 } }` map over `SOURCE_FILES` — a file the
|
||||
* shard does not have is simply absent, which is normal (few clients carry all
|
||||
* five anim files).
|
||||
*
|
||||
* **A null `sha256` means "not computed yet", never "changed".** The shard hashes
|
||||
* off the request path because `anim.mul` alone is 195 MB and hashing it cannot
|
||||
* fit inside a reply, and it reports `hashing: true` while that runs.
|
||||
* `sameSources` below falls back to (size, mtime) in that case, which is the same
|
||||
* gate the shard itself applies.
|
||||
*/
|
||||
async function sourceFingerprint() {
|
||||
const res = await uoLinkClient.getAssetSources()
|
||||
if (!res.ok) throw describeFailure(res, 'client file manifest')
|
||||
|
||||
// A 200 from this call stopped meaning "client files are on offer" in phase 7:
|
||||
// it now answers whenever either plane is enabled, so a shard serving only its
|
||||
// configuration tree reports an empty file list rather than a 403. Read as-is
|
||||
// that becomes "your client has no animation files", which sends an operator to
|
||||
// the wrong place entirely.
|
||||
if (res.data?.assetsEnabled === false) {
|
||||
throw new AssetBridgeError(
|
||||
'The shard is refusing to serve client assets (Bridge.AssetsEnabled is off)',
|
||||
'DISABLED',
|
||||
)
|
||||
}
|
||||
|
||||
const wanted = new Set(SOURCE_FILES)
|
||||
const files = {}
|
||||
|
||||
for (const entry of res.data?.files ?? []) {
|
||||
const name = String(entry?.name || '').toLowerCase()
|
||||
if (!wanted.has(name)) continue
|
||||
|
||||
files[name] = {
|
||||
size: Number(entry.size) || 0,
|
||||
mtime: Number(entry.mtime) || 0,
|
||||
sha256: entry.sha256 ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
extractorVersion: Number(res.data?.extractorVersion) || 0,
|
||||
hashing: Boolean(res.data?.hashing),
|
||||
complete: Boolean(res.data?.complete),
|
||||
imaging: res.data?.imaging ?? null,
|
||||
// Which §5 key families this overlay can be asked for (phase 5). Absent on a
|
||||
// phase-3 or phase-4 overlay, which served bodies and nothing else — so the
|
||||
// fallback is `['body']` rather than `[]`: an older shard is not a shard with
|
||||
// no assets, and treating it as one would turn a working bestiary off.
|
||||
families: Array.isArray(res.data?.families) && res.data.families.length > 0
|
||||
? res.data.families.map(String)
|
||||
: [FAMILY],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when two source fingerprints describe the same client files.
|
||||
*
|
||||
* The file SET has to match as well as each file's contents: a client that gained
|
||||
* an `anim5.mul` it did not have before is a client whose gargoyles suddenly
|
||||
* resolve, and comparing only the files present in both would call that
|
||||
* unchanged.
|
||||
*/
|
||||
function sameSources(a, b) {
|
||||
if (!a || !b) return false
|
||||
if (a.extractorVersion !== b.extractorVersion) return false
|
||||
|
||||
const names = new Set([...Object.keys(a.files ?? {}), ...Object.keys(b.files ?? {})])
|
||||
|
||||
for (const name of names) {
|
||||
const left = a.files?.[name]
|
||||
const right = b.files?.[name]
|
||||
|
||||
if (!left || !right) return false
|
||||
|
||||
if (left.sha256 && right.sha256) {
|
||||
if (left.sha256 !== right.sha256) return false
|
||||
continue
|
||||
}
|
||||
|
||||
if (left.size !== right.size || left.mtime !== right.mtime || left.size <= 0) return false
|
||||
}
|
||||
|
||||
return names.size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 2: the whole manifest for the body family.
|
||||
*
|
||||
* Returns `{ rows, catalog, extractorVersion, playerBodies, pages, scanned }`.
|
||||
* No pixels — `rows` is `[{ key, sha256, bytes, width, height, body, direction }]`.
|
||||
*/
|
||||
async function readManifest({ family = FAMILY } = {}) {
|
||||
const started = Date.now()
|
||||
const rows = []
|
||||
|
||||
let cursor = null
|
||||
let pages = 0
|
||||
let catalog = null
|
||||
let extractorVersion = 0
|
||||
let playerBodies = []
|
||||
let scanned = 0
|
||||
let finished = false
|
||||
|
||||
while (pages < MAX_PAGES) {
|
||||
const page = await withBusyRetry(
|
||||
() => uoLinkClient.getAssetManifest({ family, cursor }),
|
||||
`${family} asset manifest`,
|
||||
)
|
||||
pages++
|
||||
|
||||
if (catalog === null) {
|
||||
catalog = page.catalog ?? null
|
||||
extractorVersion = Number(page.extractorVersion) || 0
|
||||
playerBodies = Array.isArray(page.playerBodies) ? page.playerBodies.map(Number) : []
|
||||
} else if (page.catalog !== catalog) {
|
||||
// The client files moved between two pages of one walk. Refusing is the
|
||||
// only honest answer: half of what we hold describes files that no longer
|
||||
// exist, and nothing later can tell which half.
|
||||
throw new AssetBridgeError(
|
||||
"The shard's client files changed while the manifest was being read; nothing was imported",
|
||||
'SOURCE_CHANGED',
|
||||
)
|
||||
}
|
||||
|
||||
scanned += Number(page.scanned) || 0
|
||||
|
||||
for (const row of page.rows) {
|
||||
const key = String(row?.key ?? '')
|
||||
if (key === '') continue
|
||||
|
||||
rows.push({
|
||||
key,
|
||||
family,
|
||||
sha256: String(row?.sha256 ?? ''),
|
||||
bytes: Number(row?.bytes) || 0,
|
||||
width: Number(row?.width) || 0,
|
||||
height: Number(row?.height) || 0,
|
||||
body: Number.isFinite(Number(row?.body)) ? Number(row.body) : null,
|
||||
// Which action the thumbnail came from (§11.2, phase 6). All but 73 of
|
||||
// this client's bodies answer 0; the rest have no art there and are
|
||||
// catalogued deeper, with the key naming the action. An overlay older
|
||||
// than phase 6 omits it, and 0 is the right reading of that.
|
||||
action: Number.isFinite(Number(row?.action)) ? Number(row.action) : 0,
|
||||
direction: Number.isFinite(Number(row?.direction)) ? Number(row.direction) : null,
|
||||
})
|
||||
}
|
||||
|
||||
if (rows.length > MAX_ROWS) {
|
||||
throw new AssetBridgeError(
|
||||
`The shard listed more than ${MAX_ROWS} assets; refusing to keep reading`,
|
||||
'TOO_LARGE',
|
||||
)
|
||||
}
|
||||
|
||||
const state = checkPage(page, { arrayName: 'rows', cursor, pages })
|
||||
|
||||
if (state.done) {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
|
||||
cursor = state.cursor
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
throw new AssetBridgeError(
|
||||
`The asset manifest did not end within ${MAX_PAGES} pages; nothing was imported`,
|
||||
'TOO_LARGE',
|
||||
)
|
||||
}
|
||||
|
||||
log.info('asset manifest read from the shard', {
|
||||
family,
|
||||
rows: rows.length,
|
||||
scanned,
|
||||
pages,
|
||||
ms: Date.now() - started,
|
||||
})
|
||||
|
||||
return { rows, catalog, extractorVersion, playerBodies, pages, scanned }
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes for an explicit list of keys.
|
||||
*
|
||||
* Returns a Map of key → `{ sha256, bytes, width, height, body, action, direction, png }`
|
||||
* where `png` is a Buffer. A key the shard could not serve is **absent from the
|
||||
* map** rather than present with a null — the caller then decides what that means
|
||||
* for its own row, and the two ways it happens (`absent`, `unsupported`) are
|
||||
* counted separately in the returned tallies so an operator can tell "this client
|
||||
* has no art for that body" from "the site asked for a key shape this shard does
|
||||
* not serve", which is a bug rather than a gap.
|
||||
*/
|
||||
async function fetchAssets({ keys, catalog } = {}) {
|
||||
const started = Date.now()
|
||||
const out = new Map()
|
||||
const missing = { absent: 0, unsupported: 0 }
|
||||
|
||||
const list = Array.isArray(keys) ? keys.filter((k) => typeof k === 'string' && k !== '') : []
|
||||
|
||||
if (list.length === 0) return { assets: out, missing, pages: 0, catalog: catalog ?? null }
|
||||
|
||||
let pages = 0
|
||||
|
||||
// The catalogue the shard actually answered under. The body import already knows
|
||||
// it from the manifest, but the on-demand families have no manifest to learn it
|
||||
// from (§11) — so it is read back off the reply and stored with the rows, which
|
||||
// is what makes a later "is this stale?" answerable per key.
|
||||
let answered = catalog ?? null
|
||||
|
||||
for (let i = 0; i < list.length; i += FETCH_CHUNK) {
|
||||
const chunk = list.slice(i, i + FETCH_CHUNK)
|
||||
|
||||
let cursor = null
|
||||
let finished = false
|
||||
let walked = 0
|
||||
|
||||
while (walked < MAX_PAGES) {
|
||||
const page = await withBusyRetry(
|
||||
() => uoLinkClient.fetchAssets({ keys: chunk, catalog, cursor }),
|
||||
'asset content',
|
||||
)
|
||||
pages++
|
||||
walked++
|
||||
|
||||
if (typeof page.catalog === 'string' && page.catalog !== '') {
|
||||
if (answered !== null && page.catalog !== answered) {
|
||||
// Two pages of one walk describing two different clients. The shard
|
||||
// refuses this when it is told what to expect; when it was not told —
|
||||
// the first fetch of a warm pass — this is where it is caught.
|
||||
throw new AssetBridgeError(
|
||||
`The shard's client files changed mid-fetch (catalog ${answered} became ${page.catalog})`,
|
||||
'UNAVAILABLE',
|
||||
)
|
||||
}
|
||||
|
||||
answered = page.catalog
|
||||
}
|
||||
|
||||
for (const row of page.rows ?? []) {
|
||||
const key = String(row?.key ?? '')
|
||||
if (key === '') continue
|
||||
|
||||
if (row?.status !== 'ok') {
|
||||
if (row?.status === 'unsupported') missing.unsupported++
|
||||
else missing.absent++
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof row.png !== 'string' || row.png === '') {
|
||||
missing.absent++
|
||||
continue
|
||||
}
|
||||
|
||||
out.set(key, {
|
||||
sha256: String(row.sha256 ?? ''),
|
||||
bytes: Number(row.bytes) || 0,
|
||||
width: Number(row.width) || 0,
|
||||
height: Number(row.height) || 0,
|
||||
body: Number.isFinite(Number(row.body)) ? Number(row.body) : null,
|
||||
action: Number.isFinite(Number(row.action)) ? Number(row.action) : null,
|
||||
direction: Number.isFinite(Number(row.direction)) ? Number(row.direction) : null,
|
||||
// Phase 5's art families carry these; the body catalogue does not, and a
|
||||
// consumer that wants neither is unaffected by either.
|
||||
hue: Number.isFinite(Number(row.hue)) ? Number(row.hue) : null,
|
||||
partialHue: typeof row.partialHue === 'boolean' ? row.partialHue : null,
|
||||
source: typeof row.source === 'string' ? row.source : null,
|
||||
png: Buffer.from(row.png, 'base64'),
|
||||
})
|
||||
}
|
||||
|
||||
const state = checkPage(page, { arrayName: 'rows', cursor, pages: walked })
|
||||
|
||||
if (state.done) {
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
|
||||
cursor = state.cursor
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
throw new AssetBridgeError(
|
||||
`An asset fetch did not end within ${MAX_PAGES} pages; nothing was imported`,
|
||||
'TOO_LARGE',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
log.info('asset content fetched from the shard', {
|
||||
catalog: answered,
|
||||
asked: list.length,
|
||||
got: out.size,
|
||||
absent: missing.absent,
|
||||
unsupported: missing.unsupported,
|
||||
pages,
|
||||
ms: Date.now() - started,
|
||||
})
|
||||
|
||||
return { assets: out, missing, pages, catalog: answered }
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug → body id, for the atlas's own creature list (§8).
|
||||
*
|
||||
* `creatures` is `[{ slug, name }]` where `name` is the ServUO class name — which
|
||||
* `shard_spawn_creatures.name` already holds, because the atlas build picks the
|
||||
* winning spelling of the spawn TYPE token rather than inventing a display name.
|
||||
* That is why this needs no new column to ask its question.
|
||||
*
|
||||
* Returns `[{ slug, typeName, body, status }]`, one row per creature asked, with
|
||||
* every outcome recorded — including the negative ones. A creature the shard says
|
||||
* it does not have is a fact worth keeping: without it, the next pass asks again,
|
||||
* and the pass costs a real constructor per name on the shard's Core thread.
|
||||
*/
|
||||
async function resolveBodies({ creatures } = {}) {
|
||||
const started = Date.now()
|
||||
const list = Array.isArray(creatures) ? creatures : []
|
||||
const out = []
|
||||
|
||||
for (let i = 0; i < list.length; i += BODY_CHUNK) {
|
||||
const chunk = list.slice(i, i + BODY_CHUNK)
|
||||
const bySlug = new Map()
|
||||
|
||||
for (const creature of chunk) {
|
||||
const typeName = String(creature?.name ?? '').trim()
|
||||
if (typeName === '') continue
|
||||
// Several slugs can share a type name only if the atlas slugified two
|
||||
// spellings to one slug, in which case they ARE one creature; asking once
|
||||
// per distinct name is what keeps the batch inside the shard's cap.
|
||||
if (!bySlug.has(typeName)) bySlug.set(typeName, [])
|
||||
bySlug.get(typeName).push(String(creature.slug))
|
||||
}
|
||||
|
||||
const types = [...bySlug.keys()]
|
||||
if (types.length === 0) continue
|
||||
|
||||
const page = await withBusyRetry(() => uoLinkClient.resolveBodies(types), 'body resolution')
|
||||
|
||||
if (!page || !Array.isArray(page.rows)) {
|
||||
throw new AssetBridgeError('The shard sent a body resolution with no rows array', 'MALFORMED')
|
||||
}
|
||||
|
||||
for (const row of page.rows) {
|
||||
const typeName = String(row?.type ?? '')
|
||||
const slugs = bySlug.get(typeName)
|
||||
|
||||
if (!slugs) continue
|
||||
|
||||
const status = String(row?.status ?? 'failed')
|
||||
const body = status === 'ok' && Number.isFinite(Number(row?.body)) ? Number(row.body) : null
|
||||
|
||||
for (const slug of slugs) out.push({ slug, typeName, body, status })
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = out.filter((r) => r.status === 'ok').length
|
||||
|
||||
log.info('creature bodies resolved by the shard', {
|
||||
asked: list.length,
|
||||
answered: out.length,
|
||||
resolved,
|
||||
ms: Date.now() - started,
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AssetBridgeError,
|
||||
// Shared with `treeBridge.js` (phase 7): the 425 backoff, the page-envelope
|
||||
// checks and the hash are properties of this PLANE, not of the body family, and
|
||||
// a second copy of any of them is a second place for the envelope to drift.
|
||||
withBusyRetry,
|
||||
checkPage,
|
||||
sha256Of,
|
||||
FAMILY,
|
||||
BODY_CHUNK,
|
||||
FETCH_CHUNK,
|
||||
MAX_PAGES,
|
||||
MAX_ROWS,
|
||||
SOURCE_FILES,
|
||||
sourceFingerprint,
|
||||
sameSources,
|
||||
readManifest,
|
||||
fetchAssets,
|
||||
resolveBodies,
|
||||
}
|
||||
318
server/utils/clilocBridge.js
Normal file
318
server/utils/clilocBridge.js
Normal file
@@ -0,0 +1,318 @@
|
||||
// Cliloc table — the SHARD source (docs/link/v8.md §9, protocol 8 phase 2).
|
||||
//
|
||||
// `clilocSource.js` is the filesystem half of this story and predates it. This is
|
||||
// the half that replaces the part of it nobody enjoyed: until protocol 8 the base
|
||||
// table reached the site because an operator installed UOFiddler, built a
|
||||
// converter against its `Ultima.dll`, ran it over their client's compressed
|
||||
// `Cliloc.enu` and copied a five-megabyte file to the web host — every time they
|
||||
// patched their client.
|
||||
//
|
||||
// The shard has always had those files (a ServUO server cannot boot without a UO
|
||||
// client) and, as of phase 2, has the decompressor too. So the base table now
|
||||
// arrives over the same request/reply path as every other shard read, and the
|
||||
// operator installs nothing.
|
||||
//
|
||||
// **What is NOT here.** Overlays. Shard-added items carry cliloc ids no client
|
||||
// table has, ServUO has no server-side notion of a custom cliloc, and there is
|
||||
// therefore nothing on the shard to ask for. `custom/` stays a directory the site
|
||||
// reads (`clilocSource.readOverlays`), and the model merges it OVER whatever
|
||||
// arrives here. That division is the whole of CLILOCS.md §Shard-added items and
|
||||
// it is unchanged by this file.
|
||||
//
|
||||
// ── Why this walks pages instead of asking for a table ────────────────────
|
||||
//
|
||||
// The sidecar's reply timeout is 10 s and its inbound line cap is 1 MiB, so a
|
||||
// five-megabyte table cannot be one answer. The shard cuts pages at a 512 KiB
|
||||
// byte budget and hands back a cursor; this walks them. A stock English table is
|
||||
// about eleven pages.
|
||||
//
|
||||
// Three properties of that envelope are load-bearing and each has a check below:
|
||||
//
|
||||
// - **Only `cut: 'end'` means finished.** A short page can equally mean the
|
||||
// budget was spent (`budget`) or the family stopped at its own limit
|
||||
// (`limit`). Treating a short page as the end would import a truncated table,
|
||||
// which is indistinguishable downstream from a complete one — some items
|
||||
// named, some not, exactly what "no table at all" looks like.
|
||||
// - **The cursor must advance.** A shard that answered the same cursor forever
|
||||
// would spin this loop until the request timeout with nothing to show.
|
||||
// - **The file must not change underneath the walk.** Every page echoes the
|
||||
// source's size and mtime; an operator patching their client mid-import would
|
||||
// otherwise produce one table stitched from two, with no error anywhere.
|
||||
|
||||
// Required as a namespace, not destructured: a test that stubs the sidecar
|
||||
// replaces these on the module object, and a destructured copy taken at load
|
||||
// time would keep calling the real one.
|
||||
const uoLinkClient = require('./uoLinkClient')
|
||||
const log = require('../core').logger('cliloc-bridge')
|
||||
|
||||
/** The client file the base table comes from, as `assets.sources` names it. */
|
||||
const SOURCE_FILE = 'cliloc.enu'
|
||||
|
||||
const DEFAULT_LANGUAGE = 'enu'
|
||||
|
||||
// Bounds on the walk. Neither is expected to be reached — a stock table is ~11
|
||||
// pages and ~67k rows — and both exist so that a shard answering nonsense costs a
|
||||
// bounded amount of time rather than an unbounded amount of memory.
|
||||
const MAX_PAGES = 200
|
||||
const MAX_ROWS = 500000
|
||||
|
||||
// 425 is the ordinary answer during an import, not an error: the shard's asset
|
||||
// plane serves one request at a time on purpose, because its outbound queue is
|
||||
// bounded in lines rather than bytes. So a page that comes back busy is retried
|
||||
// with a short backoff rather than failing the import.
|
||||
const BUSY_RETRIES = 5
|
||||
const BUSY_BACKOFF_MS = [200, 400, 800, 1600, 3200]
|
||||
|
||||
class ClilocBridgeError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message)
|
||||
this.name = 'ClilocBridgeError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Map a sidecar response onto one of this module's codes.
|
||||
*
|
||||
* The statuses are the ones `respond_assets` produces, and the distinction that
|
||||
* matters most to an operator is 403 vs 404: "you have not switched this on" and
|
||||
* "your client does not have that file" are different jobs, and both are things
|
||||
* they can fix.
|
||||
*/
|
||||
function describeFailure(res, what) {
|
||||
const reason = res?.data?.reason || res?.error || `sidecar responded ${res?.status}`
|
||||
|
||||
switch (res?.status) {
|
||||
case 403:
|
||||
return new ClilocBridgeError(
|
||||
`The shard is refusing to serve client assets (Bridge.AssetsEnabled is off): ${reason}`,
|
||||
'DISABLED',
|
||||
)
|
||||
case 404:
|
||||
return new ClilocBridgeError(`The shard has no ${what}: ${reason}`, 'NO_SOURCE')
|
||||
case 409:
|
||||
return new ClilocBridgeError(
|
||||
`The sidecar refused the protocol version this build declares: ${reason}`,
|
||||
'PROTOCOL',
|
||||
)
|
||||
case 422:
|
||||
return new ClilocBridgeError(`The shard could not read its own ${what}: ${reason}`, 'UNREADABLE')
|
||||
case 425:
|
||||
return new ClilocBridgeError(
|
||||
'The shard is busy serving another asset request and stayed busy',
|
||||
'BUSY',
|
||||
)
|
||||
case 503:
|
||||
case 504:
|
||||
return new ClilocBridgeError(`The shard did not answer: ${reason}`, 'SHARD_DOWN')
|
||||
default:
|
||||
return new ClilocBridgeError(reason, 'UNAVAILABLE')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 1: the fingerprint of the shard's own cliloc file.
|
||||
*
|
||||
* Returns `{ file, size, mtime, sha256, extractorVersion, hashing, complete }`.
|
||||
*
|
||||
* `sha256` may be **null** — the shard reports hashes only once it has computed
|
||||
* them off the request path, because hashing the client files it also serves
|
||||
* (343 MB of art and animation) cannot fit inside a 10 s reply. A null hash means
|
||||
* "not yet", never "changed", and `sameSource` below compares (size, mtime) in
|
||||
* that case, which is the same gate the shard itself uses.
|
||||
*/
|
||||
async function fingerprint() {
|
||||
const res = await uoLinkClient.getAssetSources()
|
||||
if (!res.ok) throw describeFailure(res, 'client file manifest')
|
||||
|
||||
// Since protocol 8 phase 7 this call answers when EITHER plane is enabled, so
|
||||
// a 200 no longer means the client files are on offer. Without this check an
|
||||
// operator who switched client-file extraction off would read "your UO client
|
||||
// has no cliloc.enu" and go looking at their client install for a setting that
|
||||
// lives on their shard.
|
||||
if (res.data?.assetsEnabled === false) {
|
||||
throw new ClilocBridgeError(
|
||||
'The shard is refusing to serve client assets (Bridge.AssetsEnabled is off)',
|
||||
'DISABLED',
|
||||
)
|
||||
}
|
||||
|
||||
const files = Array.isArray(res.data?.files) ? res.data.files : []
|
||||
const entry = files.find((f) => String(f?.name || '').toLowerCase() === SOURCE_FILE)
|
||||
|
||||
if (!entry) {
|
||||
throw new ClilocBridgeError(
|
||||
`The shard's UO client has no ${SOURCE_FILE} (it reported ${files.length} client file(s))`,
|
||||
'NO_SOURCE',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'bridge',
|
||||
file: entry.name,
|
||||
path: entry.path ?? null,
|
||||
size: Number(entry.size) || 0,
|
||||
mtime: Number(entry.mtime) || 0,
|
||||
sha256: entry.sha256 ?? null,
|
||||
extractorVersion: Number(res.data?.extractorVersion) || 0,
|
||||
hashing: Boolean(res.data?.hashing),
|
||||
complete: Boolean(res.data?.complete),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when two fingerprints describe the same client file.
|
||||
*
|
||||
* Hash first when both sides have one, because a hash is the only thing that
|
||||
* catches a file rewritten with the same length and timestamp. Falls back to
|
||||
* (size, mtime) when either side's hash is missing, which is the case on the
|
||||
* first poll after a shard restart and the reason `hashing` exists at all.
|
||||
*/
|
||||
function sameSource(a, b) {
|
||||
if (!a || !b) return false
|
||||
if (a.extractorVersion !== b.extractorVersion) return false
|
||||
if (a.sha256 && b.sha256) return a.sha256 === b.sha256
|
||||
return a.size === b.size && a.mtime === b.mtime && a.size > 0
|
||||
}
|
||||
|
||||
/** One page, with the 425 backoff. */
|
||||
async function fetchPage({ lang, cursor }) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const res = await uoLinkClient.getClilocTable({ lang, cursor })
|
||||
if (res.ok) return res.data
|
||||
|
||||
if (res.status === 425 && attempt < BUSY_RETRIES) {
|
||||
await sleep(BUSY_BACKOFF_MS[Math.min(attempt, BUSY_BACKOFF_MS.length - 1)])
|
||||
continue
|
||||
}
|
||||
|
||||
throw describeFailure(res, `cliloc.${lang}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the whole table.
|
||||
*
|
||||
* Returns `{ entries, source }` where `entries` is `[{ number, flag, text }]` in
|
||||
* the shape `clilocParse` produces, so the merge in `shardClilocs.model` does not
|
||||
* care which source an entry came from.
|
||||
*
|
||||
* Blanks are already gone: the shard drops the ~56,000 empty strings a stock
|
||||
* table carries before they reach the wire, since the site would drop them at
|
||||
* import anyway. Nothing downstream changes — `db.replaceAll` still filters, and
|
||||
* still would if a source ever sent one.
|
||||
*/
|
||||
async function readCliloc({ lang = DEFAULT_LANGUAGE } = {}) {
|
||||
const started = Date.now()
|
||||
const entries = []
|
||||
|
||||
let cursor = null
|
||||
let pages = 0
|
||||
let first = null
|
||||
let finished = false
|
||||
let total = null
|
||||
|
||||
while (pages < MAX_PAGES) {
|
||||
const page = await fetchPage({ lang, cursor })
|
||||
pages++
|
||||
|
||||
if (!page || !Array.isArray(page.rows)) {
|
||||
throw new ClilocBridgeError('The shard sent a cliloc page with no rows array', 'MALFORMED')
|
||||
}
|
||||
|
||||
if (first === null) {
|
||||
first = { size: Number(page.size) || 0, mtime: Number(page.mtime) || 0 }
|
||||
total = Number.isFinite(Number(page.total)) ? Number(page.total) : null
|
||||
} else if (Number(page.size) !== first.size || Number(page.mtime) !== first.mtime) {
|
||||
// The client was patched (or a different one mounted) between two pages.
|
||||
// Refusing is the only honest answer: half of what we hold is from a file
|
||||
// that no longer exists, and nothing later can tell which half.
|
||||
throw new ClilocBridgeError(
|
||||
'The shard\'s cliloc file changed while it was being read; nothing was imported',
|
||||
'SOURCE_CHANGED',
|
||||
)
|
||||
}
|
||||
|
||||
for (const row of page.rows) {
|
||||
const number = Number(row?.n)
|
||||
if (!Number.isInteger(number)) continue
|
||||
entries.push({ number, flag: Number(row?.f) || 0, text: String(row?.t ?? '') })
|
||||
}
|
||||
|
||||
if (entries.length > MAX_ROWS) {
|
||||
throw new ClilocBridgeError(
|
||||
`The shard sent more than ${MAX_ROWS} cliloc rows; refusing to keep reading`,
|
||||
'TOO_LARGE',
|
||||
)
|
||||
}
|
||||
|
||||
if (!page.more) {
|
||||
// `cut` is the field that says WHY a page was the last one, and only one of
|
||||
// its values means the table ended. A shard that stopped for its own limit
|
||||
// has not finished, and importing what arrived would silently drop the tail.
|
||||
if (page.cut !== 'end') {
|
||||
throw new ClilocBridgeError(
|
||||
`The shard stopped sending cliloc rows after ${entries.length} (cut: ${page.cut || 'unknown'})`,
|
||||
'INCOMPLETE',
|
||||
)
|
||||
}
|
||||
finished = true
|
||||
break
|
||||
}
|
||||
|
||||
if (!page.cursor || page.cursor === cursor) {
|
||||
// Either would loop forever: no cursor to advance with, or the same one
|
||||
// back again.
|
||||
throw new ClilocBridgeError(
|
||||
`The shard asked for another cliloc page without advancing its cursor (${page.cursor || 'none'})`,
|
||||
'STUCK',
|
||||
)
|
||||
}
|
||||
|
||||
cursor = page.cursor
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
throw new ClilocBridgeError(
|
||||
`The cliloc table did not end within ${MAX_PAGES} pages; nothing was imported`,
|
||||
'TOO_LARGE',
|
||||
)
|
||||
}
|
||||
|
||||
log.info('cliloc table read from the shard', {
|
||||
lang,
|
||||
entries: entries.length,
|
||||
pages,
|
||||
ms: Date.now() - started,
|
||||
})
|
||||
|
||||
return {
|
||||
entries,
|
||||
source: {
|
||||
kind: 'bridge',
|
||||
lang,
|
||||
file: `cliloc.${lang}`,
|
||||
size: first?.size ?? 0,
|
||||
mtime: first?.mtime ?? 0,
|
||||
pages,
|
||||
// What the shard said it holds, kept beside what actually arrived. They
|
||||
// agree or the walk is wrong, and an operator seeing them disagree in the
|
||||
// panel learns more than a single number would tell them.
|
||||
reported: total,
|
||||
received: entries.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ClilocBridgeError,
|
||||
SOURCE_FILE,
|
||||
DEFAULT_LANGUAGE,
|
||||
MAX_PAGES,
|
||||
MAX_ROWS,
|
||||
fingerprint,
|
||||
sameSource,
|
||||
readCliloc,
|
||||
}
|
||||
@@ -6,6 +6,24 @@
|
||||
// - the server, which refreshes the table on boot (`shardClilocs.model.js`)
|
||||
// - the admin panel, which can force a reimport without a restart
|
||||
//
|
||||
// ── What protocol 8 took away, and what it left ───────────────────────────
|
||||
//
|
||||
// The BASE table no longer comes from here on a shard that has uo-link
|
||||
// configured: `clilocBridge.js` asks the shard for it, because the shard has the
|
||||
// operator's client files already and, since phase 2, the decompressor to read
|
||||
// them (docs/link/v8.md §9). Nobody converts a file by hand any more.
|
||||
//
|
||||
// Two things keep this module alive rather than deleting it:
|
||||
//
|
||||
// - **Overlays.** Shard-added items carry cliloc ids no client table has, and
|
||||
// ServUO has no server-side notion of a custom cliloc — there is nothing on
|
||||
// the shard to ask for. `custom/` is still a directory the site reads, and
|
||||
// `readOverlays` below is the entry point the bridge path uses.
|
||||
// - **Installs with no shard link**, and development. A site that has never
|
||||
// configured uo-link can still be pointed at a converted file; that path is
|
||||
// deprecated, not removed, and it stays the whole of this module's base-table
|
||||
// behaviour.
|
||||
//
|
||||
// The files are the OPERATOR'S (see docs/website/CLILOCS.md). Nothing derived
|
||||
// from them is committed: the repo holds no string table, exactly as it holds no
|
||||
// map snapshot and no artwork. That rule is why this module reads a configured
|
||||
@@ -194,6 +212,63 @@ function readSources(configured) {
|
||||
return { root, files }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the OVERLAY files only, with no base table.
|
||||
*
|
||||
* The bridge path needs exactly this: the base arrives from the shard and the
|
||||
* `custom/` directory beside the configured path still has to be merged over it.
|
||||
* `readSources` cannot answer it, because resolving a base is the first thing it
|
||||
* does and there may not be one — an operator on the bridge is entitled to point
|
||||
* this setting at a directory that holds nothing but `custom/`.
|
||||
*
|
||||
* **Never throws.** A path that is blank, missing or unreadable is reported as a
|
||||
* `problem` string and an empty file list, because none of those may stop a base
|
||||
* table that arrived perfectly well from being imported. The model decides what
|
||||
* to do about it — and it has a real decision to make, since an overlay that was
|
||||
* loaded last time and is missing now is the vanished-source hazard, not a
|
||||
* config typo.
|
||||
*/
|
||||
function readOverlays(configured) {
|
||||
const target = String(configured ?? '').trim()
|
||||
if (target === '') return { root: null, files: [], problem: null }
|
||||
|
||||
let root
|
||||
try {
|
||||
const stat = fs.statSync(target)
|
||||
root = stat.isFile() ? path.dirname(target) : target
|
||||
} catch {
|
||||
return { root: null, files: [], problem: `Cliloc path does not exist: ${target}` }
|
||||
}
|
||||
|
||||
let overlays
|
||||
try {
|
||||
overlays = listCustom(root)
|
||||
} catch (err) {
|
||||
return { root, files: [], problem: err.message }
|
||||
}
|
||||
|
||||
const files = []
|
||||
for (const file of overlays) {
|
||||
let buffer
|
||||
try {
|
||||
buffer = fs.readFileSync(file)
|
||||
} catch {
|
||||
return { root, files: [], problem: `Cliloc overlay is not readable: ${file}` }
|
||||
}
|
||||
files.push({
|
||||
label: path.relative(root, file).split(path.sep).join('/'),
|
||||
kind: 'custom',
|
||||
file,
|
||||
buffer,
|
||||
sha256: sha256(buffer),
|
||||
bytes: buffer.length,
|
||||
compressed: isCompressedCliloc(buffer),
|
||||
})
|
||||
}
|
||||
|
||||
return { root, files, problem: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* A fingerprint of every source: `{ "<label>": "<sha256>" }`, plus the base's
|
||||
* details for the admin panel.
|
||||
@@ -244,6 +319,25 @@ function missingSources(current, loaded) {
|
||||
return Object.keys(loaded).filter((label) => !Object.hasOwn(current, label))
|
||||
}
|
||||
|
||||
/**
|
||||
* The same question asked of OVERLAYS only.
|
||||
*
|
||||
* Needed because the base table moved to the bridge. An install upgraded from the
|
||||
* file pipeline carries a base label (`clilocs.plain`, say) in its loaded
|
||||
* fingerprint, and that label is *supposed* to disappear when the base starts
|
||||
* arriving from the shard — reporting it as a vanished source would make every
|
||||
* first import after the upgrade demand an approval for a change the upgrade
|
||||
* itself made. Overlay labels are the ones whose absence is genuinely ambiguous,
|
||||
* and they are exactly the labels under `custom/`.
|
||||
*/
|
||||
function missingOverlays(current, loaded) {
|
||||
if (!loaded) return []
|
||||
const prefix = `${CUSTOM_DIR}/`
|
||||
return Object.keys(loaded).filter(
|
||||
(label) => label.startsWith(prefix) && !Object.hasOwn(current, label),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse every source, merged into one entry list.
|
||||
*
|
||||
@@ -309,8 +403,10 @@ module.exports = {
|
||||
resolveBase,
|
||||
listCustom,
|
||||
readSources,
|
||||
readOverlays,
|
||||
hashSources,
|
||||
sameSources,
|
||||
missingSources,
|
||||
missingOverlays,
|
||||
readCliloc,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user