Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/) —
|
||||
|
||||
19
README.md
19
README.md
@@ -134,11 +134,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.",
|
||||
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
|
||||
"ref": "87230c879aa6e9adde3507718aed6bc4e4d86009",
|
||||
"refName": "edge @ phase 3 slice 4 (website#140)"
|
||||
"ref": "963d734dcc09580a7d8bb676370b4faf9b8727b2",
|
||||
"refName": "main @ the Teams cutover (website#161)"
|
||||
}
|
||||
|
||||
@@ -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, '\.')}"`))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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`;
|
||||
|
||||
@@ -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
|
||||
@@ -641,4 +687,14 @@ 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`);
|
||||
|
||||
@@ -45,6 +45,8 @@ module.exports = function register(ctx, api) {
|
||||
|
||||
const shardStreams = require('./config/shardStreams')
|
||||
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 +88,25 @@ module.exports = function register(ctx, api) {
|
||||
api.registerNotificationStreams(shardStreams.STREAMS)
|
||||
api.registerAnnounceLeg(townCrierLeg.leg)
|
||||
|
||||
// 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])
|
||||
|
||||
api.onBoot(boot.onBoot)
|
||||
api.onShutdown(boot.onShutdown)
|
||||
|
||||
|
||||
@@ -171,6 +171,50 @@ 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 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 +386,11 @@ module.exports = {
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
upsertGuildMembers,
|
||||
clearGuildMembers,
|
||||
removeGuildMember,
|
||||
clearAllGuildMembers,
|
||||
listGuildMembers,
|
||||
findGuildLedByActor,
|
||||
listGuildsLedByAccounts,
|
||||
upsertGovernor,
|
||||
|
||||
@@ -340,8 +340,88 @@ 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,
|
||||
}))
|
||||
}
|
||||
|
||||
function shapeGuild(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
@@ -620,6 +700,9 @@ module.exports = {
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
upsertGuildRoster,
|
||||
removeGuildMember,
|
||||
listGuildMembers,
|
||||
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,
|
||||
}
|
||||
@@ -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).`)
|
||||
}
|
||||
@@ -95,6 +95,8 @@ function fakeApi() {
|
||||
extensions: [],
|
||||
streams: null,
|
||||
legs: [],
|
||||
teamProvider: null,
|
||||
slashCommands: [],
|
||||
hooks: {},
|
||||
}
|
||||
const called = new Set()
|
||||
@@ -107,6 +109,14 @@ 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 },
|
||||
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'))
|
||||
})
|
||||
@@ -85,6 +85,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)
|
||||
})
|
||||
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'])
|
||||
})
|
||||
@@ -116,6 +116,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 +354,16 @@ 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']
|
||||
|
||||
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 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].sort(),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
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'])
|
||||
})
|
||||
@@ -45,6 +45,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',
|
||||
@@ -187,6 +193,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)
|
||||
|
||||
@@ -163,6 +163,13 @@ const KIND_FEATURE = new Map(
|
||||
'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',
|
||||
|
||||
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