diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 1422d8c..61aedcb 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -27,11 +27,29 @@ # Building the chunk in CI is not only a check: it is how the chunk that ships is # produced, since an operator never builds (MODULE_SYSTEM.md §1.14). # -# Not here yet, deliberately, because there is nothing for them to act on until -# the extraction is further along: the release workflow (the -# `module-uo-.tar.gz` artifact and its sha256 manifest) and the module's -# own frozen route manifest, which needs core checked out at a pinned ref -# (MODULE_API.md §5.3). Each lands with the slice it checks. +# • `server: check:swagger` — `swagger-fragment.json` describes the routes this +# module registers, today. Core has no way to generate it: core is a prebuilt +# image, this module arrived on a volume afterwards, and it mounts through a +# call no static parser can follow. So the fragment core merges into +# `/api/docs.json` is whatever this repo committed, and a stale one documents +# a URL surface that does not exist (§2.8). +# +# • `frozen-manifest` — the job with the interesting shape. It clones CORE at +# the ref pinned in `ci/core-ref.json`, generates its route manifest twice +# (without this module, then with) and takes the difference. That difference +# is what this module serves, and it is checked three ways: it must match the +# committed `routes.manifest.json`, it must not have REMOVED or changed one of +# core's own routes, and every route in it must have an operation in +# `swagger-fragment.json` — the per-module form of core's rule that a route +# which isn't in the spec doesn't ship (§5.3, §2.8). +# +# Nothing else can ask those questions. Every other check here runs against +# this repo alone, where a mount prefix is a string in `server/index.js` and a +# documented path is a string in a JSON file; whether they name the same URL +# is a fact about a running core, and this is the only job that has one. +# +# Still not here, deliberately: nothing. The release workflow is +# `.gitea/workflows/release.yml` and runs on a tag rather than on a PR. # # Enforcement (one-time, in the Gitea UI): # Repository Settings → Branches → Branch Protection (rule for `main`) @@ -79,6 +97,9 @@ jobs: - name: Check the module boundary (MODULE_API.md §5.1) run: npm run check:imports --prefix server + - name: Check the OpenAPI fragment is current (MODULE_API.md §2.8) + run: npm run check:swagger --prefix server + client-build: runs-on: ubuntu-latest timeout-minutes: 20 @@ -107,3 +128,70 @@ jobs: - name: Check the built chunk's externals (MODULE_API.md §3.6) run: npm run check:externals --prefix client + + # ── The URLs this module actually serves ────────────────────────────────── + # + # Everything above proves the module against itself. This proves it against a + # real core: the one place where "the prefix I register" and "the path I + # document" are the same fact rather than two strings that ought to agree. + # + # The module is COPIED into the core checkout, never symlinked — core's loader + # filters its scan with `entry.isDirectory()`, which reports a link as a link + # and skips it silently, so a symlinked module produces a manifest with no + # module routes in it and a diff that looks like the module registering + # nothing. + frozen-manifest: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + path: module + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + # Anonymous HTTPS, and a full clone rather than a shallow one: the pin is a + # commit sha, and `--depth 1` can only fetch a branch tip. + - name: Clone core at the pinned ref (MODULE_API.md §5.3) + run: | + REPO=$(node -p "require('./module/ci/core-ref.json').repo") + REF=$(node -p "require('./module/ci/core-ref.json').ref") + echo "core: $REPO @ $REF" + git clone --quiet "$REPO" core + git -C core checkout --quiet "$REF" + + - name: Install core's server deps + run: npm ci --prefix core/server + + # Core alone. `--check` first, so a pin that no longer regenerates its own + # committed manifest fails HERE, naming the pin, instead of showing up below + # as this module having removed a route it never touched. + - name: Generate core's manifest without this module + run: | + npm run routes:manifest --prefix core/server -- --check + cp core/server/routes.manifest.json before.json + + # The chunk has to exist before the loader will accept the module at all — + # `client.entry` is validated during the manifest step of the scan, and a + # missing one is a load failure, not a warning. + - name: Build the client chunk + run: | + npm ci --prefix module/client + npm run build --prefix module/client + + - name: Install the module into core + run: | + mkdir -p core/modules/uo + tar -C module --exclude=.git --exclude=node_modules -cf - . | tar -C core/modules/uo -xf - + npm ci --omit=dev --prefix core/modules/uo/server + + - name: Generate core's manifest with this module + run: | + npm run routes:manifest --prefix core/server + cp core/server/routes.manifest.json after.json + + - name: Check the frozen manifest and the fragment's coverage + working-directory: module + run: node server/scripts/frozenManifest.js --before ../before.json --after ../after.json --check diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..4192b4f --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,258 @@ +# Build and publish the installable bundle: `module-uo-.tar.gz` plus the +# manifest carrying its sha256 (docs/website/MODULE_SYSTEM.md §2.3, §2.5). +# +# ── What a release IS here ────────────────────────────────────────────────── +# +# **An operator never builds anything** (MODULE_SYSTEM.md §1.14 — the constraint +# the whole module system is shaped around). So a release is not source: it is the +# directory core's loader expects to find at `modules/uo/`, already assembled — +# the prebuilt client chunk, the one runtime dependency installed, the schema +# fragment and the OpenAPI fragment — packed as it will be unpacked. Phase 4's +# admin install downloads the tarball, verifies it against the `sha256` in the +# manifest, and unpacks it onto the volume. Nothing runs `npm` on the way. +# +# ── The version is DECLARED, not derived ──────────────────────────────────── +# +# Unlike RunicGateway/link and RunicGateway/installer, whose release engines read +# conventional-commit subjects to compute the next version, this repo already has +# one authoritative version — `module.json`'s, which is the version core records +# in `installed_modules` and shows on the admin screen, and which sits beside the +# `coreApi` range a bump usually has to be considered against. Two sources for one +# number is how they drift, so: **a release happens when a merge to `main` leaves +# `module.json` at a version that has no release yet.** Bumping the version is an +# ordinary reviewed PR; publishing is this file's business. +# +# It follows that this workflow never writes to a branch — it tags and publishes, +# nothing else — so `main` needs no push exception. That is the installer's model, +# adopted here for the reason it was adopted there: `main` is protected, and a +# release engine that has to push to it is a release engine that stops working the +# day someone tightens the rule. +# +# Re-running on a version that is already released is a no-op, so a rerun after an +# unrelated failure is safe. +# +# Prerequisites (Settings → Actions → Secrets on RunicGateway/Module-uo): +# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the tag +# and create the release. + +name: Release + +on: + push: + branches: [main] + +concurrency: + group: release-module-uo + cancel-in-progress: false + +env: + GITEA_HOST: gitea.whitlocktech.com + REPO: RunicGateway/Module-uo + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Decide whether this commit releases + id: plan + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + VERSION="$(node -p "require('./module.json').version")" + echo "module.json version: ${VERSION}" + + # Does a release already exist for this version? A 404 means no, a 200 + # means yes, and anything else — a network failure, a bad token — is not + # evidence of absence. Guessing "no" would publish over a good release, + # so refuse instead. (The installer learned this one the expensive way.) + HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \ + "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)" + + case "$HTTP" in + 404) RELEASE=true ;; + 200) RELEASE=false; echo "v${VERSION} is already released — nothing to do." ;; + *) echo "::error::Could not determine whether v${VERSION} is released (HTTP ${HTTP}). Refusing to guess."; exit 1 ;; + esac + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + + # Before anything is built or tagged, so a repo without secrets fails + # legibly rather than half-publishing: the tag push can succeed on the + # credential actions/checkout left in the local git config while the + # release API call 401s, leaving the repo tagged and unreleased. + - name: Verify release credentials are configured + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + if [ -z "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ]; then + echo "::error::Missing Actions secret REGISTRY_TOKEN (needs write:repository) on ${REPO}." + exit 1 + fi + echo "Release credentials present." + + - name: Build the client chunk + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + npm ci --prefix client + npm run build --prefix client + + # `--omit=dev` and then PACKED: express, express-validator and swagger-autogen + # are build- and test-time only — the shipped half is handed express on `ctx` + # (MODULE_API.md §2.3) — and `ws` is the one runtime dependency. Node resolves + # it by walking up from `modules/uo/server/`, which is why it ships inside the + # tarball rather than being installed on the operator's box. + - name: Install the shipped runtime dependency + if: ${{ steps.plan.outputs.release == 'true' }} + run: npm ci --omit=dev --prefix server + + # ── Assemble exactly what an operator's volume gets ────────────────── + # + # Stated as an INCLUDE list, not an exclude list. An exclude list ships + # whatever it forgot: the day someone adds `server/tools/` with a scratch + # credential in it, an exclude list packs it and nobody finds out. + - name: Assemble the bundle + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + OUT="dist/module-uo-${VERSION}" + rm -rf dist && mkdir -p "$OUT" + + # The manifest core reads, the two fragments, and the licence the code + # is under — a bundle that ships GPL code without its licence is not + # distributable. + cp module.json swagger-fragment.json LICENSE.md README.md "$OUT/" + + # The server half, minus what never runs inside core's process. + mkdir -p "$OUT/server" + for d in boot.js core.js index.js config data db model router utils; do + cp -r "server/$d" "$OUT/server/" + done + cp server/package.json "$OUT/server/" + cp -r server/node_modules "$OUT/server/" + + # The client half is the BUILT chunk only. `client/src` is 5,000 lines + # of source an operator has no use for and core will never read. + mkdir -p "$OUT/client/dist" + cp client/dist/entry.js "$OUT/client/dist/" + + # Prove the bundle is loadable before it is published: these are the + # paths core's loader resolves out of module.json, and a release whose + # entry point is missing fails on an operator's box with a + # `startup_failed` row instead of here. + node -e ' + const fs = require("fs"), path = require("path"); + const root = process.argv[1]; + const m = JSON.parse(fs.readFileSync(path.join(root, "module.json"), "utf8")); + for (const p of [m.server, m.schema, m.purge, m.client.entry, "swagger-fragment.json"]) { + if (!fs.existsSync(path.join(root, p))) { console.error("bundle is missing " + p); process.exit(1); } + } + console.log("bundle contents check: ok"); + ' "$OUT" + + tar -C dist -czf "dist/module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}" + rm -rf "$OUT" + + SHA="$(sha256sum "dist/module-uo-${VERSION}.tar.gz" | cut -d' ' -f1)" + SIZE="$(stat -c%s "dist/module-uo-${VERSION}.tar.gz")" + + # The install manifest. Same shape as the installer's bundle JSON — a + # per-asset sha256 fetched over HTTPS, no signatures — because that is + # the model this project already has and a second one would be a second + # thing to get right (MODULE_SYSTEM.md §1.11). + jq -n \ + --arg id "$(node -p "require('./module.json').id")" \ + --arg name "$(node -p "require('./module.json').name")" \ + --arg version "$VERSION" \ + --arg coreApi "$(node -p "require('./module.json').coreApi")" \ + --arg artifact "module-uo-${VERSION}.tar.gz" \ + --arg sha256 "$SHA" \ + --argjson size "$SIZE" \ + --arg url "https://${GITEA_HOST}/${REPO}/releases/download/v${VERSION}/module-uo-${VERSION}.tar.gz" \ + '{schema:1, id:$id, name:$name, version:$version, coreApi:$coreApi, + artifact:$artifact, url:$url, sha256:$sha256, size:$size}' \ + > "dist/module-uo-${VERSION}.json" + + echo "${SHA} module-uo-${VERSION}.tar.gz" > dist/SHA256SUMS + cat "dist/module-uo-${VERSION}.json" + + - name: Write the changelog + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" + RANGE="${LAST_TAG:+${LAST_TAG}..}HEAD" + { + echo "## module-uo v${VERSION}" + echo + echo "Install from the website's Admin → Modules screen, or unpack onto the" + echo "modules volume as \`modules/uo/\`. Requires a core whose \`MODULE_API_VERSION\`" + echo "satisfies \`$(node -p "require('./module.json').coreApi")\`." + echo + echo "### Changes" + if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi + git log --no-merges --format='- %s' $RANGE || true + echo + echo "### Verifying this download" + echo + echo "Releases are **unsigned** — the \`sha256\` in \`module-uo-${VERSION}.json\` is the" + echo "trust anchor, and the website verifies it before unpacking." + echo + echo '```bash' + echo "sha256sum -c SHA256SUMS --ignore-missing" + echo '```' + } > dist/CHANGELOG.md + + - name: Tag the release + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.plan.outputs.tag }}" + git config user.name 'Runic Gateway CI' + git config user.email 'ci@whitlocktech.net' + git tag -a "$TAG" -m "module-uo ${TAG}" + git push origin "$TAG" + + - name: Create the Gitea release and upload the bundle + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.plan.outputs.tag }}" + VERSION="${{ steps.plan.outputs.version }}" + API="https://${GITEA_HOST}/api/v1/repos/${REPO}" + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + + REL_ID="$(curl -sSf -X POST "${API}/releases" \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg tag "$TAG" --arg body "$(cat dist/CHANGELOG.md)" \ + '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \ + | jq -r '.id')" + echo "Created release ${TAG} (id=${REL_ID})" + + for f in "module-uo-${VERSION}.tar.gz" "module-uo-${VERSION}.json" SHA256SUMS; do + curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ + -H "Authorization: token ${CI_TOKEN}" \ + -F "attachment=@dist/${f}" >/dev/null + echo " uploaded ${f}" + done diff --git a/.gitignore b/.gitignore index 595f484..242c77f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,13 @@ client/coverage/ *.env !.env.example -# Release staging +# Release staging. `.gitea/workflows/release.yml` assembles the bundle under +# /dist and packs it from there. Note this is the ROOT dist only — the module's +# two committed generated artifacts, swagger-fragment.json and +# routes.manifest.json, are deliberately NOT ignored: core merges the first +# verbatim and the second is the frozen URL surface, so both have to be +# reviewable in a diff (MODULE_API.md §2.8, §5.3). +/dist/ *.tar.gz # logs / os diff --git a/README.md b/README.md index ec0f90c..fd7d05d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The module's **id** is `uo` — that is what appears in `module.json`, in the `i table, in the `modules//` path on disk and in the URL segment (`/uo/*`, `/admin/uo/*`, `/player/uo/*`). `Module-uo` is the repository; `module-uo` is the module and its release artifact. -## Status: the bundle skeleton exists; the extraction has started +## Status: the extraction is complete; this repo is the UO half of the site The design of record is [`website/MODULE_SYSTEM.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_SYSTEM.md) @@ -37,32 +37,50 @@ in the docs repo — **read them before opening a PR here.** Where the two diffe | 0 — CI trigger fix, cut `website` `edge`, bootstrap this repo | `website`, here | ✅ done | | 1 — module API contract (`docs/website/MODULE_API.md`) + the atlas spike | `docs`, `website` | ✅ done | | 2 — core scaffolding: loader, `installed_modules`, registries, client registry | `website` | ✅ done | -| 3 — extract the UO half of the site into this repo | `website`, here | 🟡 in progress | +| 3 — extract the UO half of the site into this repo | `website`, here | ✅ done | | 4 — delivery: the admin Modules screen + the Docker path | `website` | ⬜ | -Phase 3 moves the UO half of `website/` here in ten slices (`MODULE_SYSTEM.md` §2.7.1), server-first -and then client. Each slice is one PR here that adds, and one PR in `website` that deletes — this one -merging first, so `website`'s `edge` branch serves the feature from core right up to the moment core -drops it. +Phase 3 moved the UO half of `website/` here in six slices (`MODULE_SYSTEM.md` §2.7.1): the bundle +skeleton, the whole server half, core's client extension slots, the whole client half, the de-UO of +core's own copy, and this one — the artifacts that make the result installable and checkable. Each +slice was one PR here that added and one in `website` that deleted, this one merging first, so +`website`'s `edge` branch served each feature from core right up to the moment core dropped it. -**Slice 0 is the bundle skeleton, and it registers nothing on purpose.** What it proves is the -delivery path itself: core discovers the module, validates `module.json`, calls `register()`, serves -the client chunk, injects it, and reports the module `started` — and the chunk resolves React, the -renderer and the router from core's `window.__rg` rather than bundling its own. Every slice after -this one adds registrations to `server/index.js` and `client/src/entry.jsx`. +Neither half sliced by feature in the end, and for the same reason on both sides: a mount prefix is +claimed whole and a shared leaf moves with its **last** consumer, so the closure of either half is +the whole half. + +**What core serves and what this repo serves is now a fact you can read**, not a claim: 72 URLs, in +[`routes.manifest.json`](routes.manifest.json), derived by loading this module into a real core and +diffing. Not one of core's own URLs moved — that is the promise `MODULE_SYSTEM.md` §1.2 makes to the +shipped Android app and the Discord bot, and it is checked on every PR. ## Working on it ```bash npm ci --prefix server && npm test --prefix server && npm run check:imports --prefix server +npm run check:swagger --prefix server # is swagger-fragment.json still current? npm ci --prefix client && npm test --prefix client && npm run build --prefix client npm run check:externals --prefix client # asks the BUILT chunk, so it runs after the build ``` -The two `check:*` scripts are the contract's acceptance criteria rather than this module's own tests: -no import may escape the module root (`MODULE_API.md` §5.1), and no bare specifier may survive into -the built chunk (§3.6). The matching failure — a shared dependency being *bundled* — fails the build -itself, from a guard inside `vite.config.js`. +The `check:*` scripts are the contract's acceptance criteria rather than this module's own tests: no +import may escape the module root (`MODULE_API.md` §5.1), no bare specifier may survive into the +built chunk (§3.6), and the OpenAPI fragment core merges must describe the routes registered today +(§2.8). The matching build failure — a shared dependency being *bundled* — comes from a guard inside +`vite.config.js`. + +**Changed a route, or its `#swagger` annotations?** `npm run swagger --prefix server` regenerates +`swagger-fragment.json`; commit it. Core cannot generate it — core is a prebuilt image and this +module mounts through a call no static parser can follow — so the file this repo commits is the one +an operator's `/api/docs` shows. + +**Changed a mount prefix, or added a route?** `routes.manifest.json` is regenerated by the +`frozen-manifest` CI job, which clones core at the ref pinned in [`ci/core-ref.json`](ci/core-ref.json), +loads this module into it and takes the difference. To do it locally, check this repo out into that +core as `modules/uo` (**copy it — a symlink is silently skipped by the loader**), run core's +`npm run routes:manifest` with and without it, and hand both files to +`server/scripts/frozenManifest.js`. Running it against a real core means checking this repo out as `website/modules/uo`, building the client half, and booting core. The four-step browser smoke in `MODULE_API.md` §7.7 is the only thing @@ -72,24 +90,35 @@ neither has a shape a DOM-less test runner can see. ## What it contains One repo, one bundle: the server half and the client half live side by side and version together, so -a route and the screen that calls it can never be mismatched. A ✅ is in the tree today. +a route and the screen that calls it can never be mismatched. ``` -module.json ✅ id, version, coreApi range, mounts, extensions -server/index.js ✅ the entry point — register(ctx, api), synchronous, no database -server/scripts/ ✅ checkImports.js — the §5.1 boundary check -server/test/ ✅ node --test, with a fake ctx standing in for core -server/ routers, controllers, models, utils -server/db/schema.sql idempotent fragment, replayed by core's ensureSchema() -server/db/purge.sql destructive; only ever run by an explicit purge -client/src/entry.jsx ✅ the chunk's entry — registers routes, nav, feature provider -client/src/shim/ ✅ react, react-dom, react-router-dom, jsx-runtime, from window.__rg -client/vite.config.js ✅ the library build, the aliases, the not-bundled guard -client/src/ route components, nav registrations, feature provider -client/dist/ ✅ PREBUILT ESM chunk, built by CI — never by an operator +module.json id, version, coreApi range, mounts, extensions +swagger-fragment.json generated · the OpenAPI core merges into /api/docs.json +routes.manifest.json generated · the 72 URLs this module serves +ci/core-ref.json the core commit the two above were proved against +server/index.js the entry point — register(ctx, api), synchronous, no database +server/router/ routers + controllers, one directory per tier +server/model/ one directory per table family; nothing crosses the boundary +server/utils/ sidecar client, visibility, ingest, town crier, cliloc, atlas +server/config/ the push stream catalog +server/db/schema.sql idempotent fragment, replayed by core's ensureSchema() +server/db/purge.sql destructive; only ever run by an explicit purge +server/scripts/ the three checks: imports, the fragment, the frozen manifest +server/test/ node --test, with a fake ctx standing in for core +client/src/entry.jsx the chunk's entry — registers routes, nav, slots, feature provider +client/src/shim/ react, react-dom, react-router-dom, jsx-runtime, from window.__rg +client/vite.config.js the library build, the aliases, the not-bundled guard +client/dist/ PREBUILT ESM chunk, built by CI — never by an operator ``` -Release artifact: `module-uo-.tar.gz`, plus a manifest carrying its `sha256`. +**The three generated files are committed on purpose.** Two of them are what core reads instead of +looking at this source — it never has it — and the third records which core they were proved against. +A generated file nobody reviews is a generated file nobody notices going wrong, so each lands in a +diff. + +Release artifact: `module-uo-.tar.gz`, plus `module-uo-.json` carrying its +`sha256`. See below. ## How it reaches an operator @@ -103,6 +132,29 @@ The [installer](https://gitea.whitlocktech.com/RunicGateway/installer) is **not* It deploys the *shard* side — the plugin overlay and the uo-link sidecar — and never contacts the website. Module delivery is website-side only. +### Releases + +A merge to `main` that leaves `module.json` at a version with no release yet publishes one. The +version is **declared**, not computed from commit subjects: `module.json`'s version is what core +records in `installed_modules` and shows on the admin screen, and it sits beside the `coreApi` range +a bump usually has to be weighed against — two sources for one number is how they drift. Bumping it +is an ordinary reviewed PR. + +Each release carries: + +| Asset | What it is | +|---|---| +| `module-uo-.tar.gz` | the directory core expects at `modules/uo/` — already assembled, with the chunk built and `ws` installed | +| `module-uo-.json` | id, version, `coreApi`, the artifact's URL, size and **`sha256`** | +| `SHA256SUMS` | the same hash, in the shape every other repo here publishes | + +Releases are **unsigned**; the `sha256` is the trust anchor, and the website verifies it before +unpacking. That is the model `installer`'s bundles already use, and a second trust model would be a +second thing to get right. + +The tarball is assembled from an **include** list, never an exclude list — an exclude list ships +whatever it forgot. Tests, scripts, `client/src` and the dev dependencies are not in it. + ## Compatibility `module.json` declares a `coreApi` semver range, checked at boot against core's `MODULE_API_VERSION`. diff --git a/ci/core-ref.json b/ci/core-ref.json new file mode 100644 index 0000000..9e78853 --- /dev/null +++ b/ci/core-ref.json @@ -0,0 +1,6 @@ +{ + "$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/uo and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves. Pinned rather than tracking `edge` on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together.", + "repo": "https://gitea.whitlocktech.com/RunicGateway/website.git", + "ref": "87230c879aa6e9adde3507718aed6bc4e4d86009", + "refName": "edge @ phase 3 slice 4 (website#140)" +} diff --git a/routes.manifest.json b/routes.manifest.json new file mode 100644 index 0000000..a588abd --- /dev/null +++ b/routes.manifest.json @@ -0,0 +1,365 @@ +{ + "$comment": "Generated inventory of the URLs module-uo serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest workflow; see server/scripts/frozenManifest.js.", + "routes": [ + { + "method": "DELETE", + "path": "/api/v1/admin/uo-link/towncrier/:id", + "tier": "public" + }, + { + "method": "DELETE", + "path": "/api/v1/admin/users/:id/shard/link/:account", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/accounts", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/atlas", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/audit", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/char/:serial", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/clilocs", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/houses", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/pages", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/roster/:account", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/sales", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/vendors/:account", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/shard/visibility", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/uo-link/config", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/uo-link/signup-mode", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/uo-link/stream", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/shard/accounts", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/shard/houses", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/shard/online", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/shard/sales", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/admin/users/:id/shard/standing", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/player/shard/accounts", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/player/shard/char/:serial", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/player/shard/houses", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/player/shard/roster/:account", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/player/shard/sales", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/player/shard/vendors/:account", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/champions", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/creatures", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/creatures/:slug", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/landmarks", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/meta", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/regions", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/champs", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/economy", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/features", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/feed", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/governors", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/governors/:city/history", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/guilds", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/houses", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/idoc", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/market", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/market/meta", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/market/vendors/:serial", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/online", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/points", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/points/:system", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/presence", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/ruleset", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/status", + "tier": "public" + }, + { + "method": "GET", + "path": "/api/v1/public/shard/stream", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/account", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/atlas/approve", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/atlas/import", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/atlas/reject", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/ban", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/broadcast", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/clilocs/import", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/kick", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/link", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/pages/:id/close", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/pages/:id/respond", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/unban", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/admin/uo-link/towncrier", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/player/shard/account", + "tier": "public" + }, + { + "method": "POST", + "path": "/api/v1/player/shard/link", + "tier": "public" + }, + { + "method": "PUT", + "path": "/api/v1/admin/shard/atlas/path", + "tier": "public" + }, + { + "method": "PUT", + "path": "/api/v1/admin/shard/clilocs/path", + "tier": "public" + }, + { + "method": "PUT", + "path": "/api/v1/admin/shard/visibility", + "tier": "public" + }, + { + "method": "PUT", + "path": "/api/v1/admin/uo-link/config", + "tier": "public" + }, + { + "method": "PUT", + "path": "/api/v1/admin/uo-link/signup-mode", + "tier": "public" + } + ] +} diff --git a/server/package-lock.json b/server/package-lock.json index a8d1456..d5227e8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,7 +13,8 @@ }, "devDependencies": { "express": "^4.19.2", - "express-validator": "^7.2.0" + "express-validator": "^7.2.0", + "swagger-autogen": "^2.23.7" }, "engines": { "node": ">=20" @@ -33,6 +34,19 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -40,6 +54,13 @@ "dev": true, "license": "MIT" }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", @@ -65,6 +86,17 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -106,6 +138,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -156,6 +195,16 @@ "ms": "2.0.0" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -359,6 +408,13 @@ "node": ">= 0.6" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -408,6 +464,28 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -481,6 +559,18 @@ "node": ">=0.10.0" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -498,6 +588,19 @@ "node": ">= 0.10" } }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -581,6 +684,19 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -624,6 +740,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -634,6 +760,16 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -867,6 +1003,19 @@ "node": ">= 0.8" } }, + "node_modules/swagger-autogen": { + "version": "2.23.7", + "resolved": "https://registry.npmjs.org/swagger-autogen/-/swagger-autogen-2.23.7.tgz", + "integrity": "sha512-vr7uRmuV0DCxWc0wokLJAwX3GwQFJ0jwN+AWk0hKxre2EZwusnkGSGdVFd82u7fQLgwSTnbWkxUL7HXuz5LTZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^7.4.1", + "deepmerge": "^4.2.2", + "glob": "^7.1.7", + "json5": "^2.2.3" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -931,6 +1080,13 @@ "node": ">= 0.8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", diff --git a/server/package.json b/server/package.json index 0a3642c..a7f0675 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,9 @@ "main": "index.js", "scripts": { "test": "node --test --require ./test/_setup.js", - "check:imports": "node scripts/checkImports.js" + "check:imports": "node scripts/checkImports.js", + "swagger": "node scripts/swaggerFragment.js", + "check:swagger": "node scripts/swaggerFragment.js --check" }, "engines": { "node": ">=20" @@ -15,10 +17,11 @@ "//dependencies": "The ONE runtime dependency, and it ships inside the release tarball: CI runs npm ci --omit=dev and packs server/node_modules, because an operator never builds (MODULE_SYSTEM.md 1.14). Node resolves it by walking up from modules/uo/server/. Everything else the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger, the middleware and the rate-limit factory are all core-owned and handed over.", "devDependencies": { "express": "^4.19.2", - "express-validator": "^7.2.0" + "express-validator": "^7.2.0", + "swagger-autogen": "^2.23.7" }, "dependencies": { "ws": "^8.21.0" }, - "//devDependencies": "Test-only. test/_fakes.js builds a REAL express router - a fake Router would test the fake." + "//devDependencies": "Test-only and build-only, never shipped. test/_fakes.js builds a REAL express router - a fake Router would test the fake. swagger-autogen is the same generator core uses, pinned to the same major so the fragment and the spec it merges into come out of one tool (MODULE_API.md 2.8)." } diff --git a/server/router/admin/shard.router.js b/server/router/admin/shard.router.js index dbb7fa2..01d4c35 100644 --- a/server/router/admin/shard.router.js +++ b/server/router/admin/shard.router.js @@ -47,8 +47,8 @@ shardRouter.post( // #swagger.tags = ['Admin · Account'] // #swagger.summary = 'Link an in-game account with a one-time code (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */ + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkResult" } } } } */ /* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ body('code').isString().trim().isLength({ min: 4, max: 32 }), validate, @@ -59,7 +59,7 @@ shardRouter.get( // #swagger.tags = ['Admin · Account'] // #swagger.summary = 'List the caller’s linked game accounts (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */ selfShard.listAccounts, ) shardRouter.get( @@ -103,7 +103,7 @@ shardRouter.get( // #swagger.tags = ['Admin · Account'] // #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ + /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */ selfShard.getSales, ) shardRouter.post( @@ -112,7 +112,7 @@ shardRouter.post( // #swagger.summary = 'Create a game account and link it to the caller (staff self-service)' // #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */ + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } } */ /* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ @@ -223,7 +223,7 @@ shardRouter.get( // #swagger.tags = ['Admin · Shard'] // #swagger.summary = 'Recent in-game moderation audit events (admin/moderator)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */ + /* #swagger.responses[200] = { description: 'admin.audit events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEvent" } } } } } */ modAccess, shardOps.listAudit, ) @@ -233,7 +233,7 @@ shardRouter.get( // #swagger.summary = 'Full house registry — owner, price, decay (admin/moderator)' // #swagger.description = 'The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ + /* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */ modAccess, shardOps.listHouses, ) @@ -253,7 +253,7 @@ shardRouter.get( // #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)' // #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */ + /* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasStatus" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, shardAtlas.getStatus, @@ -265,7 +265,7 @@ shardRouter.post( // #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */ - /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */ + /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */ adminOnly, body('force').optional().isBoolean(), validate, @@ -277,7 +277,7 @@ shardRouter.post( // #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)' // #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */ + /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */ adminOnly, shardAtlas.approve, ) @@ -287,7 +287,7 @@ shardRouter.post( // #swagger.summary = 'Reject a staged atlas refresh (admin only)' // #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */ + /* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasRefreshResult" } } } } */ /* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, shardAtlas.reject, @@ -299,7 +299,7 @@ shardRouter.put( // #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */ - /* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */ + /* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasStatus" } } } } */ adminOnly, body('path').isString().isLength({ max: 512 }), validate, @@ -322,7 +322,7 @@ shardRouter.get( // #swagger.summary = 'Cliloc table status: sources, drift, entry count (admin only)' // #swagger.description = 'Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */ + /* #swagger.responses[200] = { description: 'Cliloc status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocStatus" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, shardClilocs.getStatus, @@ -334,7 +334,7 @@ shardRouter.post( // #swagger.description = 'Applies a client patch, or a change to the shard\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\'s own COMPRESSED Cliloc.enu — answers 200 with status "unavailable" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the sources are unchanged." }, approve: { type: "boolean", description: "Accept a refresh in which a previously-loaded source has vanished." } } } } } } */ - /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocRefreshResult" } } } } */ + /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocRefreshResult" } } } } */ adminOnly, body('force').optional().isBoolean(), body('approve').optional().isBoolean(), @@ -348,7 +348,7 @@ shardRouter.put( // #swagger.description = 'Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." } } } } } } */ - /* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/ClilocStatus" } } } } */ + /* #swagger.responses[200] = { description: 'Cliloc status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/UoClilocStatus" } } } } */ adminOnly, body('path').isString().isLength({ max: 512 }), validate, @@ -364,7 +364,7 @@ shardRouter.get( // #swagger.summary = 'Get per-feature shard visibility config (admin only)' // #swagger.description = 'The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */ + /* #swagger.responses[200] = { description: 'Visibility config', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityConfig" } } } } */ /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, shardVisibility.getVisibility, @@ -375,8 +375,8 @@ shardRouter.put( // #swagger.summary = 'Update per-feature shard visibility config (admin only)' // #swagger.description = 'Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityUpdate" } } } } */ - /* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardVisibilityConfig" } } } } */ + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityUpdate" } } } } */ + /* #swagger.responses[200] = { description: 'Updated config', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardVisibilityConfig" } } } } */ /* #swagger.responses[400] = { description: 'Unknown feature, rung, or a locked field', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ adminOnly, body('features').isObject(), diff --git a/server/router/admin/uoLink.router.js b/server/router/admin/uoLink.router.js index 80fa632..f2f0f92 100644 --- a/server/router/admin/uoLink.router.js +++ b/server/router/admin/uoLink.router.js @@ -100,7 +100,7 @@ uoLinkRouter.post( // #swagger.tags = ['Admin · Shard'] // #swagger.summary = 'Publish / replace a town-crier message (admin only)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TownCrierRequest" } } } } */ + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoTownCrierRequest" } } } } */ /* #swagger.responses[200] = { description: 'Posted', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ /* #swagger.responses[400] = { description: 'Rejected (over caps)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[503] = { description: 'Shard unavailable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ diff --git a/server/router/admin/usersShard.router.js b/server/router/admin/usersShard.router.js index bf092e8..75f89e5 100644 --- a/server/router/admin/usersShard.router.js +++ b/server/router/admin/usersShard.router.js @@ -39,7 +39,7 @@ shardRouter.get( // #swagger.summary = 'A user’s linked game accounts (admin only)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('id').isInt(), validate, @@ -51,7 +51,7 @@ shardRouter.get( // #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } - /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ + /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */ /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('id').isInt(), validate, diff --git a/server/router/player/shard.router.js b/server/router/player/shard.router.js index 8a5dd8c..705ce55 100644 --- a/server/router/player/shard.router.js +++ b/server/router/player/shard.router.js @@ -30,8 +30,8 @@ shardRouter.post( // #swagger.summary = 'Link an in-game account with a one-time code' // #swagger.description = 'The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */ - /* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */ + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkRequest" } } } } */ + /* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardLinkResult" } } } } */ /* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ body('code').isString().trim().isLength({ min: 4, max: 32 }), @@ -44,7 +44,7 @@ shardRouter.post( // #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller' // #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */ + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } } */ /* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */ /* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ /* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ @@ -62,7 +62,7 @@ shardRouter.get( // #swagger.tags = ['Player · Shard'] // #swagger.summary = 'List the caller’s linked game accounts' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */ + /* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardLink" } } } } } */ shard.listAccounts, ) shardRouter.get( @@ -109,7 +109,7 @@ shardRouter.get( // #swagger.tags = ['Player · Shard'] // #swagger.summary = 'Recent player-vendor sales for the caller’s linked accounts' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */ + /* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardVendorSale" } } } } } */ shard.getSales, ) shardRouter.get( @@ -118,7 +118,7 @@ shardRouter.get( // #swagger.summary = 'The caller’s own houses (home status)' // #swagger.description = 'Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] - /* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ + /* #swagger.responses[200] = { description: 'The caller’s houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */ shard.getHouses, ) diff --git a/server/router/public/atlas.router.js b/server/router/public/atlas.router.js index 27c3258..9ddf931 100644 --- a/server/router/public/atlas.router.js +++ b/server/router/public/atlas.router.js @@ -43,7 +43,7 @@ atlasRouter.get( // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard\'s own files; an unknown one returns an empty page.' } // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' } // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' } - /* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */ + /* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasCreaturePage" } } } } */ /* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }), @@ -63,7 +63,7 @@ atlasRouter.get( // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' } // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' } // #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' } - /* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */ + /* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasCreature" } } } } */ /* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ param('slug').isString().isLength({ min: 1, max: 120 }), facetParam, @@ -80,7 +80,7 @@ atlasRouter.get( // #swagger.description = 'Flattened out of the shard\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.' // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' } // #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' } - /* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */ + /* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoAtlasRegion" } } } } } */ facetParam, query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }), validate, @@ -95,7 +95,7 @@ atlasRouter.get( // #swagger.description = 'From the shard\'s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").' // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' } // #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' } - /* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */ + /* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoAtlasLandmark" } } } } } */ facetParam, query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }), validate, @@ -109,7 +109,7 @@ atlasRouter.get( // #swagger.summary = 'Configured champion altars (the roster, not the live board)' // #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").' // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' } - /* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */ + /* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoAtlasChampion" } } } } } */ facetParam, validate, siteMode, @@ -121,7 +121,7 @@ atlasRouter.get( // #swagger.tags = ['Public · Atlas'] // #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported' // #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on " line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.' - /* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */ + /* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/UoAtlasMeta" } } } } */ siteMode, atlas.getMeta, ) diff --git a/server/router/public/shard.router.js b/server/router/public/shard.router.js index 1ab07a9..02531df 100644 --- a/server/router/public/shard.router.js +++ b/server/router/public/shard.router.js @@ -37,7 +37,7 @@ shardRouter.get( requireFeature('status'), // #swagger.tags = ['Public · Shard'] // #swagger.summary = 'Shard connection state, online count and latest economy' - /* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardStatus" } } } } */ + /* #swagger.responses[200] = { description: 'Shard status', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardStatus" } } } } */ shard.getStatus, ) shardRouter.get( @@ -48,7 +48,7 @@ shardRouter.get( // #swagger.description = 'The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\'s audience rung under the live visibility config, and each event\'s payload is field-projected against its own kind\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.' // #swagger.parameters['kind'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind.' } // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max rows (default 100, max 1000).' } - /* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEvent" } } } } } */ + /* #swagger.responses[200] = { description: 'Events, newest first', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEvent" } } } } } */ query('kind').optional({ values: 'falsy' }).isString().isLength({ max: 48 }), query('limit').optional().isInt({ min: 1, max: 1000 }), validate, @@ -60,7 +60,7 @@ shardRouter.get( // #swagger.tags = ['Public · Shard'] // #swagger.summary = 'Gold-supply time series (oldest → newest)' // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max samples (default 100, max 1000).' } - /* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardEconomyPoint" } } } } } */ + /* #swagger.responses[200] = { description: 'Economy samples', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardEconomyPoint" } } } } } */ query('limit').optional().isInt({ min: 1, max: 1000 }), validate, shard.getEconomy, @@ -70,7 +70,7 @@ shardRouter.get( requireFeature('presence'), // #swagger.tags = ['Public · Shard'] // #swagger.summary = 'Staff online now (linked staff accounts; location is admin/moderator-only)' - /* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardOnlinePlayer" } } } } } */ + /* #swagger.responses[200] = { description: 'Online players', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardOnlinePlayer" } } } } } */ shard.getOnline, ) shardRouter.get( @@ -79,7 +79,7 @@ shardRouter.get( // #swagger.tags = ['Public · Shard'] // #swagger.summary = 'Houses currently in danger (IDOC)' // #swagger.description = 'Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\'s field rules (default `staff`), and the owner\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.' - /* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ + /* #swagger.responses[200] = { description: 'IDOC houses', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */ shard.getIdoc, ) shardRouter.get( @@ -137,7 +137,7 @@ shardRouter.get( // #swagger.tags = ['Public · Shard'] // #swagger.summary = 'House registry (owner, co-owners, price, decay)' // #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.' - /* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ + /* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardHouse" } } } } } */ shard.getHouses, ) shardRouter.get( @@ -155,7 +155,7 @@ shardRouter.get( // #swagger.tags = ['Public · Shard'] // #swagger.summary = 'Points / loyalty leaderboards, one board per point system' // #swagger.description = 'Every points/loyalty leaderboard the shard publishes (Queen\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.' - /* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardPointsBoard" } } } } } */ + /* #swagger.responses[200] = { description: 'Boards, ordered by display name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/UoShardPointsBoard" } } } } } */ shard.getPointsBoards, ) shardRouter.get( @@ -165,7 +165,7 @@ shardRouter.get( // #swagger.summary = 'One points system\'s leaderboard' // #swagger.description = 'A single board by the shard\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.' /* #swagger.parameters['system'] = { in: 'path', required: true, description: 'PointsType name, e.g. QueensLoyalty', schema: { type: 'string' } } */ - /* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardPointsBoard" } } } } */ + /* #swagger.responses[200] = { description: 'The board', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardPointsBoard" } } } } */ /* #swagger.responses[400] = { description: 'Malformed system name' } */ /* #swagger.responses[404] = { description: 'The shard has never published that system' } */ shard.getPointsBoard, @@ -185,13 +185,13 @@ shardRouter.get( // #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the resolved item name or the item\'s own literal name (max 60 chars).' } // #swagger.parameters['minPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Lowest price to include.' } // #swagger.parameters['maxPrice'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Highest price to include.' } - // #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match, for "more like this".' } + // #swagger.parameters['itemId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Exact ItemID (art id) match — the more-like-this filter.' } // #swagger.parameters['map'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet. Facet names come from the shard\'s own data; an unknown one returns an empty page.' } // #swagger.parameters['region'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one named region.' } - // #swagger.parameters['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. `recent` orders by when the shop was last seen.' } + // #swagger.parameters['sort'] = { in: 'query', required: false, schema: { type: 'string', enum: ['price_asc','price_desc','recent'] }, description: 'Default price_asc. recent orders by when the shop was last seen.' } // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' } // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' } - /* #swagger.responses[200] = { description: 'A page of listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketPage" } } } } */ + /* #swagger.responses[200] = { description: 'A page of listings plus the unpaginated total and the staleness stamp', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardMarketPage" } } } } */ /* #swagger.responses[403] = { description: 'The market feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[404] = { description: 'The market feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[429] = { description: 'Rate limited', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ @@ -213,7 +213,7 @@ shardRouter.get( // #swagger.tags = ['Public · Shard'] // #swagger.summary = 'Marketplace size, staleness and filter options' // #swagger.description = 'How many vendors and listings the index holds, how stale it may be (`staleAt` = the oldest vendor row, `freshAt` = the newest), and which facets and regions actually hold vendors — so a client can build its filters without running a search it will discard.' - /* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketMeta" } } } } */ + /* #swagger.responses[200] = { description: 'Marketplace metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardMarketMeta" } } } } */ shard.getMarketMeta, ) shardRouter.get( @@ -226,7 +226,7 @@ shardRouter.get( /* #swagger.parameters['serial'] = { in: 'path', required: true, description: 'Vendor serial, e.g. 0x40001234', schema: { type: 'string' } } */ // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to return, 1..500 (default 250).' } // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Listings to skip (default 0).' } - /* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardMarketVendor" } } } } */ + /* #swagger.responses[200] = { description: 'The vendor', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardMarketVendor" } } } } */ /* #swagger.responses[400] = { description: 'Malformed vendor serial' } */ /* #swagger.responses[404] = { description: 'No such vendor in the index' } */ param('serial').isString().isLength({ max: 20 }), @@ -240,7 +240,7 @@ shardRouter.get( // #swagger.tags = ['Public · Shard'] // #swagger.summary = 'Shard features visible to the caller (drives client nav)' // #swagger.description = 'The caller\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.' - /* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardFeatures" } } } } */ + /* #swagger.responses[200] = { description: 'Visible features', content: { "application/json": { schema: { $ref: "#/components/schemas/UoShardFeatures" } } } } */ shard.getFeatures, ) shardRouter.get( diff --git a/server/scripts/frozenManifest.js b/server/scripts/frozenManifest.js new file mode 100644 index 0000000..4863a18 --- /dev/null +++ b/server/scripts/frozenManifest.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node +// ── §5.3 — this module's frozen route manifest ───────────────────────────── +// +// Core freezes its URL surface in `server/routes.manifest.json` by walking the +// live Express stack and committing the result; a PR that moves a URL has to +// commit the new manifest, which puts the change in front of a reviewer. After +// phase 3 the seventy URLs this module serves are no longer in that file. They +// are here, frozen the same way and by the same generator. +// +// **The module's routes are DERIVED, never listed.** This script is handed two +// manifests generated from the SAME core at the pinned ref — one without this +// module on the volume, one with — and the difference is what this module serves. +// Nothing here says "/api/v1/public/shard/*"; a mount prefix appears in exactly +// one place, `server/index.js`'s `registerRoutes` call, which is where an operator's +// core reads it from too. +// +// Taking the difference rather than filtering by prefix buys the other half of +// §5.3 for free, and it is the half that matters most: **no core URL may move.** +// A module that shadowed a core route, or whose mount displaced one, shows up +// here as a removal or a change, not merely as an addition somewhere else. That +// is the promise §1.2 makes to the shipped Android app and the Discord bot. +// +// The third thing it checks is the OpenAPI fragment (§2.8). `swagger-fragment.json` +// is generated from the module's own registrations against §2.4's stated tier +// bases — the one place a constant could be wrong. Here there is ground truth: a +// real core with this module loaded, reporting the URLs it actually serves. Every +// route must have a documented operation and every documented operation must be a +// route. That is the per-module form of core's standing rule, never ship a route +// that isn't in the spec — and it is what stops a wrong constant in the generator +// from producing a fragment that is internally consistent and describes nothing +// core will ever serve. +// +// Usage (the workflow does the cloning; see .gitea/workflows/frozen-manifest.yml): +// node scripts/frozenManifest.js --before core-only.json --after core-plus-uo.json +// node scripts/frozenManifest.js --before … --after … --check + +const fs = require('fs') +const path = require('path') + +const MODULE_ROOT = path.resolve(__dirname, '..', '..') +const MANIFEST = path.join(MODULE_ROOT, 'routes.manifest.json') +const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json') + +const COMMENT = + 'Generated inventory of the URLs module-uo serves - the module half of the freeze ' + + 'core keeps in server/routes.manifest.json. DERIVED as the difference between a core ' + + 'without this module and the same core with it, both at the pinned ref in ci/core-ref.json. ' + + 'Regenerate with the frozen-manifest workflow; see server/scripts/frozenManifest.js.' + +const key = (r) => `${r.method} ${r.path}` + +/** + * The module's routes, plus proof that core's own surface did not move. + * + * @param {object} before routes.manifest.json from core alone + * @param {object} after routes.manifest.json from the same core with this module + * @returns {{ added: object[], removed: string[] }} + */ +function diffManifests(before, after) { + const added = [] + const removed = [] + + for (const tier of ['public', 'internal']) { + const was = new Set((before[tier] || []).map(key)) + for (const route of after[tier] || []) { + if (!was.has(key(route))) added.push({ ...route, tier }) + was.delete(key(route)) + } + for (const gone of was) removed.push(`${tier} ${gone}`) + } + + added.sort((a, b) => (key(a) < key(b) ? -1 : 1)) + return { added, removed } +} + +/** + * Which of the module's routes the fragment fails to document, and vice versa. + * + * Express `:id` is OpenAPI `{id}`; the fragment is already in OpenAPI's spelling + * because that is what core merges, so the manifest's paths are converted here + * rather than the other way round. + */ +function coverage(added, fragment) { + const documented = new Set() + for (const [p, item] of Object.entries(fragment.paths || {})) { + for (const method of Object.keys(item)) documented.add(`${method.toUpperCase()} ${p}`) + } + + const undocumented = [] + for (const route of added) { + const oas = `${route.method} ${route.path.replace(/:([A-Za-z0-9_]+)/g, '{$1}')}` + if (documented.has(oas)) documented.delete(oas) + else undocumented.push(oas) + } + + // Whatever is left is documented and not served: a route that moved or was + // deleted while its annotation stayed behind. Core's spec has no equivalent + // check and grew four orphan tags and thirty-three orphan schemas because of it. + return { undocumented, unserved: [...documented].sort() } +} + +function serialize(routes) { + return `${JSON.stringify( + { + $comment: COMMENT, + routes: routes.map(({ method, path: p, tier }) => ({ method, path: p, tier })), + }, + null, + 2, + )}\n` +} + +function main() { + const arg = (name) => { + const i = process.argv.indexOf(name) + return i === -1 ? null : process.argv[i + 1] + } + const beforePath = arg('--before') + const afterPath = arg('--after') + if (!beforePath || !afterPath) { + process.stderr.write('usage: frozenManifest.js --before --after [--check]\n') + process.exit(2) + } + + const before = JSON.parse(fs.readFileSync(beforePath, 'utf8')) + const after = JSON.parse(fs.readFileSync(afterPath, 'utf8')) + const { added, removed } = diffManifests(before, after) + + let failed = false + + if (removed.length > 0) { + process.stderr.write( + `\nLoading this module REMOVED or CHANGED ${removed.length} of core's own route(s):\n` + + `${removed.map((r) => ` - ${r}`).join('\n')}\n` + + 'A module may only add. This is the frozen-URL promise (MODULE_SYSTEM.md §1.2) breaking.\n', + ) + failed = true + } + + if (added.length === 0) { + process.stderr.write( + '\nLoading this module added NO routes. Either it failed to load in the core checkout\n' + + '(check the boot log for a startup_failed line) or the two manifests are the same file.\n', + ) + process.exit(1) + } + + const fragment = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8')) + const { undocumented, unserved } = coverage(added, fragment) + if (undocumented.length > 0) { + process.stderr.write( + `\n${undocumented.length} route(s) this module serves have no operation in swagger-fragment.json:\n` + + `${undocumented.map((r) => ` - ${r}`).join('\n')}\n` + + 'Run `npm run swagger --prefix server` and commit the result (MODULE_API.md §2.8).\n', + ) + failed = true + } + if (unserved.length > 0) { + process.stderr.write( + `\n${unserved.length} operation(s) in swagger-fragment.json are not routes this module serves:\n` + + `${unserved.map((r) => ` - ${r}`).join('\n')}\n` + + 'A documented URL nobody serves is a client following the docs into a 404.\n', + ) + failed = true + } + + if (failed) process.exit(1) + + const contents = serialize(added) + if (process.argv.includes('--check')) { + const current = fs.existsSync(MANIFEST) ? fs.readFileSync(MANIFEST, 'utf8').replace(/\r\n/g, '\n') : null + if (current !== contents) { + process.stderr.write( + '\nroutes.manifest.json is stale. The URLs this module serves changed — regenerate it and\n' + + 'commit the result so the move is reviewed rather than merged as mechanical.\n', + ) + process.exit(1) + } + process.stdout.write(`routes.manifest.json is current — ${added.length} routes, all documented\n`) + return + } + + fs.writeFileSync(MANIFEST, contents) + process.stdout.write(`wrote routes.manifest.json — ${added.length} routes, all documented\n`) +} + +if (require.main === module) main() + +module.exports = { diffManifests, coverage, serialize, MANIFEST, FRAGMENT } diff --git a/server/scripts/swaggerFragment.js b/server/scripts/swaggerFragment.js new file mode 100644 index 0000000..33a5b3a --- /dev/null +++ b/server/scripts/swaggerFragment.js @@ -0,0 +1,281 @@ +#!/usr/bin/env node +// ── §2.8 — the OpenAPI fragment ──────────────────────────────────────────── +// +// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths, +// tags and schemas describing every route this module registers. Core merges the +// fragments of *started* modules over its own committed spec at request time and +// serves the result at `/api/docs.json` (docs/website/MODULE_API.md §6.1a). +// +// **Why a module ships a fragment at all.** Core's `npm run swagger` is STATIC +// analysis — swagger-autogen parses `src/app.js` as text and follows the literal +// `app.use(...)` chain. A module arrives on a volume after core was built, is +// required by a filesystem loop, and mounts through `api.registerRoutes()`. There +// is no literal mount for a parser to follow and core does not have our sources +// anyway, so nothing core can run will ever describe these routes. The failure +// mode is the dangerous one: swagger-autogen reports success and emits a spec +// with the routes simply absent (§6.1, and core hit it twice — the spike's atlas +// paths and PR 4's 407 deleted lines). +// +// ── Where the prefixes come from ─────────────────────────────────────────── +// +// swagger-autogen is pointed at one router file at a time, so its paths come out +// relative to that router (`/status`, not `/api/v1/public/shard/status`) — nothing +// in the file says where it hangs. §6.1a requires fully-qualified paths, because +// core merges the fragment verbatim and never re-derives a prefix. +// +// So this script **runs the module's own `register()`** against a recording `api` +// and reads the mounts back out of it. The prefix of every router is therefore the +// prefix that router is actually registered under — the same call an operator's +// core will make, not a table beside it that drifts the first time a mount moves. +// Which router a recorded object came from is answered by `require.cache`: the +// file whose `module.exports` IS this router. +// +// The two things that cannot be derived here are the tier base paths and the +// extension slot's mount, because they are core's, not ours. They are §2.4's +// normative table, quoted below — and they are not taken on trust: the frozen +// route manifest (`scripts/frozenManifest.js`) generates the real URLs from a real +// core with this module loaded, and fails if a fragment path is not among them. +// That check is where a wrong constant here dies. + +const fs = require('fs') +const os = require('os') +const path = require('path') + +const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' }) + +const { fakeCtx, fakeApi } = require('../test/_fakes') +const doc = require('../swagger/doc') + +const MODULE_ROOT = path.resolve(__dirname, '..', '..') +const SERVER_ROOT = path.join(MODULE_ROOT, 'server') +const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json') + +// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's +// router in core, behind its gate; the tier's own base path is core's and fixed +// by §1.2's frozen URL surface. +const TIER_BASE = { + public: '/api/v1/public', + admin: '/api/v1/admin', + player: '/api/v1/player', +} + +// MODULE_API.md §2.4's slot table. Exactly one slot exists in v1, and only core +// may declare one — so a module filling it has to be told where it landed. +const SLOT_MOUNT = { + 'admin.users.detail': '/api/v1/admin/users/:id', +} + +/** + * Run `register()` with a recording api and return `[{ file, prefix }]`. + * + * The ctx is the test fakes' — the same one the suite proves the module runs + * against — because registration must not touch a database (§2.2 rule 1) and this + * script is exactly the kind of no-database caller that rule exists for. + */ +function mountedRouters() { + const register = require('../index') + const api = fakeApi() + register(fakeCtx(), api) + + const fileOf = (router) => { + for (const mod of Object.values(require.cache)) { + if (mod && mod.exports === router) return mod.filename + } + return null + } + + const mounts = [] + for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) { + const base = TIER_BASE[tier] + if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`) + for (const [prefix, router] of Object.entries(byPrefix)) { + mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` }) + } + } + for (const { slot, router } of api.record.extensions) { + const mount = SLOT_MOUNT[slot] + if (!mount) throw new Error(`swagger: filled slot "${slot}", which §2.4's table does not list`) + mounts.push({ router, prefix: mount, what: `slot ${slot}` }) + } + + return mounts.map(({ router, prefix, what }) => { + const file = fileOf(router) + if (!file) { + // A router built inline in index.js rather than required from its own file. + // swagger-autogen needs a file to read, so there is nothing to generate from. + throw new Error(`swagger: cannot find the source file of the router for ${what}`) + } + return { file, prefix, what } + }) +} + +/** + * Run swagger-autogen over one router file. Paths come out router-relative. + * + * **swagger-autogen reports a broken annotation and then succeeds anyway** — it + * `console.error`s "Syntax error" or "out of structure", drops that one + * annotation, and prints `Success` in green. Four of the annotations that came + * across in slice 1 were broken that way and had been for as long as they had + * existed in core: two `requestBody` literals a brace short, and two descriptions + * whose inner quoting the tool cannot survive (it re-quotes `"` and a backtick to + * `'` before evaluating, so either inside a single-quoted description ends the + * string early). The visible result was a documented route missing its body, or a + * typed query parameter demoted to an untyped one. + * + * So its diagnostics are captured and made fatal. This is the same class as every + * other failure in this seam — a generator that reports success while silently + * dropping what it was asked to describe (§6.1) — and the only difference is that + * here the tool does say something. Nothing was listening. + */ +async function fragmentFor(file) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'uo-swagger-')) + const out = path.join(dir, 'fragment.json') + + const complaints = [] + const realError = console.error + console.error = (...args) => { + const line = args.map(String).join(' ') + if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim()) + else realError(...args) + } + try { + // A DEEP COPY per call, and that is not defensive style. swagger-autogen + // renders `components.schemas` from an EXAMPLE object rather than treating it + // as OpenAPI — `{ type: 'object' }` comes back as `{ type: 'object', + // properties: { type: { type: 'string', example: 'object' } } }`, a + // meta-description of itself. That shape is uniform across core's committed + // spec and is the house shape, so it is matched rather than fought. What is + // NOT survivable is that it writes the result back into the object it was + // handed: reusing one `doc` across six routers re-wraps the previous pass's + // output five more times, and the fragment came out at 484 MB. + await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], { + ...JSON.parse(JSON.stringify(doc)), + info: { title: 'module-uo fragment', version: '0' }, + }) + } finally { + console.error = realError + } + if (complaints.length > 0) { + throw new Error( + `swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` + + `swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`, + ) + } + + const fragment = JSON.parse(fs.readFileSync(out, 'utf8')) + fs.rmSync(dir, { recursive: true, force: true }) + return fragment +} + +/** + * Re-root a router-relative fragment under the prefix it is mounted at. + * + * Express path params (`:id`) become OpenAPI's (`{id}`), and the prefix's own + * params are moved to the FRONT of each operation's parameter list: swagger-autogen + * orders parameters by where they appeared in the path it saw, which was only the + * tail, so `/{id}/shard/link/{account}` would otherwise document (account, id). + */ +function prefixPaths(fragment, prefix) { + const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '') + const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1]) + const paths = {} + for (const [p, item] of Object.entries(fragment.paths || {})) { + for (const operation of Object.values(item)) { + const params = operation && operation.parameters + if (!Array.isArray(params)) continue + const rank = (q) => { + const i = outer.indexOf(q && q.name) + return i === -1 ? outer.length : i + } + operation.parameters = params + .map((q, i) => ({ q, i })) + .sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i) + .map(({ q }) => q) + } + // `router.get('/')` under a prefix concatenates to `/api/v1/public/shard/`, + // a URL no client calls. Core's swagger.js normalizes the same way. + paths[`${oas}${p}`.replace(/\/$/, '')] = item + } + return paths +} + +/** + * Build the whole fragment: every mounted router, re-rooted and merged. + * + * Only `paths`, `tags` and `components.schemas` — the three sections §6.1a allows + * a fragment to carry. `info`, `servers` and the security schemes are the merged + * document's, which is to say core's. + */ +async function build() { + const spec = { paths: {}, tags: [], components: { schemas: {} } } + let shared = false + + for (const { file, prefix, what } of mountedRouters()) { + const generated = await fragmentFor(file) + // The tags and schemas are the SAME on every pass — each was handed the same + // `doc` — so they are taken from whichever ran first rather than from `doc` + // itself. What lands in the fragment has to be what swagger-autogen produced, + // not what it was given: those two differ (see fragmentFor), and core merges + // this file verbatim into a spec whose own schemas went through the same mill. + if (!shared) { + spec.tags = generated.tags || [] + spec.components.schemas = (generated.components || {}).schemas || {} + shared = true + } + const paths = prefixPaths(generated, prefix) + const count = Object.keys(paths).length + if (count === 0) { + // An empty fragment is precisely what the silent drop looks like, so it is + // a hard failure rather than a router that happens to declare no routes. + throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`) + } + for (const [p, item] of Object.entries(paths)) { + if (spec.paths[p]) { + throw new Error(`swagger: two of this module's routers both document ${p}`) + } + spec.paths[p] = item + } + process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix} ← ${what}\n`) + } + + // Sorted, for the reason core sorts: swagger-autogen emits router-traversal + // order, so moving a route between files would rewrite most of this committed + // artifact even when the API is provably unchanged. + spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1))) + return spec +} + +async function main() { + const check = process.argv.includes('--check') + const spec = await build() + const json = `${JSON.stringify(spec, null, 2)}\n` + + if (!check) { + fs.writeFileSync(FRAGMENT, json) + process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)} — ${Object.keys(spec.paths).length} paths\n`) + return + } + + if (!fs.existsSync(FRAGMENT)) { + process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n') + process.exit(1) + } + if (fs.readFileSync(FRAGMENT, 'utf8') !== json) { + process.stderr.write( + '\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' + + 'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' + + 'so a stale one documents a URL surface this module does not serve.\n', + ) + process.exit(1) + } + process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`) +} + +if (require.main === module) { + main().catch((err) => { + process.stderr.write(`${err.stack}\n`) + process.exit(1) + }) +} + +module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, SLOT_MOUNT, FRAGMENT } diff --git a/server/swagger/doc.js b/server/swagger/doc.js new file mode 100644 index 0000000..1afa6cf --- /dev/null +++ b/server/swagger/doc.js @@ -0,0 +1,621 @@ +// ── module-uo's OpenAPI fragment: the shared half ────────────────────────── +// +// The tags and component schemas every `#swagger.*` annotation under +// `server/router/**` refers to. `scripts/swaggerFragment.js` feeds this to +// swagger-autogen; the per-endpoint detail lives beside each route, exactly as +// it does in core. +// +// These 31 schemas were core's until phase 3 — they sat in +// `website/server/swagger/swagger.js` describing routes core no longer serves, +// which is what an extraction leaves behind if nobody looks (the inert-leaf +// class slice 4 found in `api/client.js`). They moved with the routes. +// +// **Two rules about names, and both are the merged document's, not this file's** +// (docs/website/MODULE_API.md §6.1a): +// +// • **What this module DEFINES is namespaced `Uo…`.** Core merges started +// modules' fragments into one `/api/docs.json`, and core wins every key +// collision — so an un-namespaced `ShardStatus` from a second game's module +// would silently lose to, or clobber, this one. The prefix is what makes two +// modules able to describe the same idea. +// • **What core defines is referenced by CORE's name.** The annotations point +// at `#/components/schemas/Error` and `ValidationError` and this file does +// not redefine them: they resolve in the merged spec, where core's +// definitions are. Shipping our own copy would be a collision core drops, +// which is the correct outcome arrived at the expensive way. +// +// Tag NAMES are core's originals (`Public · Shard`, not `Uo · Shard`). A tag is +// how the docs UI groups operations, and core stopped declaring these four in +// the same slice this file started — nothing collides, and renaming them would +// churn every reader's bookmark for no gain. + +module.exports = { + tags: [ + { name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' }, + { name: 'Public · Atlas', description: 'Spawn atlas / bestiary — static shard content parsed from the shard\'s own ServUO tree, independent of the sidecar' }, + { name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' }, + { name: 'Admin · Shard', description: 'uo-link sidecar connection config, live status and town crier (admin only)' }, + ], + components: { + schemas: { + // ── uo-link shard data ────────────────────────────────────────────── + UoShardStatus: { + type: 'object', + description: 'Public shard status (GET /public/shard/status).', + properties: { + enabled: { type: 'boolean', example: true }, + status: { type: 'string', example: 'connected', description: 'connected | reconnecting | disconnected | error' }, + pluginConnected: { type: 'boolean', description: 'Is the shard link up right now?', example: true }, + lastEventAt: { type: 'string', format: 'date-time', nullable: true }, + onlineCount: { type: 'integer', example: 12 }, + economy: { $ref: '#/components/schemas/UoShardEconomyPoint' }, + }, + }, + UoShardEvent: { + type: 'object', + description: 'A logged shard event.', + properties: { + id: { type: 'integer', example: 4821 }, + kind: { type: 'string', example: 'vendor.sale' }, + t: { type: 'integer', description: 'Event time, epoch ms.', example: 1783720195626 }, + bootId: { type: 'string', nullable: true, example: 'boot-abc123' }, + payload: { type: 'object', additionalProperties: true, description: 'The full event object.' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + UoShardEconomyPoint: { + type: 'object', + nullable: true, + description: 'One gold-supply sample.', + properties: { + accounts: { type: 'integer', nullable: true, example: 240 }, + gold: { type: 'integer', nullable: true, example: 1028983421 }, + t: { type: 'integer', description: 'Sample time, epoch ms.', example: 1783720000000 }, + }, + }, + UoShardOnlinePlayer: { + type: 'object', + description: 'A LINKED player online now (only accounts linked to a website user are listed).', + properties: { + serial: { type: 'string', example: '0x24C' }, + name: { type: 'string', example: 'Darrow' }, + map: { type: 'string', nullable: true, example: 'Trammel' }, + x: { type: 'integer', nullable: true, example: 1402 }, + y: { type: 'integer', nullable: true, example: 1604 }, + z: { type: 'integer', nullable: true, example: 0 }, + }, + }, + UoShardVendorSale: { + type: 'object', + description: 'A player-vendor sale (visible only to the linked owner).', + properties: { + t: { type: 'integer', description: 'Sale time, epoch ms.', example: 1783720195626 }, + itemType: { type: 'string', example: 'Longsword' }, + amount: { type: 'integer', example: 1 }, + price: { type: 'integer', example: 100 }, + commission: { type: 'integer', nullable: true, example: 5 }, + ownerAcct: { type: 'string', example: 'whitlocktech' }, + }, + }, + UoShardHouse: { + type: 'object', + description: 'A house at its current decay stage.', + properties: { + serial: { type: 'string', example: '0x4004705F' }, + stage: { type: 'string', example: 'IDOC' }, + map: { type: 'string', nullable: true, example: 'Trammel' }, + x: { type: 'integer', nullable: true }, + y: { type: 'integer', nullable: true }, + z: { type: 'integer', nullable: true }, + region: { type: 'string', nullable: true }, + name: { type: 'string', nullable: true, example: 'An Unnamed House' }, + ownerSerial: { type: 'string', nullable: true }, + ownerAcct: { type: 'string', nullable: true }, + builtOn: { type: 'string', format: 'date-time', nullable: true }, + lastRefreshed: { type: 'string', format: 'date-time', nullable: true }, + isIdoc: { type: 'boolean', example: true }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + UoShardPointsBoard: { + type: 'object', + description: + "One point system's leaderboard (Protocol 3.0 points.board). The shard carries ~25 separate point currencies; each publishes its own board. The display name may arrive as a literal string, a cliloc id, or both — resolve clilocs client-side.", + properties: { + system: { type: 'string', example: 'QueensLoyalty', description: "The shard's PointsType name; the board's stable key." }, + nameString: { type: 'string', nullable: true, example: "Queen's Loyalty" }, + nameNumber: { type: 'integer', nullable: true, example: 1114938, description: 'Cliloc id, 0 when the name is a literal.' }, + maxPoints: { type: 'integer', nullable: true, example: 30000 }, + players: { type: 'integer', nullable: true, example: 842, description: 'Players actually holding points in this system.' }, + showOnGump: { type: 'boolean', example: true, description: "The shard's own 'is this player-facing?' flag." }, + top: { + type: 'array', + description: 'The ranked players, best first. Capped by the shard (10 by default). Empty when nobody has scored yet.', + items: { + type: 'object', + properties: { + rank: { type: 'integer', example: 1 }, + serial: { type: 'string', example: '0x1A2B' }, + name: { type: 'string', example: 'Darrow', description: 'Omitted when the leaderboards `name` field is gated above the caller.' }, + points: { type: 'integer', example: 29500 }, + }, + }, + }, + t: { type: 'integer', nullable: true, description: 'Frame time, epoch ms.' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + UoShardMarketLocation: { + type: 'object', + nullable: true, + description: + "Where a vendor is standing. ONE nested object rather than flat map/x/y/region because it is one admin-configurable field (`market.location`) — the whole object is omitted when that field is gated above the caller.", + properties: { + map: { type: 'string', nullable: true, example: 'Trammel' }, + x: { type: 'integer', nullable: true, example: 1421 }, + y: { type: 'integer', nullable: true, example: 1699 }, + z: { type: 'integer', nullable: true, example: 0 }, + region: { type: 'string', nullable: true, example: 'Britain' }, + house: { type: 'string', nullable: true, example: "Darrow's Villa", description: "The house SIGN's name, not the house type. Null for a vendor standing outside one." }, + }, + }, + UoShardMarketListing: { + type: 'object', + description: + 'One priced listing on a player vendor, carrying enough of its shop to be actionable without a second request.', + properties: { + serial: { type: 'string', example: '0x40012ABC' }, + itemId: { type: 'integer', example: 3922, description: 'ItemID (the art/graphic id).' }, + hue: { type: 'integer', example: 0 }, + amount: { type: 'integer', example: 1 }, + price: { type: 'integer', example: 25000 }, + name: { type: 'string', nullable: true, description: "The item's own literal name, set by a player. Null for most items." }, + cliloc: { type: 'integer', nullable: true, example: 1023721, description: "The item's LabelNumber." }, + displayName: { + type: 'string', + nullable: true, + example: 'quarter staff', + description: 'Resolved server-side from `name` (preferred, being player-set and more specific) else `cliloc`. Null on a shard with no cliloc table configured — render the item id.', + }, + child: { type: 'boolean', example: false, description: 'Priced by an enclosing container rather than itself, exactly as the in-game Vendor Search reports it.' }, + vendor: { + type: 'object', + properties: { + serial: { type: 'string', example: '0x40001234' }, + shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" }, + ownerSerial: { type: 'string', nullable: true, example: '0x1A2B', description: 'Omitted when the market `ownerSerial` field is gated above the caller.' }, + ownerName: { type: 'string', nullable: true, example: 'Darrow', description: 'Omitted when the market `ownerName` field is gated above the caller.' }, + location: { $ref: '#/components/schemas/UoShardMarketLocation' }, + updatedAt: { type: 'string', format: 'date-time', description: 'When the shard last published this shop.' }, + }, + }, + }, + }, + UoShardMarketPage: { + type: 'object', + description: 'A page of marketplace listings plus the unpaginated total and the staleness stamp.', + properties: { + listings: { type: 'array', items: { $ref: '#/components/schemas/UoShardMarketListing' } }, + total: { type: 'integer', example: 1284, description: 'Matching listings, ignoring paging.' }, + limit: { type: 'integer', example: 50 }, + offset: { type: 'integer', example: 0 }, + vendors: { type: 'integer', example: 137, description: 'Vendors in the whole index.' }, + staleAt: { + type: 'string', + format: 'date-time', + nullable: true, + description: 'The OLDEST vendor row. The shard sweeps vendors round-robin, so the index can be a full cycle behind and a client must say so rather than implying live prices.', + }, + }, + }, + UoShardMarketVendor: { + type: 'object', + description: 'One player vendor and its listings.', + properties: { + serial: { type: 'string', example: '0x40001234' }, + shopName: { type: 'string', nullable: true, example: "Darrow's Bargains" }, + ownerSerial: { type: 'string', nullable: true }, + ownerName: { type: 'string', nullable: true, example: 'Darrow' }, + location: { $ref: '#/components/schemas/UoShardMarketLocation' }, + count: { type: 'integer', example: 250, description: 'Listings the shard published for this shop.' }, + total: { type: 'integer', example: 3104, description: 'Listings the shop actually holds.' }, + truncated: { type: 'boolean', example: true, description: '`total` exceeds `count` — the shop holds more than the shard publishes per frame.' }, + updatedAt: { type: 'string', format: 'date-time' }, + items: { type: 'array', items: { $ref: '#/components/schemas/UoShardMarketListing' } }, + }, + }, + UoShardMarketMeta: { + type: 'object', + description: 'Marketplace size, staleness and the filter options a client needs to build its UI.', + properties: { + vendors: { type: 'integer', example: 137 }, + items: { type: 'integer', example: 18422 }, + staleAt: { type: 'string', format: 'date-time', nullable: true }, + freshAt: { type: 'string', format: 'date-time', nullable: true }, + maps: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Trammel'], description: "Facets that actually hold vendors. From the shard's own data — never a hardcoded list." }, + regions: { type: 'array', items: { type: 'string' }, example: ['Britain', 'Luna'] }, + }, + }, + UoShardFeatures: { + type: 'object', + description: + "The shard features the caller may reach, plus the audience rung they resolved to. Drives client nav so it never renders a link that would 403.", + properties: { + level: { + type: 'string', + enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'], + example: 'anonymous', + }, + features: { + type: 'array', + items: { type: 'string' }, + example: ['status', 'activity', 'champs', 'guilds', 'governors', 'houses', 'presence'], + }, + }, + }, + UoShardFeatureVisibility: { + type: 'object', + description: 'Visibility settings for one shard feature.', + properties: { + enabled: { type: 'boolean', example: true }, + audience: { + type: 'string', + enum: ['anonymous', 'logged_in', 'player', 'staff', 'admin'], + description: 'Minimum rung that may reach this feature. Each rung implies the ones below it.', + example: 'anonymous', + }, + stream: { + type: 'boolean', + description: "Whether this feature's event kinds fan out over SSE at all.", + example: true, + }, + fieldRules: { + type: 'object', + additionalProperties: { type: 'string' }, + description: + 'Per-field rung overrides for the sensitive fields this feature exposes. acct / webId are admin-only always and are rejected here.', + example: { location: 'staff' }, + }, + }, + }, + UoShardVisibilityConfig: { + type: 'object', + properties: { + ladder: { + type: 'array', + items: { type: 'string' }, + example: ['anonymous', 'logged_in', 'player', 'staff', 'admin'], + }, + lockedFields: { type: 'array', items: { type: 'string' }, example: ['acct', 'webId'] }, + defaults: { + type: 'object', + additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' }, + }, + features: { + type: 'object', + additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' }, + }, + }, + }, + UoShardVisibilityUpdate: { + type: 'object', + required: ['features'], + properties: { + features: { + type: 'object', + additionalProperties: { $ref: '#/components/schemas/UoShardFeatureVisibility' }, + example: { market: { enabled: true, audience: 'player', stream: false, fieldRules: { ownerName: 'player' } } }, + }, + }, + }, + // ── Spawn atlas (Protocol 3.0 Part C) ──────────────────────────────── + // Static shard content, parsed from the shard's own ServUO tree. Nothing + // here comes from the sidecar, so it stays populated while the shard is + // down. Facet names are whatever the shard's files declare — the examples + // below are stock ServUO, not a fixed list. + UoAtlasCreature: { + type: 'object', + description: 'A creature in the bestiary. `places`/`points`/`alsoHere` are present only on the single-creature route.', + properties: { + slug: { type: 'string', example: 'lizardman' }, + name: { type: 'string', example: 'Lizardman' }, + total: { type: 'integer', description: 'How many can be alive at once, summed across every spawner.', example: 214 }, + points: { type: 'integer', description: 'How many spawners mention this creature.', example: 62 }, + facets: { + type: 'object', + additionalProperties: { type: 'integer' }, + description: "This creature's share per facet.", + example: { Felucca: 96, Trammel: 88, Tokuno: 30 }, + }, + art: { type: 'string', nullable: true, description: 'Operator-supplied art under uploads/atlas/. NULL on a fresh import — the repo ships no creature art.' }, + places: { + type: 'array', + description: 'Where it spawns, aggregated by resolved place. The answer the atlas exists to give.', + items: { + type: 'object', + properties: { + facet: { type: 'string', example: 'Trammel' }, + label: { type: 'string', description: 'Resolved region, else nearest landmark group, else "Wilderness".', example: 'Shrines' }, + spawners: { type: 'integer', example: 7 }, + maxAlive: { type: 'integer', example: 21 }, + }, + }, + }, + spawners: { + type: 'array', + description: 'The individual spawners. Named separately from `points` (the count) so one key never means two things.', + items: { $ref: '#/components/schemas/UoAtlasSpawner' }, + }, + spawnersTruncated: { type: 'boolean', description: 'True when the spawner list was cut at the requested bound.', example: false }, + alsoHere: { + type: 'array', + description: 'Creatures sharing a spawner with this one.', + items: { + type: 'object', + properties: { + slug: { type: 'string', example: 'lizardman-warrior' }, + name: { type: 'string', example: 'Lizardman Warrior' }, + shared: { type: 'integer', example: 12 }, + }, + }, + }, + }, + }, + UoAtlasSpawner: { + type: 'object', + description: 'One ServUO spawner, with the place its coordinates resolved to.', + properties: { + id: { type: 'integer' }, + facet: { type: 'string', example: 'Felucca' }, + name: { type: 'string', nullable: true, description: "The spawner's own name in the ServUO file." }, + x: { type: 'integer', example: 5411 }, + y: { type: 'integer', example: 1234 }, + width: { type: 'integer' }, + height: { type: 'integer' }, + range: { type: 'integer', description: 'Spawn radius.' }, + maxCount: { type: 'integer', description: 'How many of THIS creature this spawner keeps alive.', example: 3 }, + minDelay: { type: 'integer', description: 'Respawn window, in SECONDS. Normalised at parse time — the source stores minutes or seconds per record, decided by its own DelayInSec flag.', example: 300 }, + maxDelay: { type: 'integer', example: 600 }, + todStart: { type: 'integer', description: 'Meaningless unless todMode is non-zero.' }, + todEnd: { type: 'integer' }, + todMode: { type: 'integer' }, + region: { type: 'string', nullable: true, example: 'Despise' }, + landmark: { type: 'string', nullable: true, example: 'Covetous' }, + label: { type: 'string', description: 'Region, else landmark group, else "Wilderness".', example: 'Despise' }, + }, + }, + UoAtlasCreaturePage: { + type: 'object', + properties: { + total: { type: 'integer', description: 'Matching creatures before pagination.', example: 800 }, + limit: { type: 'integer', example: 50 }, + offset: { type: 'integer', example: 0 }, + creatures: { type: 'array', items: { $ref: '#/components/schemas/UoAtlasCreature' } }, + }, + }, + UoAtlasRegion: { + type: 'object', + description: 'A named region, flattened out of the shard\'s nested Regions.xml.', + properties: { + facet: { type: 'string', example: 'Felucca' }, + name: { type: 'string', example: 'Despise' }, + type: { type: 'string', nullable: true, description: 'ServUO region class.', example: 'DungeonRegion' }, + priority: { type: 'integer', example: 50 }, + parent: { type: 'string', nullable: true, example: 'Britain' }, + rects: { + type: 'array', + description: 'The rectangles that placed each spawn point.', + items: { type: 'object', additionalProperties: true }, + }, + }, + }, + UoAtlasLandmark: { + type: 'object', + properties: { + facet: { type: 'string', example: 'Trammel' }, + name: { type: 'string', example: 'Level 1' }, + group: { type: 'string', nullable: true, description: 'Innermost enclosing parent — the label worth showing.', example: 'Covetous' }, + x: { type: 'integer', example: 5411 }, + y: { type: 'integer', example: 1234 }, + z: { type: 'integer', example: 0 }, + }, + }, + UoAtlasChampion: { + type: 'object', + description: 'A CONFIGURED champion altar. Not the live board — see GET /public/shard/champs for that.', + properties: { + slug: { type: 'string', example: 'felucca-deceit' }, + name: { type: 'string', example: 'Deceit' }, + group: { type: 'string', nullable: true, description: 'Spawn group; one altar active per group.', example: 'Dungeons' }, + type: { type: 'string', nullable: true, description: 'NULL when the champion is drawn at activation.', example: 'UnholyTerror' }, + randomType: { type: 'boolean', example: false }, + facet: { type: 'string', example: 'Felucca' }, + x: { type: 'integer' }, + y: { type: 'integer' }, + z: { type: 'integer' }, + radius: { type: 'integer', example: 60 }, + label: { type: 'string', nullable: true, example: 'Deceit' }, + }, + }, + UoAtlasMeta: { + type: 'object', + description: 'What atlas is loaded. Game-world facts only: the ServUO path, source hashes and any pending refresh are operator detail and live on the admin status route.', + properties: { + importedAt: { type: 'string', format: 'date-time', nullable: true }, + generatedAt: { type: 'string', format: 'date-time', nullable: true }, + counts: { + type: 'object', + nullable: true, + additionalProperties: true, + example: { facets: 6, points: 6455, creatures: 800, regions: 387, landmarks: 558, champions: 25, unresolvedPoints: 1086 }, + }, + facets: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Ilshenar', 'Malas', 'TerMur', 'Tokuno', 'Trammel'] }, + }, + }, + UoAtlasStatus: { + type: 'object', + description: 'Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.', + properties: { + configured: { type: 'boolean', example: true }, + path: { type: 'string', example: '/srv/servuo' }, + treeReadable: { type: 'boolean', example: true }, + drift: { type: 'boolean', nullable: true, description: 'True when the tree\'s source hashes differ from the loaded atlas. NULL when the tree could not be read.', example: false }, + facets: { type: 'array', items: { type: 'string' } }, + importedAt: { type: 'string', format: 'date-time', nullable: true }, + counts: { type: 'object', nullable: true, additionalProperties: true }, + pending: { + type: 'object', + nullable: true, + description: 'A refresh that was parsed but NOT applied because it would remove a facet. `status` is pending or rejected.', + additionalProperties: true, + }, + }, + }, + UoAtlasRefreshResult: { + type: 'object', + description: 'Outcome of a refresh. Reported rather than thrown, so an unreadable tree is an answer and not a 500.', + properties: { + status: { + type: 'string', + enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed', 'rejected', 'none'], + example: 'imported', + }, + reason: { type: 'string', nullable: true }, + path: { type: 'string', nullable: true }, + counts: { type: 'object', nullable: true, additionalProperties: true }, + addedFacets: { type: 'array', items: { type: 'string' } }, + removedFacets: { type: 'array', items: { type: 'string' } }, + }, + }, + UoClilocStatus: { + type: 'object', + description: + 'Admin view of cliloc state: where the converted file is, whether it is readable, how many entries are loaded, and whether the file has drifted from them. `configured: false` is a supported state — item names then render as ids.', + properties: { + configured: { type: 'boolean', example: true }, + path: { type: 'string', example: '/srv/uo-client' }, + file: { type: 'string', nullable: true, description: 'The file actually resolved, when the path is a directory.', example: '/srv/uo-client/clilocs.tsv' }, + fileReadable: { type: 'boolean', example: true }, + problem: { type: 'string', nullable: true, description: 'Why the file cannot be used, when it cannot. Set (with code COMPRESSED) for a readable-but-unconverted client file.', example: null }, + code: { type: 'string', nullable: true, description: 'Machine-readable cause of `problem`.', enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED'] }, + drift: { type: 'boolean', nullable: true, description: 'True when any source hash differs from the loaded table. NULL when the sources could not be read or are not usable.', example: false }, + count: { type: 'integer', description: 'Entries currently loaded.', example: 67496 }, + sources: { + type: 'array', + items: { type: 'string' }, + description: 'Every source found now, root-relative, base first then overlays in merge order.', + example: ['clilocs.plain', 'custom/uomysticmoon.tsv'], + }, + loadedSources: { + type: 'array', + nullable: true, + description: 'What each source contributed at the last import.', + items: { + type: 'object', + properties: { + label: { type: 'string', example: 'custom/uomysticmoon.tsv' }, + kind: { type: 'string', enum: ['base', 'custom'], example: 'custom' }, + entries: { type: 'integer', example: 37 }, + added: { type: 'integer', description: 'Ids this source introduced.', example: 25 }, + overrode: { type: 'integer', description: 'Ids it replaced from an earlier source.', example: 12 }, + }, + }, + }, + missingSources: { + type: 'array', + items: { type: 'string' }, + description: 'Sources loaded previously and now absent. An import refuses these without `approve`.', + example: [], + }, + importedAt: { type: 'string', format: 'date-time', nullable: true }, + sourceBytes: { type: 'integer', nullable: true, example: 4973525 }, + }, + }, + UoClilocRefreshResult: { + type: 'object', + description: + 'Outcome of a cliloc refresh. Reported rather than thrown, so a missing or compressed file is an answer and not a 500.', + properties: { + status: { + type: 'string', + enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed'], + description: '`needsReview` means a previously-loaded source has vanished and nothing was applied; re-run with `approve` to accept it.', + example: 'imported', + }, + reason: { type: 'string', nullable: true }, + code: { + type: 'string', + nullable: true, + description: 'Machine-readable cause. `COMPRESSED` means the client\'s own Cliloc.enu was supplied instead of a converted one.', + enum: ['NO_PATH', 'NOT_FOUND', 'NO_FILE', 'UNREADABLE', 'COMPRESSED', 'TRUNCATED', 'EMPTY', 'NOT_BUFFER'], + }, + path: { type: 'string', nullable: true }, + file: { type: 'string', nullable: true }, + count: { type: 'integer', nullable: true, description: 'Entries stored (blank strings are dropped).', example: 67496 }, + parsed: { type: 'integer', nullable: true, description: 'Entries read across every source before blanks were dropped.', example: 123527 }, + blank: { type: 'integer', nullable: true, example: 55994 }, + sources: { + type: 'array', + nullable: true, + description: 'Per-source breakdown: what each file contributed and how much of it overrode an earlier source.', + items: { + type: 'object', + properties: { + label: { type: 'string' }, + kind: { type: 'string', enum: ['base', 'custom'] }, + entries: { type: 'integer' }, + added: { type: 'integer' }, + overrode: { type: 'integer' }, + }, + }, + }, + missingSources: { + type: 'array', + nullable: true, + items: { type: 'string' }, + description: 'On `needsReview`: the sources that vanished. Nothing was applied.', + }, + acceptedMissing: { + type: 'array', + nullable: true, + items: { type: 'string' }, + description: 'On `imported` with `approve`: the vanished sources the admin accepted.', + }, + }, + }, + UoShardLinkRequest: { + type: 'object', + required: ['code'], + properties: { + code: { type: 'string', description: 'The one-time code shown by [link in game.', example: 'AB12CD' }, + }, + }, + UoShardLinkResult: { + type: 'object', + properties: { + linked: { type: 'boolean', example: true }, + account: { type: 'string', example: 'whitlocktech' }, + }, + }, + UoShardLink: { + type: 'object', + description: 'A linked in-game account (GET /player/shard/accounts).', + properties: { + account: { type: 'string', example: 'whitlocktech' }, + userId: { type: 'integer', example: 42 }, + charName: { type: 'string', nullable: true, example: 'Darrow' }, + linkedAt: { type: 'string', format: 'date-time' }, + }, + }, + UoTownCrierRequest: { + type: 'object', + required: ['id', 'lines'], + properties: { + id: { type: 'string', maxLength: 64, description: 'Re-posting the same id replaces the prior entry.', example: 'news-42' }, + lines: { type: 'array', items: { type: 'string', maxLength: 200 }, example: ['Hear ye!', 'Market tax is now 5%.'] }, + durationSec: { type: 'integer', minimum: 1, maximum: 86400, example: 3600 }, + }, + }, + }, + }, +} diff --git a/server/test/frozenManifest.test.js b/server/test/frozenManifest.test.js new file mode 100644 index 0000000..f9879cb --- /dev/null +++ b/server/test/frozenManifest.test.js @@ -0,0 +1,170 @@ +// The frozen manifest's derivation, checked. +// +// `scripts/frozenManifest.js` runs in one place — a CI job with a whole core +// checked out beside it — so it is the least-exercised piece of machinery in this +// repo, and it is the piece that decides whether the URLs this module claims are +// the URLs it serves (MODULE_API.md §5.3). Its three answers are pure functions of +// two manifests and a fragment, so all three are asked here, with fixtures rather +// than a clone. +// +// What is deliberately NOT asserted here: the numbers. `routes.manifest.json`'s +// 72 routes are proved by the job that generates them from a real core, and a +// copy of that count in this file would only ever be a second thing to update. + +const test = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const path = require('node:path') + +const { diffManifests, coverage, MANIFEST, FRAGMENT } = require('../scripts/frozenManifest') + +const manifest = (public_ = [], internal = []) => ({ public: public_, internal }) +const get = (p) => ({ method: 'GET', path: p }) + +test('the module\'s routes are the ones a core gains by loading it', () => { + const before = manifest([get('/api/v1/public/settings')]) + const after = manifest([get('/api/v1/public/settings'), get('/api/v1/public/shard/status')]) + + const { added, removed } = diffManifests(before, after) + assert.deepStrictEqual(removed, []) + assert.deepStrictEqual(added, [{ method: 'GET', path: '/api/v1/public/shard/status', tier: 'public' }]) +}) + +test('a route core loses to the module is reported, not quietly absorbed', () => { + // The failure this exists for: a module whose mount displaces a core route. + // It cannot show up as an addition — the URL is unchanged — so a check that + // only looked at what appeared would call this clean. + const before = manifest([get('/api/v1/public/settings'), get('/api/v1/public/status')]) + const after = manifest([get('/api/v1/public/settings')]) + + const { removed } = diffManifests(before, after) + assert.deepStrictEqual(removed, ['public GET /api/v1/public/status']) +}) + +test('a route whose METHOD changed counts as removed and added', () => { + const { added, removed } = diffManifests( + manifest([{ method: 'POST', path: '/api/v1/admin/thing' }]), + manifest([{ method: 'PUT', path: '/api/v1/admin/thing' }]), + ) + assert.deepStrictEqual(removed, ['public POST /api/v1/admin/thing']) + assert.strictEqual(added.length, 1) +}) + +test('the internal app is diffed too, and keeps its own tier', () => { + const { added } = diffManifests( + manifest([], [get('/internal/health')]), + manifest([], [get('/internal/health'), get('/internal/uo/thing')]), + ) + assert.deepStrictEqual(added, [{ method: 'GET', path: '/internal/uo/thing', tier: 'internal' }]) +}) + +test('added routes are sorted, so the committed file does not churn on traversal order', () => { + const { added } = diffManifests( + manifest([]), + manifest([get('/b'), get('/a'), { method: 'POST', path: '/a' }]), + ) + assert.deepStrictEqual(added.map((r) => `${r.method} ${r.path}`), ['GET /a', 'GET /b', 'POST /a']) +}) + +// ── coverage: the route ⇄ fragment agreement ──────────────────────────────── + +const fragment = (paths) => ({ paths }) + +test('a served route with no documented operation is named', () => { + const { undocumented, unserved } = coverage( + [get('/api/v1/public/shard/status')], + fragment({}), + ) + assert.deepStrictEqual(undocumented, ['GET /api/v1/public/shard/status']) + assert.deepStrictEqual(unserved, []) +}) + +test('a documented operation nobody serves is named too', () => { + // The direction core's own spec has no check for, which is how it accumulated + // four orphan tags and thirty-three orphan schemas describing routes that had + // moved to this repo. A documented URL nobody serves is a client following the + // docs into a 404. + const { undocumented, unserved } = coverage( + [], + fragment({ '/api/v1/public/shard/gone': { get: {} } }), + ) + assert.deepStrictEqual(undocumented, []) + assert.deepStrictEqual(unserved, ['GET /api/v1/public/shard/gone']) +}) + +test('express :params and OpenAPI {params} are the same route', () => { + const { undocumented, unserved } = coverage( + [{ method: 'DELETE', path: '/api/v1/admin/users/:id/shard/link/:account' }], + fragment({ '/api/v1/admin/users/{id}/shard/link/{account}': { delete: {} } }), + ) + assert.deepStrictEqual(undocumented, []) + assert.deepStrictEqual(unserved, []) +}) + +test('methods are matched, not just paths', () => { + const { undocumented, unserved } = coverage( + [{ method: 'POST', path: '/api/v1/admin/shard/kick' }], + fragment({ '/api/v1/admin/shard/kick': { get: {} } }), + ) + assert.deepStrictEqual(undocumented, ['POST /api/v1/admin/shard/kick']) + assert.deepStrictEqual(unserved, ['GET /api/v1/admin/shard/kick']) +}) + +// ── the committed artifacts, against each other ───────────────────────────── +// +// These two files are generated together by a job that has a real core; here +// there is no core, so what can still be asked is whether they agree with each +// other. If they do not, one of them was committed without the other. + +test('every route in the committed manifest has a committed operation', () => { + const routes = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')).routes + const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8')) + const { undocumented, unserved } = coverage(routes, spec) + assert.deepStrictEqual(undocumented, [], 'routes.manifest.json lists routes swagger-fragment.json does not document') + assert.deepStrictEqual(unserved, [], 'swagger-fragment.json documents operations routes.manifest.json does not list') +}) + +test('the fragment carries only the three sections §6.1a allows', () => { + const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8')) + assert.deepStrictEqual(Object.keys(spec).sort(), ['components', 'paths', 'tags']) + assert.deepStrictEqual(Object.keys(spec.components), ['schemas']) +}) + +test('the fragment defines only namespaced schemas, and redefines none of core\'s', () => { + const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8')) + for (const name of Object.keys(spec.components.schemas)) { + assert.match(name, /^Uo[A-Z]/, `${name} is not namespaced — core wins the collision and drops it (§6.1a)`) + } + // Core's shared schemas are REFERENCED by their core names and not redefined; + // they resolve in the merged document, which is the whole point of a fragment. + const refs = JSON.stringify(spec.paths).match(/#\/components\/schemas\/([A-Za-z0-9_]+)/g) || [] + const core = [...new Set(refs.map((r) => r.split('/').pop()))].filter((n) => !n.startsWith('Uo')) + assert.deepStrictEqual(core.sort(), ['Error', 'ValidationError']) +}) + +test('every path in the fragment is fully qualified', () => { + const spec = JSON.parse(fs.readFileSync(FRAGMENT, 'utf8')) + for (const p of Object.keys(spec.paths)) { + // §6.1a: core merges the fragment verbatim and never re-derives a prefix, so + // a router-relative path here is a path nothing serves. + assert.match(p, /^\/api\/v1\/(public|admin|player)\//, `${p} is not a fully-qualified URL`) + assert.doesNotMatch(p, /\/$/, `${p} has a trailing slash — no client calls that URL`) + } +}) + +test('the manifest and the module\'s declared mounts agree', () => { + const routes = JSON.parse(fs.readFileSync(MANIFEST, 'utf8')).routes + const { mounts } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'module.json'), 'utf8')) + + const declared = [] + for (const [tier, prefixes] of Object.entries(mounts)) { + for (const prefix of prefixes) declared.push(`/api/v1/${tier}${prefix}/`) + } + // The extension slot is core's resource, not one of our mounts (§2.4). + const slot = '/api/v1/admin/users/' + + for (const route of routes) { + const under = declared.some((d) => route.path.startsWith(d)) || route.path.startsWith(slot) + assert.ok(under, `${route.method} ${route.path} is served from outside every mount module.json declares`) + } +}) diff --git a/server/test/swaggerFragment.test.js b/server/test/swaggerFragment.test.js new file mode 100644 index 0000000..b9f708b --- /dev/null +++ b/server/test/swaggerFragment.test.js @@ -0,0 +1,92 @@ +// The fragment generator's derivation, checked. +// +// `scripts/swaggerFragment.js` decides the prefix of every path core will publish +// on this module's behalf, and it decides it from `server/index.js`'s own +// registration call rather than from a table. That derivation is what these +// assert. The generator's OUTPUT — whether those prefixes are the URLs a real +// core serves — is `frozenManifest.js`'s question, because answering it needs a +// core; here the question is whether the machinery reads the module correctly. + +const test = require('node:test') +const assert = require('node:assert') + +const { mountedRouters, prefixPaths, TIER_BASE, SLOT_MOUNT } = require('../scripts/swaggerFragment') + +test('every registered mount is discovered, and resolved to a source file', () => { + const mounts = mountedRouters() + + // Six: five `registerRoutes` prefixes plus the filled extension slot. + assert.strictEqual(mounts.length, 6) + for (const { file, prefix } of mounts) { + assert.match(file, /server[\\/]router[\\/]/, 'a mounted router resolved to a file outside router/') + assert.match(prefix, /^\/api\/v1\/(public|admin|player)\//) + } +}) + +test('the prefixes come from the registration, not from a list here', () => { + // Change `registerRoutes` in server/index.js and this list changes with it — + // which is the property being asserted. The manifest declares the same five + // prefixes and the loader rejects a mismatch between the two, so this is the + // third and last place they could disagree. + const byPrefix = mountedRouters().map((m) => m.prefix).sort() + assert.deepStrictEqual(byPrefix, [ + '/api/v1/admin/shard', + '/api/v1/admin/uo-link', + '/api/v1/admin/users/:id', + '/api/v1/player/shard', + '/api/v1/public/atlas', + '/api/v1/public/shard', + ]) +}) + +test('the tier bases and the slot mount are §2.4\'s, spelled as core mounts them', () => { + assert.deepStrictEqual(TIER_BASE, { + public: '/api/v1/public', + admin: '/api/v1/admin', + player: '/api/v1/player', + }) + assert.deepStrictEqual(SLOT_MOUNT, { 'admin.users.detail': '/api/v1/admin/users/:id' }) +}) + +test('re-rooting converts express params to OpenAPI\'s', () => { + const paths = prefixPaths({ paths: { '/shard/link': { get: {} } } }, '/api/v1/admin/users/:id') + assert.deepStrictEqual(Object.keys(paths), ['/api/v1/admin/users/{id}/shard/link']) +}) + +test('re-rooting drops the trailing slash a collection route would produce', () => { + // `router.get('/')` under a prefix concatenates to `/api/v1/public/shard/`, a + // URL no client calls and the manifest does not record. + const paths = prefixPaths({ paths: { '/': { get: {} } } }, '/api/v1/public/shard') + assert.deepStrictEqual(Object.keys(paths), ['/api/v1/public/shard']) +}) + +test('the prefix\'s own parameters are ordered ahead of the route\'s', () => { + // swagger-autogen orders parameters by where they appeared in the path it saw, + // and it only ever saw the tail — so without this every slot route would churn + // the committed fragment by a reorder that means nothing. + const paths = prefixPaths( + { + paths: { + '/shard/link/{account}': { + delete: { parameters: [{ name: 'account', in: 'path' }, { name: 'id', in: 'path' }] }, + }, + }, + }, + '/api/v1/admin/users/:id', + ) + const params = paths['/api/v1/admin/users/{id}/shard/link/{account}'].delete.parameters + assert.deepStrictEqual(params.map((p) => p.name), ['id', 'account']) +}) + +test('a parameter the prefix does not name keeps its position', () => { + const paths = prefixPaths( + { + paths: { + '/thing': { get: { parameters: [{ name: 'limit' }, { name: 'offset' }, { name: 'id' }] } }, + }, + }, + '/api/v1/admin/users/:id', + ) + const params = paths['/api/v1/admin/users/{id}/thing'].get.parameters + assert.deepStrictEqual(params.map((p) => p.name), ['id', 'limit', 'offset']) +}) diff --git a/swagger-fragment.json b/swagger-fragment.json new file mode 100644 index 0000000..8ace786 --- /dev/null +++ b/swagger-fragment.json @@ -0,0 +1,8160 @@ +{ + "paths": { + "/api/v1/admin/shard/account": { + "post": { + "tags": [ + "Admin · Account" + ], + "summary": "Create a game account and link it to the caller (staff self-service)", + "description": "Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shard’s mode; the password is never stored or logged.", + "responses": { + "201": { + "description": "Account created and linked", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Game-account signup unavailable (site or shard)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Account name already taken", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "account", + "password" + ], + "properties": { + "account": { + "type": "string" + }, + "password": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/accounts": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "List the caller’s linked game accounts (self)", + "description": "", + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardLink" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/atlas": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Spawn atlas status: path, drift, counts, pending review (admin only)", + "description": "Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.", + "responses": { + "200": { + "description": "Atlas status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoAtlasStatus" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/atlas/approve": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Approve a staged atlas refresh that removes a facet (admin only)", + "description": "Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.", + "responses": { + "200": { + "description": "What happened", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoAtlasRefreshResult" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/atlas/import": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Re-import the spawn atlas from the ServUO tree (admin only)", + "description": "Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status \"unavailable\" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.", + "responses": { + "200": { + "description": "What happened", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoAtlasRefreshResult" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "force": { + "type": "boolean", + "description": "Reimport even if the tree is unchanged." + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/atlas/path": { + "put": { + "tags": [ + "Admin · Shard" + ], + "summary": "Set the ServUO tree the atlas reads from (admin only)", + "description": "Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.", + "responses": { + "200": { + "description": "Atlas status after the change", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoAtlasStatus" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute path to the ServUO server root. Blank disables the atlas." + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/atlas/reject": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Reject a staged atlas refresh (admin only)", + "description": "Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.", + "responses": { + "200": { + "description": "Rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoAtlasRefreshResult" + } + } + } + }, + "404": { + "description": "Nothing is awaiting review", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/audit": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Recent in-game moderation audit events (admin/moderator)", + "description": "", + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "admin.audit events, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardEvent" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/ban": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Ban an account, timed or indefinite (admin/moderator)", + "description": "", + "responses": { + "200": { + "description": "Banned", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Protected target or write plane disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "serial": { + "type": "string" + }, + "durationSec": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/broadcast": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Broadcast a system message to everyone online (admin/moderator)", + "description": "", + "responses": { + "200": { + "description": "Broadcast", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "hue": { + "type": "integer" + } + }, + "required": [ + "text" + ] + } + } + } + } + } + }, + "/api/v1/admin/shard/char/{serial}": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "Character sheet (self-linked characters; admins: any character)", + "description": "", + "parameters": [ + { + "name": "serial", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Mobile serial, e.g. 0x24C." + } + ], + "responses": { + "200": { + "description": "Character profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Character not on an account linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Service Unavailable" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/clilocs": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Cliloc table status: sources, drift, entry count (admin only)", + "description": "Where the cliloc sources are, whether they can be read, how many entries are loaded, and whether the files on disk have drifted from them. The table is built from a SET of sources — the converted client table plus every operator-maintained overlay under `custom/`, which is how shard-added and shard-edited items get names. `missingSources` lists any source that was loaded before and is now gone; an import refuses that without `approve`. A shard with nothing configured is a supported state — item names simply render as ids.", + "responses": { + "200": { + "description": "Cliloc status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoClilocStatus" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/clilocs/import": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Re-import the cliloc table from its source files (admin only)", + "description": "Applies a client patch, or a change to the shard\\'s own overlay files, without a restart. `force` reimports even when the source hashes match what is loaded. `approve` accepts a refresh in which a previously-loaded source has VANISHED — refused by default, because an unmounted volume and a deliberate deletion are indistinguishable from the server, and the wrong guess silently drops every name that file contributed. A missing path — or the common mistake of pointing at the client\\'s own COMPRESSED Cliloc.enu — answers 200 with status \"unavailable\" and the reason, rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told which file to convert.", + "responses": { + "200": { + "description": "What happened", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoClilocRefreshResult" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "force": { + "type": "boolean", + "description": "Reimport even if the sources are unchanged." + }, + "approve": { + "type": "boolean", + "description": "Accept a refresh in which a previously-loaded source has vanished." + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/clilocs/path": { + "put": { + "tags": [ + "Admin · Shard" + ], + "summary": "Set the cliloc source the site reads from (admin only)", + "description": "Accepts either the converted base file itself or a directory to search. Overlays are read from a `custom/` directory beside it either way — pointing at a file does not forfeit them. Persisted as a setting, which wins over the UO_CLIENT_PATH deploy default so the mount can move without a redeploy. Blank clears it and resolution is skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.", + "responses": { + "200": { + "description": "Cliloc status after the change", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoClilocStatus" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "description": "Path to the converted cliloc file, or a directory containing one. Blank disables resolution." + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/houses": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Full house registry — owner, price, decay (admin/moderator)", + "description": "The complete house registry. The public endpoint shows only IDOC houses with location; this staff view carries owner/price/co-owner/decay detail.", + "responses": { + "200": { + "description": "Houses, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardHouse" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/kick": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Kick every live session of an account (admin/moderator)", + "description": "", + "responses": { + "200": { + "description": "Kicked", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Protected target or write plane disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "serial": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/link": { + "post": { + "tags": [ + "Admin · Account" + ], + "summary": "Link an in-game account with a one-time code (self)", + "description": "", + "responses": { + "200": { + "description": "Linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardLinkResult" + } + } + } + }, + "400": { + "description": "Unknown or expired code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Service Unavailable" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardLinkRequest" + } + } + } + } + } + }, + "/api/v1/admin/shard/pages": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Open help-page (support) queue (admin/moderator)", + "description": "", + "responses": { + "200": { + "description": "Open pages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/pages/{id}/close": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Resolve a help page without a reply (admin/moderator)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Page id (sender serial)." + } + ], + "responses": { + "200": { + "description": "Closed", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/pages/{id}/respond": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Reply to a help page, optionally closing it (admin/moderator)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Page id (sender serial)." + } + ], + "responses": { + "200": { + "description": "Responded", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "404": { + "description": "Unknown page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "close": { + "type": "boolean" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + }, + "/api/v1/admin/shard/roster/{account}": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "Character roster for an account (self; admins: any account)", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Account roster", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Account not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/sales": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "Recent player-vendor sales for the caller’s linked accounts (self)", + "description": "", + "responses": { + "200": { + "description": "Vendor sales", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardVendorSale" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/unban": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Clear an account ban (admin/moderator)", + "description": "", + "responses": { + "200": { + "description": "Unbanned", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + } + }, + "required": [ + "account" + ] + } + } + } + } + } + }, + "/api/v1/admin/shard/vendors/{account}": { + "get": { + "tags": [ + "Admin · Account" + ], + "summary": "Player vendors for an account (self; admins: any account)", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Vendor snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Account not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/visibility": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Get per-feature shard visibility config (admin only)", + "description": "The effective config (compiled defaults merged with stored overrides) plus the vocabulary the admin UI renders from: the audience ladder and the always-locked fields. Defaults reproduce pre-v3 behavior.", + "responses": { + "200": { + "description": "Visibility config", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardVisibilityConfig" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Shard" + ], + "summary": "Update per-feature shard visibility config (admin only)", + "description": "Patch one or more features. Unknown feature names, unknown rungs, and any attempt to configure a locked field (acct / webId — admin-only always) are rejected with 400 rather than silently dropped.", + "responses": { + "200": { + "description": "Updated config", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardVisibilityConfig" + } + } + } + }, + "400": { + "description": "Unknown feature, rung, or a locked field", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardVisibilityUpdate" + } + } + } + } + } + }, + "/api/v1/admin/uo-link/config": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Get uo-link config + live status + ingestion stats (admin only)", + "description": "", + "responses": { + "200": { + "description": "Masked config, health and ingestion stats", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Shard" + ], + "summary": "Save uo-link connection config (admin only)", + "description": "token is write-only — omit/blank it to keep the existing one. Saving (re)starts the WS ingest client.", + "responses": { + "200": { + "description": "Updated config + live status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Validation error, or missing token while enabling", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + }, + "wsUrl": { + "type": "string" + }, + "token": { + "type": "string" + }, + "protocol": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "/api/v1/admin/uo-link/signup-mode": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Get the game-account signup mode (admin only)", + "description": "Whether the site offers game-account creation, and in which direction. The shard\\'s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them.", + "responses": { + "200": { + "description": "The configured mode and the legal values", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mode": { + "type": "string" + }, + "modes": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": [ + "Admin · Shard" + ], + "summary": "Set the game-account signup mode (admin only)", + "description": "", + "responses": { + "200": { + "description": "The saved mode", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mode": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Unknown mode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "mode" + ], + "properties": { + "mode": { + "type": "string", + "enum": [ + "disabled", + "website", + "hybrid", + "game" + ] + } + } + } + } + } + } + } + }, + "/api/v1/admin/uo-link/stream": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Full live shard event stream incl. audit/cheat (SSE, admin only)", + "description": "", + "responses": { + "200": { + "description": "An SSE stream (Content-Type: text/event-stream)." + } + } + } + }, + "/api/v1/admin/uo-link/towncrier": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Publish / replace a town-crier message (admin only)", + "description": "", + "responses": { + "200": { + "description": "Posted", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Rejected (over caps)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Shard unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoTownCrierRequest" + } + } + } + } + } + }, + "/api/v1/admin/uo-link/towncrier/{id}": { + "delete": { + "tags": [ + "Admin · Shard" + ], + "summary": "Remove a town-crier message (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Town-crier message id." + } + ], + "responses": { + "200": { + "description": "Removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "404": { + "description": "Unknown id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Service Unavailable" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/accounts": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s linked game accounts (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardLink" + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/houses": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "Houses owned by a user’s accounts (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Houses (IDOC first)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/link/{account}": { + "delete": { + "tags": [ + "Admin · Users" + ], + "summary": "Unlink a game account from this user (admin only)", + "description": "Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + }, + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Game account to unlink." + } + ], + "responses": { + "200": { + "description": "Unlinked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "unlinked": { + "type": "boolean" + } + } + } + } + } + }, + "403": { + "description": "Protected staff account (refused by shard)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Service Unavailable" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/online": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s characters currently online (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Online characters", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/sales": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "Recent vendor sales on a user’s accounts (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Vendor sales", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardVendorSale" + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/users/{id}/shard/standing": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s shard standing — governorships held and guilds led (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Standing { governorOf, guildsLed }", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/account": { + "post": { + "tags": [ + "Player · Shard" + ], + "summary": "Create a game account (hybrid signup) and link it to the caller", + "description": "Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.", + "responses": { + "201": { + "description": "Account created and linked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "linked": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Validation error or rejected name/password", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "403": { + "description": "Game-account signup unavailable (site or shard)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Account name already taken", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Per-IP account cap reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "account", + "password" + ], + "properties": { + "account": { + "type": "string" + }, + "password": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/player/shard/accounts": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "List the caller’s linked game accounts", + "description": "", + "responses": { + "200": { + "description": "Linked accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardLink" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/char/{serial}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Character sheet — only for a character on the caller’s linked account", + "description": "", + "parameters": [ + { + "name": "serial", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Mobile serial, e.g. 0x24C." + } + ], + "responses": { + "200": { + "description": "Character profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Character not on an account linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/houses": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "The caller’s own houses (home status)", + "description": "Houses owned by the caller’s linked accounts, with decay/IDOC status. Only the caller’s own houses — never anyone else’s.", + "responses": { + "200": { + "description": "The caller’s houses", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardHouse" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/link": { + "post": { + "tags": [ + "Player · Shard" + ], + "summary": "Link an in-game account with a one-time code", + "description": "The player runs [link in game to get a code, then submits it here. The server confirms it with the sidecar and mirrors the link.", + "responses": { + "200": { + "description": "Linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardLinkResult" + } + } + } + }, + "400": { + "description": "Unknown or expired code", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardLinkRequest" + } + } + } + } + } + }, + "/api/v1/player/shard/roster/{account}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Character roster for a linked account", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Account roster", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Account not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/sales": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Recent player-vendor sales for the caller’s linked accounts", + "description": "", + "responses": { + "200": { + "description": "Vendor sales", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardVendorSale" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/player/shard/vendors/{account}": { + "get": { + "tags": [ + "Player · Shard" + ], + "summary": "Player vendors for a linked account", + "description": "", + "parameters": [ + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "A game account linked to the caller." + } + ], + "responses": { + "200": { + "description": "Vendor snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Account not linked to the caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/public/atlas/champions": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "Configured champion altars (the roster, not the live board)", + "description": "Where the altars are and what each one summons — \"there is an Unholy Terror altar in Deceit\". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board (\"it is on level 3 right now\").", + "parameters": [ + { + "name": "facet", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Limit to one facet." + } + ], + "responses": { + "200": { + "description": "Altars, by facet then name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoAtlasChampion" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/atlas/creatures": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "Search the bestiary (paginated)", + "description": "Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\\'s share on it. Static content parsed from the shard\\'s ServUO tree — unaffected by the shard being offline.", + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Substring match on the creature name (max 60 chars)." + }, + { + "name": "facet", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Limit to creatures spawning on this facet. Facet names come from the shard's own files; an unknown one returns an empty page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page size, 1..100 (default 50)." + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Rows to skip (default 0)." + } + ], + "responses": { + "200": { + "description": "A page of creatures plus the unpaginated total", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoAtlasCreaturePage" + } + } + } + }, + "403": { + "description": "The atlas feature is gated above this caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "The atlas feature is disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/atlas/creatures/{slug}": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "One creature: where it spawns, and what spawns with it", + "description": "The answer the atlas exists to give. `places` is the aggregate — \"lizardman → Shrines, Isamu-Jima, Yew\" — resolved by point-in-rect against the shard\\'s own region rectangles, falling back to the nearest landmark, else \"Wilderness\". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Creature slug, e.g. lizardman." + }, + { + "name": "facet", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict places and spawners to one facet." + }, + { + "name": "points", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max spawners to return, 1..1000 (default 200)." + } + ], + "responses": { + "200": { + "description": "The creature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoAtlasCreature" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "No such creature in this atlas (or the feature is disabled)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/atlas/landmarks": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "Points of interest (dungeon levels, town markers)", + "description": "From the shard\\'s Data/Locations files. `group` is the innermost enclosing parent (\"Covetous\"), which is the label worth showing over the individual marker (\"Level 1\").", + "parameters": [ + { + "name": "facet", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Limit to one facet." + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Substring match on the landmark name or its group." + } + ], + "responses": { + "200": { + "description": "Landmarks, by facet then group", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoAtlasLandmark" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/atlas/meta": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "What atlas is loaded: facets, counts, when it was imported", + "description": "Drives the facet filter and the \"parsed from the shard\\'s own files on \" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.", + "responses": { + "200": { + "description": "Atlas metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoAtlasMeta" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/atlas/regions": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "Named regions and their rectangles", + "description": "Flattened out of the shard\\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.", + "parameters": [ + { + "name": "facet", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Limit to one facet." + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Substring match on the region name." + } + ], + "responses": { + "200": { + "description": "Regions, by facet then name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoAtlasRegion" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/champs": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Current champion-spawn board (all categories)", + "description": "The live board of every champion / mini-champ / sea-boss spawn. Update in place via the champ.update / champ.remove frames on /shard/stream.", + "responses": { + "200": { + "description": "Champion spawns, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/economy": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Gold-supply time series (oldest → newest)", + "description": "", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max samples (default 100, max 1000)." + } + ], + "responses": { + "200": { + "description": "Economy samples", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardEconomyPoint" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/features": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Shard features visible to the caller (drives client nav)", + "description": "The caller\\'s audience rung plus the shard features they may reach, so a client can hide nav entries instead of rendering links that 403. Reports only what the caller can see — the list itself does not disclose gated features.", + "responses": { + "200": { + "description": "Visible features", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardFeatures" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/feed": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Recent notable shard events (from the ingested log)", + "description": "The stored-history twin of /shard/stream, and it reaches the same verdict: which kinds are returned is resolved against the caller\\'s audience rung under the live visibility config, and each event\\'s payload is field-projected against its own kind\\'s feature. Kinds the caller may not read are omitted (an explicit ?kind= for one of them returns []), and acct/webId never appear below admin.", + "parameters": [ + { + "name": "kind", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter to a single event kind, e.g. vendor.sale. Returns [] if the caller may not read that kind." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max rows (default 100, max 1000)." + } + ], + "responses": { + "200": { + "description": "Events, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardEvent" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/governors": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Current town-governor board (City Loyalty)", + "description": "One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.", + "responses": { + "200": { + "description": "Cities, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/governors/{city}/history": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Governor term history for a city", + "description": "", + "parameters": [ + { + "name": "city", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "City name, e.g. Britain." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max terms (default 100, max 500)." + } + ], + "responses": { + "200": { + "description": "Terms, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/guilds": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Current guild board (rosters, alliances, leaders)", + "description": "The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.", + "responses": { + "200": { + "description": "Guilds, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/houses": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "House registry (owner, co-owners, price, decay)", + "description": "Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.", + "responses": { + "200": { + "description": "Houses, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardHouse" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/idoc": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Houses currently in danger (IDOC)", + "description": "Location-level board of the houses about to collapse. Owner identity and price are gated by the `houses` feature\\'s field rules (default `staff`), and the owner\\'s game account is admin-only always — so an anonymous caller sees name, region and coordinates only.", + "responses": { + "200": { + "description": "IDOC houses", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardHouse" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/market": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Search the player-vendor marketplace", + "description": "Every priced listing on every player vendor the shard publishes — the same index the in-game Vendor Search gump reads, and it honours the same per-vendor opt-out, so a player who hid their shop in game is hidden here too. Results are LISTINGS, each carrying enough of its shop to be actionable. Served from the site\\'s own tables (the sidecar is not touched), so it renders while the shard is down; `staleAt` is the oldest vendor row and the page must say how far behind the index can be — the shard sweeps vendors round-robin, so prices are inherently up to one full cycle old. Item names are resolved server-side against the cliloc table (docs/website/CLILOCS.md); on a shard that has not configured one, `displayName` is null and clients render the item id.", + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Substring match on the resolved item name or the item's own literal name (max 60 chars)." + }, + { + "name": "minPrice", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Lowest price to include." + }, + { + "name": "maxPrice", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Highest price to include." + }, + { + "name": "itemId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Exact ItemID (art id) match — the more-like-this filter." + }, + { + "name": "map", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Limit to one facet. Facet names come from the shard's own data; an unknown one returns an empty page." + }, + { + "name": "region", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Limit to one named region." + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "price_asc", + "price_desc", + "recent" + ], + "items": { + "type": "string" + } + } + } + }, + "description": "Default price_asc. recent orders by when the shop was last seen." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page size, 1..100 (default 50)." + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Rows to skip (default 0)." + } + ], + "responses": { + "200": { + "description": "A page of listings plus the unpaginated total and the staleness stamp", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardMarketPage" + } + } + } + }, + "403": { + "description": "The market feature is gated above this caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "The market feature is disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Rate limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/market/meta": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Marketplace size, staleness and filter options", + "description": "How many vendors and listings the index holds, how stale it may be (`staleAt` = the oldest vendor row, `freshAt` = the newest), and which facets and regions actually hold vendors — so a client can build its filters without running a search it will discard.", + "responses": { + "200": { + "description": "Marketplace metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardMarketMeta" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/market/vendors/{serial}": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "One player vendor and everything it is selling", + "description": "A single shop by its vendor serial, with its listings. `truncated` (and `total` exceeding `count`) means the shop holds more than the shard publishes per frame — a commodity reseller with thousands of stacks is a real thing, and the page says so rather than presenting a partial shop as complete. Returns 404 for a serial the index has never seen, which also covers a vendor since dismissed or hidden.", + "parameters": [ + { + "name": "serial", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Vendor serial, e.g. 0x40001234" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Listings to return, 1..500 (default 250)." + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Listings to skip (default 0)." + } + ], + "responses": { + "200": { + "description": "The vendor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardMarketVendor" + } + } + } + }, + "400": { + "description": "Malformed vendor serial" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "No such vendor in the index" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/online": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Staff online now (linked staff accounts; location is admin/moderator-only)", + "description": "", + "responses": { + "200": { + "description": "Online players", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardOnlinePlayer" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/points": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Points / loyalty leaderboards, one board per point system", + "description": "Every points/loyalty leaderboard the shard publishes (Queen\\'s Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia, …), each with its display name, max points, participant count and top N. Served from our own store, so it renders while the shard is down; live via points.board on /shard/stream. A board\\'s display name may arrive as a literal (`nameString`) or a cliloc id (`nameNumber`) — resolve clilocs client-side.", + "responses": { + "200": { + "description": "Boards, ordered by display name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UoShardPointsBoard" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/points/{system}": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "One points system\\'s leaderboard", + "description": "A single board by the shard\\'s own PointsType name (e.g. `QueensLoyalty`, `CleanUpBritannia`). Returns 404 when the shard has never published that system — distinct from a published board that nobody has scored in yet, which returns 200 with an empty `top`.", + "parameters": [ + { + "name": "system", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "PointsType name, e.g. QueensLoyalty" + } + ], + "responses": { + "200": { + "description": "The board", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardPointsBoard" + } + } + } + }, + "400": { + "description": "Malformed system name" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "The shard has never published that system" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/presence": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Online population aggregate (count + per-facet + per-region)", + "description": "The latest presence.online snapshot powering the \"Players Online\" widget. Live via presence.online on /shard/stream.", + "responses": { + "200": { + "description": "Population snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/ruleset": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "The shard\\'s published ruleset (expansion, systems, caps, limits)", + "description": "How this shard is actually configured, published by the shard itself as one world.ruleset frame: expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules and the save/restart schedule. Served from our own store, so it renders while the shard is down; live via world.ruleset on /shard/stream. Returns `null` if the shard has never published one (an older plugin, or Bridge.RulesetEnabled=false) — distinct from a published ruleset, and the page renders it differently.", + "responses": { + "200": { + "description": "The ruleset, or null if never published", + "content": { + "application/json": { + "schema": { + "type": "object", + "nullable": true, + "additionalProperties": true + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/status": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Shard connection state, online count and latest economy", + "description": "", + "responses": { + "200": { + "description": "Shard status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UoShardStatus" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/stream": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Live shard event stream (Server-Sent Events, filtered by audience)", + "description": "text/event-stream of live events. The caller\\'s audience rung is resolved once at subscribe time and frozen for the connection; each frame is then gated on its feature and field-projected, so sensitive kinds and fields (staff audit, cheat detection, login attempts, IPs, acct/webId) never reach a caller below their configured rung.", + "responses": { + "200": { + "description": "An SSE stream (Content-Type: text/event-stream)." + } + } + } + } + }, + "tags": [ + { + "name": "Public · Shard", + "description": "Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)" + }, + { + "name": "Public · Atlas", + "description": "Spawn atlas / bestiary — static shard content parsed from the shard's own ServUO tree, independent of the sidecar" + }, + { + "name": "Player · Shard", + "description": "Link an in-game account and read its roster / vendors (uo-link)" + }, + { + "name": "Admin · Shard", + "description": "uo-link sidecar connection config, live status and town crier (admin only)" + } + ], + "components": { + "schemas": { + "UoShardStatus": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Public shard status (GET /public/shard/status)." + }, + "properties": { + "type": "object", + "properties": { + "enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "connected" + }, + "description": { + "type": "string", + "example": "connected | reconnecting | disconnected | error" + } + } + }, + "pluginConnected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Is the shard link up right now?" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "lastEventAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "onlineCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 12 + } + } + }, + "economy": { + "$ref": "#/components/schemas/UoShardEconomyPoint" + } + } + } + } + }, + "UoShardEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A logged shard event." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 4821 + } + } + }, + "kind": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "vendor.sale" + } + } + }, + "t": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Event time, epoch ms." + }, + "example": { + "type": "number", + "example": 1783720195626 + } + } + }, + "bootId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "boot-abc123" + } + } + }, + "payload": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The full event object." + } + } + }, + "createdAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "UoShardEconomyPoint": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "One gold-supply sample." + }, + "properties": { + "type": "object", + "properties": { + "accounts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 240 + } + } + }, + "gold": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1028983421 + } + } + }, + "t": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Sample time, epoch ms." + }, + "example": { + "type": "number", + "example": 1783720000000 + } + } + } + } + } + } + }, + "UoShardOnlinePlayer": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A LINKED player online now (only accounts linked to a website user are listed)." + }, + "properties": { + "type": "object", + "properties": { + "serial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0x24C" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Darrow" + } + } + }, + "map": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Trammel" + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1402 + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1604 + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 0 + } + } + } + } + } + } + }, + "UoShardVendorSale": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A player-vendor sale (visible only to the linked owner)." + }, + "properties": { + "type": "object", + "properties": { + "t": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Sale time, epoch ms." + }, + "example": { + "type": "number", + "example": 1783720195626 + } + } + }, + "itemType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Longsword" + } + } + }, + "amount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1 + } + } + }, + "price": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 100 + } + } + }, + "commission": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 5 + } + } + }, + "ownerAcct": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "whitlocktech" + } + } + } + } + } + } + }, + "UoShardHouse": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A house at its current decay stage." + }, + "properties": { + "type": "object", + "properties": { + "serial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0x4004705F" + } + } + }, + "stage": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "IDOC" + } + } + }, + "map": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Trammel" + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "region": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "An Unnamed House" + } + } + }, + "ownerSerial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "ownerAcct": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "builtOn": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "lastRefreshed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "isIdoc": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "updatedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "UoShardPointsBoard": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One point system's leaderboard (Protocol 3.0 points.board). The shard carries ~25 separate point currencies; each publishes its own board. The display name may arrive as a literal string, a cliloc id, or both — resolve clilocs client-side." + }, + "properties": { + "type": "object", + "properties": { + "system": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "QueensLoyalty" + }, + "description": { + "type": "string", + "example": "The shard's PointsType name; the board's stable key." + } + } + }, + "nameString": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Queen's Loyalty" + } + } + }, + "nameNumber": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1114938 + }, + "description": { + "type": "string", + "example": "Cliloc id, 0 when the name is a literal." + } + } + }, + "maxPoints": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 30000 + } + } + }, + "players": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 842 + }, + "description": { + "type": "string", + "example": "Players actually holding points in this system." + } + } + }, + "showOnGump": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The shard's own 'is this player-facing?' flag." + } + } + }, + "top": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The ranked players, best first. Capped by the shard (10 by default). Empty when nobody has scored yet." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "rank": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1 + } + } + }, + "serial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0x1A2B" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Darrow" + }, + "description": { + "type": "string", + "example": "Omitted when the leaderboards `name` field is gated above the caller." + } + } + }, + "points": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 29500 + } + } + } + } + } + } + } + } + }, + "t": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Frame time, epoch ms." + } + } + }, + "updatedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "UoShardMarketLocation": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Where a vendor is standing. ONE nested object rather than flat map/x/y/region because it is one admin-configurable field (`market.location`) — the whole object is omitted when that field is gated above the caller." + }, + "properties": { + "type": "object", + "properties": { + "map": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Trammel" + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1421 + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1699 + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 0 + } + } + }, + "region": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Britain" + } + } + }, + "house": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Darrow's Villa" + }, + "description": { + "type": "string", + "example": "The house SIGN's name, not the house type. Null for a vendor standing outside one." + } + } + } + } + } + } + }, + "UoShardMarketListing": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One priced listing on a player vendor, carrying enough of its shop to be actionable without a second request." + }, + "properties": { + "type": "object", + "properties": { + "serial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0x40012ABC" + } + } + }, + "itemId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3922 + }, + "description": { + "type": "string", + "example": "ItemID (the art/graphic id)." + } + } + }, + "hue": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + }, + "amount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1 + } + } + }, + "price": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 25000 + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The item's own literal name, set by a player. Null for most items." + } + } + }, + "cliloc": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 1023721 + }, + "description": { + "type": "string", + "example": "The item's LabelNumber." + } + } + }, + "displayName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "quarter staff" + }, + "description": { + "type": "string", + "example": "Resolved server-side from `name` (preferred, being player-set and more specific) else `cliloc`. Null on a shard with no cliloc table configured — render the item id." + } + } + }, + "child": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + }, + "description": { + "type": "string", + "example": "Priced by an enclosing container rather than itself, exactly as the in-game Vendor Search reports it." + } + } + }, + "vendor": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "serial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0x40001234" + } + } + }, + "shopName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Darrow's Bargains" + } + } + }, + "ownerSerial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "0x1A2B" + }, + "description": { + "type": "string", + "example": "Omitted when the market `ownerSerial` field is gated above the caller." + } + } + }, + "ownerName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Darrow" + }, + "description": { + "type": "string", + "example": "Omitted when the market `ownerName` field is gated above the caller." + } + } + }, + "location": { + "$ref": "#/components/schemas/UoShardMarketLocation" + }, + "updatedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "description": { + "type": "string", + "example": "When the shard last published this shop." + } + } + } + } + } + } + } + } + } + } + }, + "UoShardMarketPage": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A page of marketplace listings plus the unpaginated total and the staleness stamp." + }, + "properties": { + "type": "object", + "properties": { + "listings": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/UoShardMarketListing" + } + } + }, + "total": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1284 + }, + "description": { + "type": "string", + "example": "Matching listings, ignoring paging." + } + } + }, + "limit": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 50 + } + } + }, + "offset": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + }, + "vendors": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 137 + }, + "description": { + "type": "string", + "example": "Vendors in the whole index." + } + } + }, + "staleAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The OLDEST vendor row. The shard sweeps vendors round-robin, so the index can be a full cycle behind and a client must say so rather than implying live prices." + } + } + } + } + } + } + }, + "UoShardMarketVendor": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One player vendor and its listings." + }, + "properties": { + "type": "object", + "properties": { + "serial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "0x40001234" + } + } + }, + "shopName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Darrow's Bargains" + } + } + }, + "ownerSerial": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "ownerName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Darrow" + } + } + }, + "location": { + "$ref": "#/components/schemas/UoShardMarketLocation" + }, + "count": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 250 + }, + "description": { + "type": "string", + "example": "Listings the shard published for this shop." + } + } + }, + "total": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 3104 + }, + "description": { + "type": "string", + "example": "Listings the shop actually holds." + } + } + }, + "truncated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "`total` exceeds `count` — the shop holds more than the shard publishes per frame." + } + } + }, + "updatedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/UoShardMarketListing" + } + } + } + } + } + } + }, + "UoShardMarketMeta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Marketplace size, staleness and the filter options a client needs to build its UI." + }, + "properties": { + "type": "object", + "properties": { + "vendors": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 137 + } + } + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 18422 + } + } + }, + "staleAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "freshAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "maps": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "Felucca", + "Trammel" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "example": "Facets that actually hold vendors. From the shard's own data — never a hardcoded list." + } + } + }, + "regions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "Britain", + "Luna" + ], + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "UoShardFeatures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The shard features the caller may reach, plus the audience rung they resolved to. Drives client nav so it never renders a link that would 403." + }, + "properties": { + "type": "object", + "properties": { + "level": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "anonymous", + "logged_in", + "player", + "staff", + "admin" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "anonymous" + } + } + }, + "features": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "status", + "activity", + "champs", + "guilds", + "governors", + "houses", + "presence" + ], + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "UoShardFeatureVisibility": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Visibility settings for one shard feature." + }, + "properties": { + "type": "object", + "properties": { + "enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "audience": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "anonymous", + "logged_in", + "player", + "staff", + "admin" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "example": "Minimum rung that may reach this feature. Each rung implies the ones below it." + }, + "example": { + "type": "string", + "example": "anonymous" + } + } + }, + "stream": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "Whether this feature's event kinds fan out over SSE at all." + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "fieldRules": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "description": { + "type": "string", + "example": "Per-field rung overrides for the sensitive fields this feature exposes. acct / webId are admin-only always and are rejected here." + }, + "example": { + "type": "object", + "properties": { + "location": { + "type": "string", + "example": "staff" + } + } + } + } + } + } + } + } + }, + "UoShardVisibilityConfig": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "ladder": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "anonymous", + "logged_in", + "player", + "staff", + "admin" + ], + "items": { + "type": "string" + } + } + } + }, + "lockedFields": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "acct", + "webId" + ], + "items": { + "type": "string" + } + } + } + }, + "defaults": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "$ref": "#/components/schemas/UoShardFeatureVisibility" + } + } + }, + "features": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "$ref": "#/components/schemas/UoShardFeatureVisibility" + } + } + } + } + } + } + }, + "UoShardVisibilityUpdate": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "features" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "features": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "$ref": "#/components/schemas/UoShardFeatureVisibility" + }, + "example": { + "type": "object", + "properties": { + "market": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "example": true + }, + "audience": { + "type": "string", + "example": "player" + }, + "stream": { + "type": "boolean", + "example": false + }, + "fieldRules": { + "type": "object", + "properties": { + "ownerName": { + "type": "string", + "example": "player" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "UoAtlasCreature": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A creature in the bestiary. `places`/`points`/`alsoHere` are present only on the single-creature route." + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "lizardman" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Lizardman" + } + } + }, + "total": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "How many can be alive at once, summed across every spawner." + }, + "example": { + "type": "number", + "example": 214 + } + } + }, + "points": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "How many spawners mention this creature." + }, + "example": { + "type": "number", + "example": 62 + } + } + }, + "facets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "description": { + "type": "string", + "example": "This creature's share per facet." + }, + "example": { + "type": "object", + "properties": { + "Felucca": { + "type": "number", + "example": 96 + }, + "Trammel": { + "type": "number", + "example": 88 + }, + "Tokuno": { + "type": "number", + "example": 30 + } + } + } + } + }, + "art": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Operator-supplied art under uploads/atlas/. NULL on a fresh import — the repo ships no creature art." + } + } + }, + "places": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Where it spawns, aggregated by resolved place. The answer the atlas exists to give." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "facet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Trammel" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Resolved region, else nearest landmark group, else \"Wilderness\"." + }, + "example": { + "type": "string", + "example": "Shrines" + } + } + }, + "spawners": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 7 + } + } + }, + "maxAlive": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 21 + } + } + } + } + } + } + } + } + }, + "spawners": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The individual spawners. Named separately from `points` (the count) so one key never means two things." + }, + "items": { + "$ref": "#/components/schemas/UoAtlasSpawner" + } + } + }, + "spawnersTruncated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "True when the spawner list was cut at the requested bound." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "alsoHere": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Creatures sharing a spawner with this one." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "lizardman-warrior" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Lizardman Warrior" + } + } + }, + "shared": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 12 + } + } + } + } + } + } + } + } + } + } + } + } + }, + "UoAtlasSpawner": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One ServUO spawner, with the place its coordinates resolved to." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "facet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Felucca" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The spawner's own name in the ServUO file." + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 5411 + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1234 + } + } + }, + "width": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "height": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "range": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Spawn radius." + } + } + }, + "maxCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "How many of THIS creature this spawner keeps alive." + }, + "example": { + "type": "number", + "example": 3 + } + } + }, + "minDelay": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Respawn window, in SECONDS. Normalised at parse time — the source stores minutes or seconds per record, decided by its own DelayInSec flag." + }, + "example": { + "type": "number", + "example": 300 + } + } + }, + "maxDelay": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 600 + } + } + }, + "todStart": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Meaningless unless todMode is non-zero." + } + } + }, + "todEnd": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "todMode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "region": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Despise" + } + } + }, + "landmark": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Covetous" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Region, else landmark group, else \"Wilderness\"." + }, + "example": { + "type": "string", + "example": "Despise" + } + } + } + } + } + } + }, + "UoAtlasCreaturePage": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "total": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Matching creatures before pagination." + }, + "example": { + "type": "number", + "example": 800 + } + } + }, + "limit": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 50 + } + } + }, + "offset": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + }, + "creatures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/UoAtlasCreature" + } + } + } + } + } + } + }, + "UoAtlasRegion": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A named region, flattened out of the shard's nested Regions.xml." + }, + "properties": { + "type": "object", + "properties": { + "facet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Felucca" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Despise" + } + } + }, + "type": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "ServUO region class." + }, + "example": { + "type": "string", + "example": "DungeonRegion" + } + } + }, + "priority": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 50 + } + } + }, + "parent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Britain" + } + } + }, + "rects": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The rectangles that placed each spawn point." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + }, + "UoAtlasLandmark": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "facet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Trammel" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Level 1" + } + } + }, + "group": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Innermost enclosing parent — the label worth showing." + }, + "example": { + "type": "string", + "example": "Covetous" + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 5411 + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1234 + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + } + } + } + } + }, + "UoAtlasChampion": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A CONFIGURED champion altar. Not the live board — see GET /public/shard/champs for that." + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "felucca-deceit" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Deceit" + } + } + }, + "group": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Spawn group; one altar active per group." + }, + "example": { + "type": "string", + "example": "Dungeons" + } + } + }, + "type": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "NULL when the champion is drawn at activation." + }, + "example": { + "type": "string", + "example": "UnholyTerror" + } + } + }, + "randomType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "facet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Felucca" + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "radius": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 60 + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Deceit" + } + } + } + } + } + } + }, + "UoAtlasMeta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "What atlas is loaded. Game-world facts only: the ServUO path, source hashes and any pending refresh are operator detail and live on the admin status route." + }, + "properties": { + "type": "object", + "properties": { + "importedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "generatedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "counts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + }, + "example": { + "type": "object", + "properties": { + "facets": { + "type": "number", + "example": 6 + }, + "points": { + "type": "number", + "example": 6455 + }, + "creatures": { + "type": "number", + "example": 800 + }, + "regions": { + "type": "number", + "example": 387 + }, + "landmarks": { + "type": "number", + "example": 558 + }, + "champions": { + "type": "number", + "example": 25 + }, + "unresolvedPoints": { + "type": "number", + "example": 1086 + } + } + } + } + }, + "facets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "Felucca", + "Ilshenar", + "Malas", + "TerMur", + "Tokuno", + "Trammel" + ], + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "UoAtlasStatus": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review." + }, + "properties": { + "type": "object", + "properties": { + "configured": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "path": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "/srv/servuo" + } + } + }, + "treeReadable": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "drift": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "True when the tree's source hashes differ from the loaded atlas. NULL when the tree could not be read." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "facets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + }, + "importedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "counts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + }, + "pending": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "A refresh that was parsed but NOT applied because it would remove a facet. `status` is pending or rejected." + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "UoAtlasRefreshResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Outcome of a refresh. Reported rather than thrown, so an unreadable tree is an answer and not a 500." + }, + "properties": { + "type": "object", + "properties": { + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "skipped", + "unavailable", + "unchanged", + "imported", + "needsReview", + "failed", + "rejected", + "none" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "imported" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "path": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "counts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + }, + "addedFacets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + }, + "removedFacets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + }, + "UoClilocStatus": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Admin view of cliloc state: where the converted file is, whether it is readable, how many entries are loaded, and whether the file has drifted from them. `configured: false` is a supported state — item names then render as ids." + }, + "properties": { + "type": "object", + "properties": { + "configured": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "path": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "/srv/uo-client" + } + } + }, + "file": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The file actually resolved, when the path is a directory." + }, + "example": { + "type": "string", + "example": "/srv/uo-client/clilocs.tsv" + } + } + }, + "fileReadable": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "problem": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Why the file cannot be used, when it cannot. Set (with code COMPRESSED) for a readable-but-unconverted client file." + }, + "example": {} + } + }, + "code": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Machine-readable cause of `problem`." + }, + "enum": { + "type": "array", + "example": [ + "NO_PATH", + "NOT_FOUND", + "NO_FILE", + "UNREADABLE", + "COMPRESSED" + ], + "items": { + "type": "string" + } + } + } + }, + "drift": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "True when any source hash differs from the loaded table. NULL when the sources could not be read or are not usable." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "count": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Entries currently loaded." + }, + "example": { + "type": "number", + "example": 67496 + } + } + }, + "sources": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "description": { + "type": "string", + "example": "Every source found now, root-relative, base first then overlays in merge order." + }, + "example": { + "type": "array", + "example": [ + "clilocs.plain", + "custom/uomysticmoon.tsv" + ], + "items": { + "type": "string" + } + } + } + }, + "loadedSources": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "What each source contributed at the last import." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "custom/uomysticmoon.tsv" + } + } + }, + "kind": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "base", + "custom" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "custom" + } + } + }, + "entries": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 37 + } + } + }, + "added": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Ids this source introduced." + }, + "example": { + "type": "number", + "example": 25 + } + } + }, + "overrode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Ids it replaced from an earlier source." + }, + "example": { + "type": "number", + "example": 12 + } + } + } + } + } + } + } + } + }, + "missingSources": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "description": { + "type": "string", + "example": "Sources loaded previously and now absent. An import refuses these without `approve`." + }, + "example": { + "type": "array", + "example": [], + "items": {} + } + } + }, + "importedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "sourceBytes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 4973525 + } + } + } + } + } + } + }, + "UoClilocRefreshResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Outcome of a cliloc refresh. Reported rather than thrown, so a missing or compressed file is an answer and not a 500." + }, + "properties": { + "type": "object", + "properties": { + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "skipped", + "unavailable", + "unchanged", + "imported", + "needsReview", + "failed" + ], + "items": { + "type": "string" + } + }, + "description": { + "type": "string", + "example": "`needsReview` means a previously-loaded source has vanished and nothing was applied; re-run with `approve` to accept it." + }, + "example": { + "type": "string", + "example": "imported" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "code": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Machine-readable cause. `COMPRESSED` means the client's own Cliloc.enu was supplied instead of a converted one." + }, + "enum": { + "type": "array", + "example": [ + "NO_PATH", + "NOT_FOUND", + "NO_FILE", + "UNREADABLE", + "COMPRESSED", + "TRUNCATED", + "EMPTY", + "NOT_BUFFER" + ], + "items": { + "type": "string" + } + } + } + }, + "path": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "file": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "count": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Entries stored (blank strings are dropped)." + }, + "example": { + "type": "number", + "example": 67496 + } + } + }, + "parsed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Entries read across every source before blanks were dropped." + }, + "example": { + "type": "number", + "example": 123527 + } + } + }, + "blank": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 55994 + } + } + }, + "sources": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Per-source breakdown: what each file contributed and how much of it overrode an earlier source." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "kind": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "base", + "custom" + ], + "items": { + "type": "string" + } + } + } + }, + "entries": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "added": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "overrode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + } + } + } + } + } + } + }, + "missingSources": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "description": { + "type": "string", + "example": "On `needsReview`: the sources that vanished. Nothing was applied." + } + } + }, + "acceptedMissing": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "description": { + "type": "string", + "example": "On `imported` with `approve`: the vanished sources the admin accepted." + } + } + } + } + } + } + }, + "UoShardLinkRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "code" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "code": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "The one-time code shown by [link in game." + }, + "example": { + "type": "string", + "example": "AB12CD" + } + } + } + } + } + } + }, + "UoShardLinkResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "linked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "account": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "whitlocktech" + } + } + } + } + } + } + }, + "UoShardLink": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A linked in-game account (GET /player/shard/accounts)." + }, + "properties": { + "type": "object", + "properties": { + "account": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "whitlocktech" + } + } + }, + "userId": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 42 + } + } + }, + "charName": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Darrow" + } + } + }, + "linkedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + } + } + } + } + } + } + }, + "UoTownCrierRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "required": { + "type": "array", + "example": [ + "id", + "lines" + ], + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 64 + }, + "description": { + "type": "string", + "example": "Re-posting the same id replaces the prior entry." + }, + "example": { + "type": "string", + "example": "news-42" + } + } + }, + "lines": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "maxLength": { + "type": "number", + "example": 200 + } + } + }, + "example": { + "type": "array", + "example": [ + "Hear ye!", + "Market tax is now 5%." + ], + "items": { + "type": "string" + } + } + } + }, + "durationSec": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "minimum": { + "type": "number", + "example": 1 + }, + "maximum": { + "type": "number", + "example": 86400 + }, + "example": { + "type": "number", + "example": 3600 + } + } + } + } + } + } + } + } + } +}