Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
@@ -61,12 +72,19 @@
|
||||
#
|
||||
# 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:
|
||||
@@ -106,6 +124,9 @@ 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
|
||||
|
||||
|
||||
@@ -11,25 +11,52 @@
|
||||
# admin install downloads the tarball, verifies it against the `sha256` in the
|
||||
# manifest, and unpacks it onto the volume. Nothing runs `npm` on the way.
|
||||
#
|
||||
# ── The version is DECLARED, not derived ────────────────────────────────────
|
||||
# ── The version is DERIVED, and the declaration is a floor ──────────────────
|
||||
#
|
||||
# Unlike RunicGateway/link and RunicGateway/installer, whose release engines read
|
||||
# conventional-commit subjects to compute the next version, this repo already has
|
||||
# one authoritative version — `module.json`'s, which is the version core records
|
||||
# in `installed_modules` and shows on the admin screen, and which sits beside the
|
||||
# `coreApi` range a bump usually has to be considered against. Two sources for one
|
||||
# number is how they drift, so: **a release happens when a merge to `main` leaves
|
||||
# `module.json` at a version that has no release yet.** Bumping the version is an
|
||||
# ordinary reviewed PR; publishing is this file's business.
|
||||
# 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.
|
||||
#
|
||||
# It follows that this workflow never writes to a branch — it tags and publishes,
|
||||
# nothing else — so `main` needs no push exception. That is the installer's model,
|
||||
# adopted here for the reason it was adopted there: `main` is protected, and a
|
||||
# release engine that has to push to it is a release engine that stops working the
|
||||
# day someone tightens the rule.
|
||||
# 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.
|
||||
# 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
|
||||
@@ -40,6 +67,16 @@ 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
|
||||
@@ -54,6 +91,8 @@ jobs:
|
||||
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
|
||||
@@ -62,32 +101,169 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Decide whether this commit releases
|
||||
- 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
|
||||
VERSION="$(node -p "require('./module.json').version")"
|
||||
echo "module.json version: ${VERSION}"
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
# Does a release already exist for this version? A 404 means no, a 200
|
||||
# means yes, and anything else — a network failure, a bad token — is not
|
||||
# evidence of absence. Guessing "no" would publish over a good release,
|
||||
# so refuse instead. (The installer learned this one the expensive way.)
|
||||
HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)"
|
||||
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>}"
|
||||
|
||||
case "$HTTP" in
|
||||
404) RELEASE=true ;;
|
||||
200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;;
|
||||
*) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;;
|
||||
esac
|
||||
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
@@ -125,25 +301,41 @@ jobs:
|
||||
# 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 dist && mkdir -p "$OUT"
|
||||
rm -rf "$OUT" && mkdir -p "$OUT"
|
||||
|
||||
# The manifest core reads, the two fragments, and the licence the code
|
||||
# is under — a bundle that ships GPL code without its licence is not
|
||||
# distributable.
|
||||
cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/"
|
||||
# The 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 boot.js core.js index.js config data db model router utils; do
|
||||
for d in $(jq -r '.server[]' ci/bundle.json); do
|
||||
cp -r "server/$d" "$OUT/server/"
|
||||
done
|
||||
cp server/package.json "$OUT/server/"
|
||||
cp -r server/node_modules "$OUT/server/"
|
||||
|
||||
# The client half is the BUILT chunk only. `client/src` is 5,000 lines
|
||||
@@ -154,16 +346,36 @@ jobs:
|
||||
# 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.
|
||||
# `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 = process.argv[1];
|
||||
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"
|
||||
' "$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"
|
||||
@@ -191,36 +403,10 @@ jobs:
|
||||
echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS
|
||||
cat "dist/module-uo-${VERSION}.json"
|
||||
|
||||
- name: Write the changelog
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||
RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD"
|
||||
{
|
||||
echo "## module-uo v${VERSION}"
|
||||
echo
|
||||
echo "Install from the website's Admin → Modules screen, or unpack onto the"
|
||||
echo "modules volume as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\`"
|
||||
echo "satisfies \`$(node -p "require('./module.json').coreApi")\`."
|
||||
echo
|
||||
echo "### Changes"
|
||||
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
|
||||
git log --no-merges --format='- %s' $RANGE || true
|
||||
echo
|
||||
echo "### Verifying this download"
|
||||
echo
|
||||
echo "Releases are **unsigned** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the"
|
||||
echo "trust anchor, and the website verifies it before unpacking."
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "sha256sum -c SHA256SUMS --ignore-missing"
|
||||
echo '```'
|
||||
} > dist/CHANGELOG.md
|
||||
|
||||
# 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' }}
|
||||
if: ${{ steps.plan.outputs.release == 'true' && steps.plan.outputs.reuse_tag != 'true' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
|
||||
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 }}
|
||||
@@ -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/) —
|
||||
|
||||
31
README.md
31
README.md
@@ -38,7 +38,7 @@ in the docs repo — **read them before opening a PR here.** Where the two diffe
|
||||
| 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 | ✅ done |
|
||||
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ |
|
||||
| 4 — delivery: the admin Modules screen + the Docker path | `website` | ✅ done |
|
||||
|
||||
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
|
||||
@@ -101,7 +101,7 @@ server/index.js the entry point — register(ctx, api), synchronous, no
|
||||
server/router/ routers + controllers, one directory per tier
|
||||
server/model/ one directory per table family; nothing crosses the boundary
|
||||
server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas
|
||||
server/config/ the push stream catalog
|
||||
server/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
|
||||
@@ -112,6 +112,14 @@ 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
|
||||
```
|
||||
|
||||
**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
|
||||
@@ -134,11 +142,20 @@ website. Module delivery is website-side only.
|
||||
|
||||
### Releases
|
||||
|
||||
A merge to `main` that leaves `module.json` at a version with no release yet publishes one. The
|
||||
version is **declared**, not computed from commit subjects: `module.json`'s version is what core
|
||||
records in `installed_modules` and shows on the admin screen, and it sits beside the `coreApi` range
|
||||
a bump usually has to be weighed against — two sources for one number is how they drift. Bumping it
|
||||
is an ordinary reviewed PR.
|
||||
**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:
|
||||
|
||||
|
||||
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"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.",
|
||||
"$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": "87230c879aa6e9adde3507718aed6bc4e4d86009",
|
||||
"refName": "edge @ phase 3 slice 4 (website#140)"
|
||||
"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}` : '')}`),
|
||||
|
||||
@@ -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'
|
||||
@@ -81,6 +82,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 /> },
|
||||
@@ -180,6 +182,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
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +119,7 @@ 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.public.length, 13)
|
||||
assert.equal(routes.admin.length, 7)
|
||||
assert.equal(routes.player.length, 2)
|
||||
for (const area of ['public', 'admin', 'player']) {
|
||||
@@ -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",
|
||||
|
||||
@@ -201,6 +201,11 @@
|
||||
"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",
|
||||
|
||||
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'
|
||||
}
|
||||
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,
|
||||
}
|
||||
2269
server/config/uoEventActions.js
Normal file
2269
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),
|
||||
|
||||
@@ -45,6 +45,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 7,
|
||||
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").
|
||||
@@ -629,6 +705,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 +746,94 @@ 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');
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 = ''
|
||||
@@ -388,5 +467,8 @@ module.exports = {
|
||||
listCreatureCompanions,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listDecorTypes,
|
||||
listSpawners,
|
||||
getDecorType,
|
||||
listChampions,
|
||||
}
|
||||
|
||||
@@ -403,6 +403,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 +538,9 @@ module.exports = {
|
||||
getCreature,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listDecorTypes,
|
||||
listSpawners,
|
||||
getDecorType,
|
||||
listChampions,
|
||||
listFacets,
|
||||
publicMeta,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -31,6 +31,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 +44,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 +101,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 +117,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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,29 @@ 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 7 because this build speaks protocol 7: the idempotency key and the
|
||||
// participation ledger (6), and the world verbs plus the targeted lease planes (7).
|
||||
//
|
||||
// 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) || 7
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) {
|
||||
@@ -85,4 +107,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 }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"scripts": {
|
||||
"test": "node --test --require ./test/_setup.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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
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).`)
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
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'))
|
||||
})
|
||||
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))
|
||||
|
||||
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'),
|
||||
|
||||
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'])
|
||||
})
|
||||
@@ -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,6 +39,21 @@ 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`),
|
||||
@@ -397,3 +412,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 })
|
||||
}
|
||||
})
|
||||
|
||||
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'])
|
||||
})
|
||||
885
server/test/uoEventActions.test.js
Normal file
885
server/test/uoEventActions.test.js
Normal file
@@ -0,0 +1,885 @@
|
||||
// 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')
|
||||
})
|
||||
|
||||
// ── 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)
|
||||
})
|
||||
1034
server/utils/shardEngagement.js
Normal file
1034
server/utils/shardEngagement.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,10 @@ const shardStateModel = require('../model/shardState/shardState.model')
|
||||
const shardLinksModel = require('../model/shardLinks/shardLinks.model')
|
||||
const shardMarketModel = require('../model/shardMarket/shardMarket.model')
|
||||
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const { settings: settingsModel } = require('../core')
|
||||
const { settings: settingsModel, events: coreEvents } = require('../core')
|
||||
const broadcaster = require('./shardBroadcast')
|
||||
const shardPush = require('./shardPush')
|
||||
const shardEngagement = require('./shardEngagement')
|
||||
const defaultLog = require('../core').logger('shard-ingest')
|
||||
|
||||
// Notable kinds appended to the shard_events log. High-frequency/session kinds
|
||||
@@ -45,6 +46,12 @@ const LOGGED_KINDS = new Set([
|
||||
'server.crashed',
|
||||
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
|
||||
'guild.join',
|
||||
// Protocol 4: the departure counterpart to guild.join, and logged for the same
|
||||
// reason — it is what a "so-and-so left" feed reads. `guild.roster` deliberately
|
||||
// stays out: it is board state like guild.update, and it is the one fat frame on
|
||||
// the wire (~69 bytes per member), so logging it would bloat shard_events with
|
||||
// a full membership snapshot on every membership change.
|
||||
'guild.leave',
|
||||
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
|
||||
'account.audit',
|
||||
'account.unlinked',
|
||||
@@ -56,6 +63,11 @@ const LOGGED_KINDS = new Set([
|
||||
const state = { bootId: null }
|
||||
function reset() {
|
||||
state.bootId = null
|
||||
// The engagement mapper's transition/threshold tracker is per-process state of
|
||||
// exactly the same kind as `bootId`, so it is reset by the same call. A test
|
||||
// that reset one and not the other would see a champion spawn that started in
|
||||
// the previous test.
|
||||
shardEngagement.reset()
|
||||
}
|
||||
|
||||
// Should this event be written to the append-only log?
|
||||
@@ -91,11 +103,12 @@ async function resolveShardName(shard, deps) {
|
||||
|
||||
// Apply the state-change side effect for a kind (if any). Returns a promise.
|
||||
async function applyStateChange(event, deps) {
|
||||
const { shardState, uoLinkConfig, log } = deps
|
||||
const { shardState, uoLinkConfig, eventsReconcile, fromBackfill, log } = deps
|
||||
switch (event.kind) {
|
||||
case 'server.hello': {
|
||||
const incoming = event.bootId || null
|
||||
if (incoming && state.bootId && incoming !== state.bootId) {
|
||||
const restarted = Boolean(incoming && state.bootId && incoming !== state.bootId)
|
||||
if (restarted) {
|
||||
log.warn('shard restarted (bootId changed) — clearing online roster', {
|
||||
from: state.bootId,
|
||||
to: incoming,
|
||||
@@ -104,6 +117,31 @@ async function applyStateChange(event, deps) {
|
||||
}
|
||||
if (incoming) state.bootId = incoming
|
||||
await uoLinkConfig.recordStatus({ pluginConnected: true, bootId: incoming, lastEventAt: event.t })
|
||||
if (restarted && !fromBackfill) {
|
||||
// EVENTS.md F: core has no concept of the game being up, so the module
|
||||
// says when a ledger of live shard resources has become a claim about a
|
||||
// world that no longer exists. This is that moment, and a changed
|
||||
// `bootId` is the only thing that distinguishes it from a sidecar
|
||||
// reconnect — which changes nothing in the game and must not orphan a row.
|
||||
//
|
||||
// **After `recordStatus`, and that ordering is load-bearing.** Every
|
||||
// action's `reconcile()` decides what is still in force by comparing its
|
||||
// stamp against the CURRENT boot id, which it reads back out of this
|
||||
// row. Asking first would have every resource compared against the boot
|
||||
// that has just ended, and every one of them would look live.
|
||||
//
|
||||
// **And never on a backfill replay**, which is the same rule the
|
||||
// engagement fan-out and the SSE broadcast state below and is far more
|
||||
// expensive to break here. A 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, and
|
||||
// the intermediate ones would compare a resource stamped with the CURRENT
|
||||
// boot against a boot that ended hours ago. The row is then `orphaned`:
|
||||
// a live crier line core will never take down again, lost to nothing
|
||||
// worse than the website reconnecting. The website-was-down case is not
|
||||
// missed by skipping these — core asks every module at its own boot.
|
||||
eventsReconcile()
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'server.shutdown':
|
||||
@@ -163,8 +201,14 @@ async function applyStateChange(event, deps) {
|
||||
name: event.name,
|
||||
ownerSerial: event.ownerSerial,
|
||||
ownerAcct: event.ownerAcct,
|
||||
// Protocol 5. `ownerName` used to arrive only on house.update, so a house
|
||||
// that had decayed but never been swept into the registry named an account
|
||||
// and no character. It rides house.decay now, which is the frame the IDOC
|
||||
// page is actually built from.
|
||||
ownerName: event.ownerName,
|
||||
builtOn: event.builtOn,
|
||||
lastRefreshed: event.lastRefreshed,
|
||||
schedule: event.schedule,
|
||||
})
|
||||
return
|
||||
case 'champ.update':
|
||||
@@ -187,6 +231,16 @@ async function applyStateChange(event, deps) {
|
||||
case 'guild.remove':
|
||||
await shardState.removeGuild(event.id)
|
||||
return
|
||||
// Protocol 4: membership. A roster arrives in one frame for any realistic
|
||||
// guild and in several for one over the shard's cap — upsertGuildRoster
|
||||
// handles both. guild.leave is advisory; the next roster would converge
|
||||
// anyway, but applying it shows the departure at once.
|
||||
case 'guild.roster':
|
||||
await shardState.upsertGuildRoster(event)
|
||||
return
|
||||
case 'guild.leave':
|
||||
await shardState.removeGuildMember(event)
|
||||
return
|
||||
case 'city.update':
|
||||
// Upserts the board AND captures term history (idempotent).
|
||||
await shardState.upsertGovernor(event)
|
||||
@@ -263,6 +317,16 @@ function resolveDeps(deps) {
|
||||
settings: deps.settings || settingsModel,
|
||||
broadcast: deps.broadcast || broadcaster.broadcast,
|
||||
pushDispatch: deps.pushDispatch || shardPush.fromShardEvent,
|
||||
engagement: deps.engagement || shardEngagement.fromShardEvent,
|
||||
// MODULE_API 1.10.0 (EVENTS.md F, Phase 8). Injectable for the same reason
|
||||
// every member above is: a test that asserted a shard restart triggers a
|
||||
// reconcile must be able to see the call without a live event engine behind
|
||||
// it.
|
||||
eventsReconcile: deps.eventsReconcile || (() => coreEvents.reconcile()),
|
||||
// Not injectable — it is the caller's statement about this frame rather than
|
||||
// a dependency. It reaches `applyStateChange` because the reconcile below is
|
||||
// the one state change that must not act on a replay; see the note there.
|
||||
fromBackfill: Boolean(deps.fromBackfill),
|
||||
log: deps.log || defaultLog,
|
||||
}
|
||||
}
|
||||
@@ -278,6 +342,34 @@ async function ingest(event, deps = {}) {
|
||||
let stored = false
|
||||
let logged = false
|
||||
|
||||
// **The engagement fan-out runs BEFORE the state write, and that ordering is
|
||||
// load-bearing rather than incidental** (ENGAGEMENT.md Phase 11). Three of the
|
||||
// mappings read a row that `applyStateChange` is about to delete or replace:
|
||||
//
|
||||
// • `account.unlinked` drops the `shard_account_links` row — the row that
|
||||
// turns the account into the one person who needs to be told it was
|
||||
// unlinked. Resolving afterwards finds nobody, every time.
|
||||
// • `house.remove` drops the house, whose stored `ownerAcct` is the only place
|
||||
// the owner of a collapsed house is named (the frame carries a serial alone).
|
||||
// • `guild.leave` / `guild.remove` need the roster and the board mirror to
|
||||
// name who left and which guild it was.
|
||||
//
|
||||
// Awaited, unlike the broadcast and the push tickle below, and this is the one
|
||||
// place this file waits on a notification path. It has to: the whole point is
|
||||
// that the read happens first, and a fire-and-forget promise would race the
|
||||
// DELETE it is trying to precede. `fromShardEvent` never throws and never opens
|
||||
// a socket — it resolves ids and hands the engine an envelope, which does its
|
||||
// own work off the caller's stack (`emit` is deliberately not awaited inside).
|
||||
// Backfilled frames are excluded for the same reason the broadcast is: a
|
||||
// reconnect replay must not re-notify anyone about events from hours ago.
|
||||
if (!deps.fromBackfill) {
|
||||
try {
|
||||
await d.engagement(event)
|
||||
} catch (err) {
|
||||
d.log.warn('engagement fan-out failed', { kind: event.kind, message: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await applyStateChange(event, d)
|
||||
} catch (err) {
|
||||
|
||||
@@ -83,7 +83,27 @@ const FEATURES = {
|
||||
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||
status: { audience: 'anonymous', fields: {} },
|
||||
activity: { audience: 'anonymous', fields: {} },
|
||||
champs: { audience: 'anonymous', fields: {} },
|
||||
// Protocol 6 adds `champ.boss.killed` to this feature, and with it the first
|
||||
// field on a champs frame that is about PEOPLE rather than about an altar.
|
||||
//
|
||||
// `damagers` is the ranked table of who fought the boss and for how much. It is
|
||||
// the honest basis for "who slew the champion" and it is also a performance
|
||||
// record of named players that nobody consented to publish, which is precisely
|
||||
// the tension the ladder exists to let a shard resolve for itself. It defaults
|
||||
// to `staff`: the kill is public (a champion falling is announced in-world and
|
||||
// is the content the board is for), the roll of who did the damage is not. A
|
||||
// shard that wants a public board lowers one rule.
|
||||
//
|
||||
// Nested for the same reason `market.fees` and `houses.schedule` are: one rule
|
||||
// covers the whole table rather than a rule per column, and the columns here
|
||||
// are actor objects whose `acct`/`webId` remain admin-only by the locked-field
|
||||
// rule regardless of what this is set to.
|
||||
//
|
||||
// `killer` is deliberately NOT listed. It is the single actor whose blow landed
|
||||
// last, it is announced in-game to everyone present, and it is the same shape
|
||||
// and the same disclosure `mob.killed` has published on the public activity
|
||||
// feed since before this framework existed.
|
||||
champs: { audience: 'anonymous', fields: { damagers: 'staff' } },
|
||||
guilds: { audience: 'anonymous', fields: {} },
|
||||
governors: { audience: 'anonymous', fields: {} },
|
||||
// The public Houses page showed IDOC location only; owner/price were staff.
|
||||
@@ -91,9 +111,22 @@ const FEATURES = {
|
||||
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
|
||||
// REST read models. Both are listed so one rule covers the wire and the read
|
||||
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
|
||||
// Protocol 5 adds `schedule` — when the next stage lands and, where ServUO can
|
||||
// actually know it, when the house collapses. It defaults to `anonymous` because
|
||||
// that is what the public IDOC page is FOR: the countdown is the content, and a
|
||||
// house at IDOC is already announced in game. It is listed rather than left
|
||||
// unconfigurable so a shard that considers a precise collapse time an unfair
|
||||
// advantage can raise it, and it is one NESTED key so raising it hides the whole
|
||||
// schedule rather than three of its four parts.
|
||||
houses: {
|
||||
audience: 'anonymous',
|
||||
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
|
||||
fields: {
|
||||
owner: 'staff',
|
||||
ownerName: 'staff',
|
||||
ownerSerial: 'staff',
|
||||
price: 'staff',
|
||||
schedule: 'anonymous',
|
||||
},
|
||||
},
|
||||
// /public/shard/online listed linked staff to everyone but gated location to
|
||||
// admin+moderator — which is exactly the `staff` rung.
|
||||
@@ -124,9 +157,25 @@ const FEATURES = {
|
||||
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
|
||||
// lists both: an admin who hides the owner's name and is left with a serial
|
||||
// that every other board resolves back to that name has not hidden anything.
|
||||
// Protocol 5 adds `fees`, and it does NOT follow the rest of this feature's
|
||||
// defaults. The shop name, the owner and the location are already visible to any
|
||||
// player through the stock in-game Vendor Search gump, which is the whole argument
|
||||
// for publishing them. A vendor's held gold, daily charge and dismissal date are
|
||||
// not: in game they are visible to the OWNER, on that vendor's own gump. Publishing
|
||||
// them anonymously would be a genuinely new disclosure and a targeting aid — it
|
||||
// says which shops are about to be abandoned and how much coin is sitting in each.
|
||||
// So it defaults to `admin`, the only default here that does not reproduce prior
|
||||
// behaviour, because there is no prior behaviour to reproduce.
|
||||
//
|
||||
// Nested for the same reason `location` is: one rule covers all seven parts.
|
||||
market: {
|
||||
audience: 'anonymous',
|
||||
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
|
||||
fields: {
|
||||
ownerName: 'anonymous',
|
||||
ownerSerial: 'anonymous',
|
||||
location: 'anonymous',
|
||||
fees: 'admin',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -160,9 +209,20 @@ const KIND_FEATURE = new Map(
|
||||
// boards
|
||||
'champ.update': 'champs',
|
||||
'champ.remove': 'champs',
|
||||
// Protocol 6. Without this line rule 2 would fail the new kind closed to
|
||||
// admin-only — correct as a default, and wrong as an outcome: a champion
|
||||
// falling is exactly what the public board is for.
|
||||
'champ.boss.killed': 'champs',
|
||||
'guild.update': 'guilds',
|
||||
'guild.remove': 'guilds',
|
||||
'guild.join': 'guilds',
|
||||
// Protocol 4. Both carry actor data — a roster is an array of actor objects
|
||||
// and guild.leave names a serial — so they ride the same `guilds` feature and
|
||||
// the same locked-field rules: `acct`/`webId` inside a roster member are
|
||||
// stripped below admin by suffix, exactly as `guild.leader.acct` already is.
|
||||
// Without these two lines rule 2 would fail them closed to admin-only.
|
||||
'guild.roster': 'guilds',
|
||||
'guild.leave': 'guilds',
|
||||
'city.update': 'governors',
|
||||
'presence.online': 'presence',
|
||||
'region.enter': 'presence',
|
||||
@@ -170,6 +230,13 @@ const KIND_FEATURE = new Map(
|
||||
// registry (house.update / house.remove — owner, price, co-owners) stays
|
||||
// off the map deliberately, so it remains admin-only exactly as before.
|
||||
'house.decay': 'houses',
|
||||
// Protocol 5's `account.login.result` is deliberately NOT here, and the omission
|
||||
// is the decision rather than an oversight. Rule 2 fails an unmapped kind closed
|
||||
// to admin-only, which is the right answer for a frame that carries an IP address
|
||||
// and says whether a password was accepted — the same reasoning that keeps
|
||||
// house.update and account.login.attempt off this map. Adding it would mean
|
||||
// choosing a feature an admin could then widen, and there is no rung below admin
|
||||
// this frame belongs on.
|
||||
// v3
|
||||
'world.ruleset': 'ruleset',
|
||||
'points.board': 'leaderboards',
|
||||
@@ -179,6 +246,14 @@ const KIND_FEATURE = new Map(
|
||||
// needs it live. An admin can turn it on.
|
||||
'vendor.listing': 'market',
|
||||
'vendor.listing.remove': 'market',
|
||||
// Protocol 6 part b's `lease.applied` and `lease.expired` are deliberately NOT
|
||||
// here, on the same reasoning that keeps `account.login.result` off it. They are
|
||||
// operational frames about the WEBSITE changing this shard's configuration --
|
||||
// which key, from what to what, on whose run, and whether the shard's own
|
||||
// deadline had to put it back because nobody asked. Rule 2 fails an unmapped
|
||||
// kind closed to admin-only, which is where an audit trail of the site's writes
|
||||
// belongs; mapping them would mean choosing a feature an operator could then
|
||||
// widen, and there is no rung below admin these frames belong on.
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -351,10 +351,17 @@ function tagValue(block, name) {
|
||||
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
|
||||
* flat, so a per-record regex sweep is both correct and cheap.
|
||||
*
|
||||
* Only the fields the site can actually show are kept. Everything to do with
|
||||
* triggering, refractory windows, proximity, sequential spawning, sounds and
|
||||
* `UniqueId` is dropped here rather than downstream — that is what holds the
|
||||
* committed artifact under 1 MB.
|
||||
* Only the fields the site can actually use are kept. Everything to do with
|
||||
* triggering, refractory windows, proximity, sequential spawning and sounds is
|
||||
* dropped here rather than downstream, which is what keeps the parsed atlas
|
||||
* small.
|
||||
*
|
||||
* **`UniqueId` was on that list until Phase 12b and is now kept**, because a
|
||||
* property lease has to name one particular spawner and this is the only name
|
||||
* for one that exists off-shard. The line that justified dropping it cited a
|
||||
* committed artifact; there is no committed artifact — `spawnAtlasSource.js`
|
||||
* says so in its own header ("nothing is precomputed and committed") — so the
|
||||
* only real cost was ~37 bytes a row in a table, and it bought a dropdown.
|
||||
*
|
||||
* NOTE: the facet comes from each record's own `<Map>`, never from the file
|
||||
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
||||
@@ -389,6 +396,14 @@ function parsePoints(source) {
|
||||
|
||||
points.push({
|
||||
name: tagValue(block, 'Name'),
|
||||
// **Kept from Phase 12b, having been discarded since the atlas shipped.**
|
||||
// It is `XmlSpawner.UniqueId` — the shard writes it into the spawn files
|
||||
// and carries it on the live spawner — so it is the ONE way an authoring
|
||||
// form can name a particular spawner without the shard being up. A serial
|
||||
// cannot do that job: serials are assigned when the world is built and
|
||||
// nothing off-shard knows them, which is why a property lease that could
|
||||
// only be addressed by serial could have no dropdown at all.
|
||||
uniqueId: tagValue(block, 'UniqueId'),
|
||||
facet,
|
||||
x: toInt(tagValue(block, 'X')),
|
||||
y: toInt(tagValue(block, 'Y')),
|
||||
@@ -548,6 +563,48 @@ function walkLocations(node, facet, path, out) {
|
||||
* A spawn with no `type` is randomised on every activation, which the site must
|
||||
* render as "random" rather than as an empty type.
|
||||
*/
|
||||
/**
|
||||
* Item types a shard uses as decoration, from one `Data/Decoration/*.cfg`.
|
||||
*
|
||||
* The format is a header line naming a type and an item id, optionally followed
|
||||
* by a parenthesised property list, and then one `x y z` line per placement:
|
||||
*
|
||||
* ```
|
||||
* # switch
|
||||
* Static 0x108F
|
||||
* 5552 1864 11
|
||||
* ```
|
||||
*
|
||||
* Only the header matters here. The properties are decoration-authoring details
|
||||
* (`Hue=`, `Facing=`, `Name=`) and the coordinates are where the SHARD put its
|
||||
* own scenery, neither of which an event author is choosing — they pick a type
|
||||
* and a place of their own.
|
||||
*
|
||||
* Returns one entry per header line, not per distinct type: the same type
|
||||
* appears under many item ids (a `BarredMetalDoor` for each facing), and how
|
||||
* often a shard reaches for something is worth keeping. `spawnAtlasSource`
|
||||
* aggregates.
|
||||
*/
|
||||
function parseDecoration(source) {
|
||||
const out = []
|
||||
if (!source) return out
|
||||
|
||||
for (const raw of String(source).split(/\r?\n/)) {
|
||||
const line = raw.trim()
|
||||
|
||||
// A coordinate line starts with a digit or a minus (z is often negative),
|
||||
// so the type test is not merely "not a comment".
|
||||
if (line === '' || line.startsWith('#')) continue
|
||||
|
||||
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s+0x([0-9A-Fa-f]+)/.exec(line)
|
||||
if (!match) continue
|
||||
|
||||
out.push({ type: match[1], itemId: parseInt(match[2], 16) })
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function parseChampions(source) {
|
||||
const root = parseXml(source)
|
||||
const champions = []
|
||||
@@ -675,6 +732,7 @@ module.exports = {
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
parseDecoration,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
facetKey,
|
||||
|
||||
@@ -23,6 +23,7 @@ const {
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
parseDecoration,
|
||||
buildPlacementIndex,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
@@ -35,6 +36,7 @@ const REGIONS_FILE = path.join('Data', 'Regions.xml')
|
||||
const LOCATIONS_DIR = path.join('Data', 'Locations')
|
||||
const SPAWNS_DIR = 'Spawns'
|
||||
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
|
||||
const DECORATION_DIR = path.join('Data', 'Decoration')
|
||||
|
||||
class AtlasSourceError extends Error {
|
||||
constructor(message, code) {
|
||||
@@ -62,6 +64,33 @@ function listXml(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `.cfg` under `dir`, recursively, tree-relative and forward-slashed.
|
||||
*
|
||||
* Recursive because `Data/Decoration` nests two deep in places
|
||||
* (`Magincia/Trammel`, `Stygian Abyss/Ter Mur`, `Old/Britannia`) and a flat read
|
||||
* would silently index a third of what the shard actually has — the failure
|
||||
* mode being a dropdown that is quietly missing whole expansions rather than an
|
||||
* error anyone would notice.
|
||||
*/
|
||||
function listCfgTree(dir, prefix = '') {
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
|
||||
throw err
|
||||
}
|
||||
|
||||
const out = []
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name
|
||||
if (entry.isDirectory()) out.push(...listCfgTree(path.join(dir, entry.name), rel))
|
||||
else if (entry.name.toLowerCase().endsWith('.cfg')) out.push(rel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function readIfPresent(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
@@ -111,6 +140,12 @@ function readSources(root) {
|
||||
|
||||
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
|
||||
|
||||
// Optional, like the champion file: a shard that has stripped its decoration still
|
||||
// has a usable atlas, it just cannot offer the decoration verb anything to place.
|
||||
for (const rel of listCfgTree(path.join(root, DECORATION_DIR))) {
|
||||
push(`Data/Decoration/${rel}`, path.join(root, DECORATION_DIR, rel))
|
||||
}
|
||||
|
||||
return { files }
|
||||
}
|
||||
|
||||
@@ -140,8 +175,13 @@ function hashSources(root) {
|
||||
*
|
||||
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
||||
* seconds in the source, decided by `DelayInSec`).
|
||||
* 3 — the decoration index, from `Data/Decoration/**\/*.cfg`.
|
||||
* 4 — a spawn point keeps its `UniqueId`, which is what a property lease
|
||||
* targets (Phase 12b). The bump is what re-reads a tree the boot path
|
||||
* would otherwise skip on an unchanged hash — the source files have not
|
||||
* changed, only what is kept from them.
|
||||
*/
|
||||
const PARSER_VERSION = 2
|
||||
const PARSER_VERSION = 4
|
||||
|
||||
/** True when two source fingerprints describe the same tree. */
|
||||
function sameSources(a, b) {
|
||||
@@ -294,6 +334,43 @@ function buildAtlas(root, options = {}) {
|
||||
}
|
||||
})
|
||||
|
||||
// Decoration: what this shard already calls scenery, which is what makes the
|
||||
// authoring dropdown the operator's own vocabulary rather than our taste.
|
||||
//
|
||||
// **Keyed case-INSENSITIVELY, because the decoration files disagree with
|
||||
// themselves about casing.** Stock 57.4 names four types under two spellings
|
||||
// each — `CheckerBoard`/`Checkerboard`, `ChessBoard`/`Chessboard`,
|
||||
// `MetalChest`/`Metalchest`, `SpinningWheelEastAddon`/`SpinningwheelEastAddon`
|
||||
// — and in every pair exactly one is a real class, the other a mis-cased line
|
||||
// the shard's own loader resolves anyway. A case-sensitive Map keeps both, and
|
||||
// then `shard_decor_types.type` (a PRIMARY KEY under MariaDB's default
|
||||
// `..._ai_ci` collation, which folds case) rejects the second row and takes the
|
||||
// WHOLE import transaction down with it. That is not a decoration bug: with no
|
||||
// atlas, every option source answers empty and no world verb can be authored at
|
||||
// all. The shard end of this feature already knew — `BridgeWorld.cs` resolves a
|
||||
// decor type with `FindTypeByName(name, ignoreCase: true)` and says why — so
|
||||
// folding here is the two ends agreeing rather than a new rule.
|
||||
//
|
||||
// The first spelling seen wins, exactly as the first item id does. Either
|
||||
// spelling resolves on the shard, so which one survives is cosmetic.
|
||||
const decorUses = new Map()
|
||||
for (const file of files) {
|
||||
if (!file.label.startsWith('Data/Decoration/')) continue
|
||||
for (const entry of parseDecoration(file.text)) {
|
||||
const key = entry.type.toLowerCase()
|
||||
const seen = decorUses.get(key)
|
||||
if (seen) {
|
||||
seen.uses += 1
|
||||
continue
|
||||
}
|
||||
// The FIRST item id wins, and it is only a preview: a type appears under
|
||||
// as many ids as it has facings or variants, and picking one arbitrarily
|
||||
// is honest in a way that picking "the most used" would not be.
|
||||
decorUses.set(key, { type: entry.type, itemId: entry.itemId, uses: 1 })
|
||||
}
|
||||
}
|
||||
const decor = [...decorUses.values()].sort((a, b) => a.type.localeCompare(b.type))
|
||||
|
||||
const creatures = aggregateCreatures(points)
|
||||
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
||||
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
||||
@@ -311,6 +388,7 @@ function buildAtlas(root, options = {}) {
|
||||
regions: regions.length,
|
||||
landmarks: landmarks.length,
|
||||
champions: champions.length,
|
||||
decor: decor.length,
|
||||
unresolvedPoints: unresolved,
|
||||
},
|
||||
source,
|
||||
@@ -321,6 +399,7 @@ function buildAtlas(root, options = {}) {
|
||||
landmarks,
|
||||
champions,
|
||||
points,
|
||||
decor,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,48 @@
|
||||
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
|
||||
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
|
||||
// token on every call.
|
||||
//
|
||||
// ── Protocol 6: `idempotencyKey` on a write ────────────────────────────────
|
||||
//
|
||||
// The three write helpers the event engine drives take an optional
|
||||
// `idempotencyKey`, which the sidecar passes to the shard verbatim. The shard
|
||||
// executes a key at most once and answers a repeat with the ORIGINAL reply, which
|
||||
// is what makes retrying a world write safe — before it, a lost acknowledgement
|
||||
// and a command that never applied were the same event seen from here.
|
||||
//
|
||||
// **A key is a function of the caller's unit of work, never of the attempt.** The
|
||||
// event runner derives it from `sha256(runId|stepId)`, so every retry of one step
|
||||
// carries the same key and a different step never collides with it. Passing a
|
||||
// fresh value per call would satisfy the type and defeat the entire mechanism.
|
||||
//
|
||||
// **The DELETEs deliberately take no key.** Their idempotency is inherent — the
|
||||
// second removal of a town-crier entry or a news article is a no-op the shard is
|
||||
// already happy to perform — and the sidecar builds those commands from the path
|
||||
// rather than from a body, so carrying one would be a protocol change bought for
|
||||
// a guarantee that already holds.
|
||||
//
|
||||
// A caller that sends no key gets exactly the pre-protocol-6 behaviour, which is
|
||||
// what leaves the admin screens (which send none, being driven by a human who can
|
||||
// see whether the thing happened) unchanged.
|
||||
//
|
||||
// One new status can now come back from a keyed write: **425**, the sidecar's
|
||||
// mapping of `bridge.busy` — a command under this key is still in flight on the
|
||||
// shard. It is transient and retryable, and `shardAnnounce.classify` already
|
||||
// treats it so by falling through to its retry case.
|
||||
|
||||
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
||||
const log = require('../core').logger('uo-link-client')
|
||||
|
||||
const TIMEOUT_MS = 12000 // sidecar waits up to 10s on the shard before 504
|
||||
// The sidecar waits up to 10s on the shard before answering 504, so this sits
|
||||
// just above it — every call answers rather than being abandoned mid-flight.
|
||||
//
|
||||
// **Exported because the event actions are declared against it** (EVENTS_PLAN.md
|
||||
// Phase 9). An action's `budgetMs` must exceed this or core's dispatch deadline
|
||||
// fires first and classifies the step `retry` without asking the module, which
|
||||
// for a broadcast means announcing twice. `config/uoEventActions.js` states that
|
||||
// relationship and its test asserts it, and both need the number to come from
|
||||
// here rather than from a copy that can drift.
|
||||
const TIMEOUT_MS = 12000
|
||||
const CONFIG_TTL_MS = 5000
|
||||
|
||||
let cachedConfig = null
|
||||
@@ -159,15 +196,18 @@ const createAccount = ({ actor, account, password, websiteUserId, ip }) =>
|
||||
})
|
||||
const unlinkAccount = ({ actor, account }) =>
|
||||
call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } })
|
||||
const postTownCrier = ({ id, lines, durationSec }) =>
|
||||
call('/towncrier', { method: 'POST', body: { id, lines, durationSec } })
|
||||
const postTownCrier = ({ id, lines, durationSec, idempotencyKey }) =>
|
||||
call('/towncrier', { method: 'POST', body: { id, lines, durationSec, idempotencyKey } })
|
||||
const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
|
||||
// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL)
|
||||
// in the in-game News window; re-posting the same id REPLACES it. `announce`
|
||||
// (default true on the sidecar) controls whether the criers proclaim the title.
|
||||
const postNews = ({ id, title, body, image, url, announce }) =>
|
||||
call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } })
|
||||
const postNews = ({ id, title, body, image, url, announce, idempotencyKey }) =>
|
||||
call('/news', {
|
||||
method: 'POST',
|
||||
body: { id: String(id), title, body, image, url, announce, idempotencyKey },
|
||||
})
|
||||
const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
|
||||
// ── Staff write plane (§6) ─────────────────────────────────────────────────
|
||||
@@ -180,15 +220,166 @@ const adminBan = ({ actor, account, serial, durationSec, reason }) =>
|
||||
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
|
||||
const adminUnban = ({ actor, account }) =>
|
||||
call('/admin/unban', { method: 'POST', body: { actor, account } })
|
||||
const adminBroadcast = ({ actor, text, hue }) =>
|
||||
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
|
||||
const adminBroadcast = ({ actor, text, hue, idempotencyKey }) =>
|
||||
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue, idempotencyKey } })
|
||||
|
||||
// ── The event plane (protocol 6, EVENTS_PLAN.md Phase 11b) ─────────────────
|
||||
//
|
||||
// Leases and the run-scoped participation ledger. Both are gated on the shard by
|
||||
// `Bridge.EventsEnabled`, which is deliberately NOT the admin plane's switch: an
|
||||
// operator consenting to staff moderation from a screen has not thereby consented
|
||||
// to the website changing their world on a schedule at four in the morning. A
|
||||
// shard with the plane off answers 403, and the actions turn that into a refusal
|
||||
// an author can read rather than a retry.
|
||||
|
||||
// Every lease this shard offers, with what each is worth right now and what is
|
||||
// holding it. One read serves both questions core asks — `read()` wants the
|
||||
// current value, `inForce()` wants to know whether the shard still has a record
|
||||
// of the hold — so a lease costs one round trip, not two.
|
||||
// **A targeted lease must name its target here** (protocol 7 part b). A key like
|
||||
// `Spawner.MaxCount` is one capability over thousands of spawners, so it has no
|
||||
// single `current` and the catalog walk cannot fill one in — while `read()` needs
|
||||
// exactly one value for exactly one target before it applies anything. Naming both
|
||||
// narrows the frame to that row and fills it.
|
||||
//
|
||||
// The frame also carries `holds`: every hold this shard has, whatever key or
|
||||
// target. A catalog walk enumerates the KEYS but can never enumerate the holds on
|
||||
// a targeted one — there is no list of spawners to walk — so `inForce()` reads
|
||||
// that rather than the row's `held` flag.
|
||||
const getLeases = ({ key, target } = {}) => {
|
||||
const params = new URLSearchParams()
|
||||
if (key) params.set('key', key)
|
||||
if (target) params.set('target', target)
|
||||
const query = params.toString()
|
||||
return call(query ? `/lease?${query}` : '/lease')
|
||||
}
|
||||
|
||||
// `holdMs` is authoritative and `untilMs` is display only. An absolute deadline
|
||||
// computed here and honoured there is a deadline measured against two clocks, and
|
||||
// a shard running ten minutes fast would restore a ten-minute lease the moment it
|
||||
// took it. Values cross as TEXT whatever the lease's declared type: `1200` and
|
||||
// `1200.0` are one number to a JSON parser and two strings to a compare-and-set.
|
||||
const applyLease = ({ key, target, value, holdMs, untilMs, runId, idempotencyKey }) =>
|
||||
call('/lease', {
|
||||
method: 'POST',
|
||||
body: { key, target, value: String(value), holdMs, untilMs, runId, idempotencyKey },
|
||||
})
|
||||
|
||||
// `expected` is what this run applied and `baseline` is what to put back, both out
|
||||
// of core's ledger rather than the shard's memory — so a release still works after
|
||||
// a reconnect, and a shard that has forgotten the lease entirely (a restart, which
|
||||
// reverts every config lease by design) answers honestly instead of refusing.
|
||||
const releaseLease = ({ key, target, expected, baseline, idempotencyKey }) =>
|
||||
call('/lease/release', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key,
|
||||
target,
|
||||
expected: expected == null ? undefined : String(expected),
|
||||
baseline: baseline == null ? undefined : String(baseline),
|
||||
idempotencyKey,
|
||||
},
|
||||
})
|
||||
|
||||
// The participation ledger. The area is a map, a point and a radius rather than a
|
||||
// region name, because protocol 6's own walk established that the most specific
|
||||
// region containing an event is routinely anonymous.
|
||||
const openParticipation = ({ runId, map, x, y, radius, holdMs, idempotencyKey }) =>
|
||||
call('/participation', {
|
||||
method: 'POST',
|
||||
body: { runId: String(runId), map, x, y, radius, holdMs, idempotencyKey },
|
||||
})
|
||||
|
||||
// A POST for a read, and the reason is the phase's headline: on a well-attended
|
||||
// run the shard walks its members across Core ticks rather than in one inbound
|
||||
// call, so a repeat arriving mid-walk is answered `bridge.busy` (425). A read that
|
||||
// can legitimately be refused as a repeat in flight is not a GET.
|
||||
const snapshotParticipation = ({ runId, idempotencyKey }) =>
|
||||
call(`/participation/${encodeURIComponent(runId)}/snapshot`, {
|
||||
method: 'POST',
|
||||
body: { idempotencyKey },
|
||||
})
|
||||
|
||||
const closeParticipation = ({ runId, idempotencyKey }) =>
|
||||
call(`/participation/${encodeURIComponent(runId)}/close`, {
|
||||
method: 'POST',
|
||||
body: { idempotencyKey },
|
||||
})
|
||||
|
||||
// ── The world verbs (protocol 7) ───────────────────────────────
|
||||
//
|
||||
// One endpoint for five author-facing verbs. `what` is the discriminator, and the
|
||||
// per-verb fields ride alongside it: `type`/`name`/`hue`/`spread` for creatures and
|
||||
// decoration, the three multipliers for a boss, `greeting`/`lines` for an oracle,
|
||||
// `target`/`holdMs` for a gate.
|
||||
//
|
||||
// The shard registers every serial it places against the run and persists that
|
||||
// registry, which is what makes `despawnWorld` below safe to point at a list of
|
||||
// serials: it can only delete what the run actually owns.
|
||||
const spawnWorld = (body) => call('/world', { method: 'POST', body })
|
||||
|
||||
// What the run still owns. A GET, unlike the participation snapshot: it carries no
|
||||
// idempotency key and the shard answers it in one pass. An unknown run answers with an
|
||||
// empty hand rather than a 404 — "owns nothing" and "never heard of it" are the same
|
||||
// fact once the registry is the only record, and they stay the same fact across a
|
||||
// restart, because the registry is written by the same world save as the objects it
|
||||
// describes.
|
||||
const ownedWorld = ({ runId }) => call(`/world/${encodeURIComponent(runId)}`)
|
||||
|
||||
// Give back what the run owns. No `serials` means everything, which is the call
|
||||
// teardown makes. The reply splits three ways: `removed` was deleted, `gone` was
|
||||
// already absent (a player killed it — an ordinary success), and `refused` was never
|
||||
// this run's to delete.
|
||||
//
|
||||
// **It takes no idempotency key, and the parameter is gone rather than optional.**
|
||||
// It used to accept one, and `revertOwned` passed the step's — the key the SPAWN
|
||||
// went out under. The shard's at-most-once store is keyed on the key alone, so the
|
||||
// despawn was answered with the spawn's stored reply and nothing was ever deleted.
|
||||
// A repeat despawn needs no key: the second pass answers `gone`, which both ends
|
||||
// already treat as a success. Removed from the signature so it cannot be handed
|
||||
// one again by accident.
|
||||
const despawnWorld = ({ runId, serials }) =>
|
||||
call(`/world/${encodeURIComponent(runId)}/despawn`, {
|
||||
method: 'POST',
|
||||
body: { serials },
|
||||
})
|
||||
|
||||
// ── Help-page (support) queue commands (§6) ────────────────────────────────
|
||||
const respondPage = (pageId, { message, close }) =>
|
||||
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
||||
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
|
||||
|
||||
// ── The one-shots (protocol 7 part b, EVENTS_PLAN.md Phase 12b) ────────────
|
||||
//
|
||||
// Neither owned nor borrowed: done is done. Both are gated on the shard by the
|
||||
// same `Bridge.EventsEnabled` as the rest of the plane.
|
||||
|
||||
// What this shard will actually build, with the bounds it will build within. The
|
||||
// module holds the same allowlist for its dropdown, so the form still works with
|
||||
// the shard down; this is what is true when that copy is wrong.
|
||||
const getGrantCatalog = () => call('/items')
|
||||
|
||||
// **The recipients are not sent.** The shard has held this run's participation
|
||||
// ledger since it opened, keyed by the same character serials core stores as
|
||||
// `member_key`, so the grant names a run and the shard resolves who was there.
|
||||
// Sending a list would put the same list 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 grantItem = ({ runId, item, amount, hue, name, where, idempotencyKey }) =>
|
||||
call('/items/grant', {
|
||||
method: 'POST',
|
||||
body: { runId: String(runId), item, amount, hue, name, where, idempotencyKey },
|
||||
})
|
||||
|
||||
// Starts a save. What actually happened rides `world.save.before`/`after` on the
|
||||
// event stream, which have been there since protocol 2 — so this asserts only that
|
||||
// the save was started, and a caller that needs the completion watches the feed it
|
||||
// is already connected to.
|
||||
const saveWorld = ({ idempotencyKey } = {}) =>
|
||||
call('/world/save', { method: 'POST', body: { idempotencyKey } })
|
||||
|
||||
module.exports = {
|
||||
TIMEOUT_MS,
|
||||
invalidateConfig,
|
||||
health,
|
||||
getCharBySerial,
|
||||
@@ -215,6 +406,18 @@ module.exports = {
|
||||
deleteTownCrier,
|
||||
postNews,
|
||||
deleteNews,
|
||||
getLeases,
|
||||
applyLease,
|
||||
releaseLease,
|
||||
openParticipation,
|
||||
snapshotParticipation,
|
||||
closeParticipation,
|
||||
spawnWorld,
|
||||
ownedWorld,
|
||||
despawnWorld,
|
||||
getGrantCatalog,
|
||||
grantItem,
|
||||
saveWorld,
|
||||
adminKick,
|
||||
adminBan,
|
||||
adminUnban,
|
||||
|
||||
54
sonar-project.properties
Normal file
54
sonar-project.properties
Normal file
@@ -0,0 +1,54 @@
|
||||
# SonarQube analysis config for module-uo.
|
||||
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
|
||||
# The project key must match the one created in SonarQube (dashboard URL
|
||||
# ?id=Module-uo).
|
||||
|
||||
sonar.projectKey=Module-uo
|
||||
sonar.projectName=Module-uo
|
||||
|
||||
# Analysed application code.
|
||||
#
|
||||
# Unlike core's repo there is no `src/` directory to point at: the server half
|
||||
# keeps its code at `server/` root (boot.js, core.js, index.js) beside its
|
||||
# subdirectories, so the whole tree is included and the non-source parts are
|
||||
# excluded below. That direction is deliberate — a new top-level server
|
||||
# directory is scanned by default rather than silently unscanned, which is the
|
||||
# safer way for this list to be wrong.
|
||||
#
|
||||
# `client/scripts` and `server/scripts` are in, not out: checkExternals.js and
|
||||
# checkImports.js *are* the enforcement of MODULE_API.md §3.6 and §5.1, they
|
||||
# each carry their own test suite, and both have already shipped defects that a
|
||||
# reviewer missed (see MODULE_SYSTEM.md §2.7.1). Build code that decides whether
|
||||
# a release is allowed out is not throwaway code.
|
||||
sonar.sources=server,client/src,client/scripts
|
||||
|
||||
# Test code is analysed separately from sources so coverage/metrics attribute
|
||||
# correctly. Both halves run on Node's built-in test runner (no browser/DOM):
|
||||
# the server suite is CommonJS behind test/_setup.js, the client's is ESM.
|
||||
sonar.tests=server/test,client/test
|
||||
sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js
|
||||
|
||||
# Coverage. The sonarqube.yml workflow runs both suites with Node's built-in
|
||||
# test-coverage and writes an LCOV report for each BEFORE the scan runs; without
|
||||
# them the dashboard shows 0% (the scanner never executes tests itself). Both
|
||||
# suites are invoked from the repo root so the `SF:` paths come out
|
||||
# repo-root-relative (server/..., client/src/...) and the scanner resolves them
|
||||
# against the project base dir.
|
||||
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info
|
||||
|
||||
# Test execution ("Unit Tests" measure). A SEPARATE report from coverage: the
|
||||
# lcov files above only populate Coverage, so without this the dashboard shows a
|
||||
# coverage % but an empty "Unit Tests" tile. Written by scripts/sonar-test-reporter.mjs,
|
||||
# a copy of core's — a pure leaf build helper, which is the side of the vendoring
|
||||
# line that may be copied (MODULE_SYSTEM.md §2.7.1).
|
||||
sonar.testExecutionReportPaths=server/coverage/test-execution.xml,client/coverage/test-execution.xml
|
||||
|
||||
# Never analyse dependencies, build output, generated artifacts, or fixtures.
|
||||
#
|
||||
# `client/dist` is the built chunk (gitignored, but the workflow builds it before
|
||||
# scanning because client/test/{build,registration}.test.js import it).
|
||||
# `server/swagger/doc.js` and the two committed generated artifacts at the repo
|
||||
# root are inputs to and outputs of swagger-autogen, not hand-written code.
|
||||
sonar.exclusions=**/node_modules/**,server/test/**,client/test/**,client/dist/**,server/swagger/**,server/data/**,server/coverage/**,client/coverage/**,**/*.min.js
|
||||
|
||||
sonar.sourceEncoding=UTF-8
|
||||
@@ -3131,6 +3131,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/guilds/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public · Shard"
|
||||
],
|
||||
"summary": "One guild and its roster",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The guild id."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The guild, with its roster",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such guild",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/shard/houses": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user