From 4426d3e3398f42af763f5ed29c233b3c6863add4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 22 Oct 2026 08:54:37 -0500 Subject: [PATCH] feat(docs): phase 14 - game-neutral core docs, an Ultima Online group, and checkTerms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md §16, D63/D64, plus D67-D69 taken 2026-10-22. - Core docs use core's words: a Team, a game server, the sidecar, the bridge plugin, the game module. Detail that belonged to Ultima Online moved into its pages rather than being dropped. - New "Ultima Online" sidebar group (nine pages): an overview, Connect a ServUO shard, Verify the bridge, The shard connection, Client files, Troubleshooting, Bridge.cfg, sidecar.toml, Shard event catalog. Five moved URLs answer a real 301 (src/config/redirects.mjs, asserted by the served test). - Getting started is game-neutral. "Connect your game server" keeps the old URL and hands off to each game; Rust links docs main:rust-link/INSTALL.md until phase 15. "Verify the whole stack" stays, neutral (D69). - D68: GameExample.astro labels a game's real example on a core page with the game's name; checkTerms allows that game's words inside it. - scripts/checkTerms.mjs, the twelfth check: 16 game words, docs pages and both sidebar trees, one allowlisted phrase (Discord's "Guild (server) ID"). Wired into verify and pr-checks.yml. - canonicalDocs grouped by game; the Rust group carries four documents. - D67: the screenshot rule from #32 is adopted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/pr-checks.yml | 10 + CONTRIBUTING.md | 2 +- PLAN.md | 78 ++++- README.md | 5 +- astro.config.mjs | 5 + package.json | 3 +- scripts/checkReference.mjs | 2 +- scripts/checkTerms.mjs | 271 ++++++++++++++++++ src/components/GameExample.astro | 37 +++ src/config/redirects.mjs | 22 ++ src/config/sidebar.mjs | 46 ++- .../docs/administration/configuration.mdx | 2 +- .../docs/docs/administration/content.mdx | 2 +- .../docs/administration/engagement-rules.mdx | 4 +- .../docs/docs/administration/events.mdx | 35 ++- .../maintenance-and-upgrades.mdx | 24 +- .../docs/administration/message-templates.mdx | 4 +- .../docs/docs/administration/moderation.mdx | 2 +- .../notifications-and-email.mdx | 4 +- .../docs/docs/administration/teams.mdx | 27 +- .../docs/administration/troubleshooting.mdx | 54 ++-- .../authentication-architecture.mdx | 4 +- .../docs/architecture/events-architecture.mdx | 7 +- .../docs/architecture/protocol-versions.mdx | 99 ++++--- .../docs/architecture/system-architecture.mdx | 56 ++-- .../docs/architecture/teams-architecture.mdx | 10 +- .../docs/docs/architecture/the-bridge.mdx | 70 ++--- .../getting-started/connect-a-game-server.mdx | 141 ++------- .../getting-started/install-a-game-module.mdx | 49 ++-- .../docs/getting-started/install-the-site.mdx | 2 +- .../docs/getting-started/requirements.mdx | 45 ++- .../verify-the-whole-stack.mdx | 122 +++----- src/content/docs/docs/index.mdx | 15 +- .../docs/docs/modules/the-integration-kit.mdx | 6 +- .../docs/docs/modules/the-module-manifest.mdx | 20 +- .../docs/docs/modules/the-module-system.mdx | 22 +- .../docs/reference/canonical-documents.mdx | 53 +++- .../docs/reference/environment-variables.mdx | 4 +- src/content/docs/docs/reference/http-api.mdx | 5 +- .../docs/docs/reference/installer-cli.mdx | 69 +++-- .../bridge-cfg.mdx | 2 +- .../client-files.mdx | 14 +- .../ultima-online/connect-a-servuo-shard.mdx | 168 +++++++++++ .../event-catalog.mdx | 14 +- src/content/docs/docs/ultima-online/index.mdx | 61 ++++ .../sidecar-toml.mdx | 0 .../the-shard-connection.mdx | 4 +- .../docs/ultima-online/troubleshooting.mdx | 60 ++++ .../docs/ultima-online/verify-the-bridge.mdx | 110 +++++++ src/data/reference.mjs | 59 ++-- src/styles/starlight.css | 25 ++ test/headers.test.mjs | 17 ++ 52 files changed, 1415 insertions(+), 557 deletions(-) create mode 100644 scripts/checkTerms.mjs create mode 100644 src/components/GameExample.astro create mode 100644 src/config/redirects.mjs rename src/content/docs/docs/{reference => ultima-online}/bridge-cfg.mdx (95%) rename src/content/docs/docs/{administration => ultima-online}/client-files.mdx (87%) create mode 100644 src/content/docs/docs/ultima-online/connect-a-servuo-shard.mdx rename src/content/docs/docs/{reference => ultima-online}/event-catalog.mdx (85%) create mode 100644 src/content/docs/docs/ultima-online/index.mdx rename src/content/docs/docs/{reference => ultima-online}/sidecar-toml.mdx (100%) rename src/content/docs/docs/{administration => ultima-online}/the-shard-connection.mdx (97%) create mode 100644 src/content/docs/docs/ultima-online/troubleshooting.mdx create mode 100644 src/content/docs/docs/ultima-online/verify-the-bridge.mdx diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index ec57fa7..2d431c9 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -78,6 +78,16 @@ jobs: # No token, no network, no build — so it runs early and fails fast. run: npm run check:sidebar + - name: Game vocabulary + # PLAN.md §16, D63/D64 — the twelfth check, phase 14. The site may NAME the games it + # supports anywhere, but a game's vocabulary (shard, guild, clan, uo-link, Oxide…) + # belongs in that game's own docs group, its sidebar group, or a block + # that labels it (D68). Core pages use core's words: a Team, a game server, the + # sidecar, the bridge plugin, the game module. + # + # No token, no network, no build — so it runs early and fails fast. + run: npm run check:terms + - name: Screenshots # PLAN.md §12, phase 9 (D45). src/data/screens.mjs is the one list of what the site # shows of itself: every entry must have a file, at the size the markup declares, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4fb1f09..525853e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,7 @@ absent `brand/` produces exactly the stock site, and `data/` is created on first ## The checks -There are eleven, plus two test suites. `npm run verify` runs all of them in dependency order, and +There are twelve, plus two test suites. `npm run verify` runs all of them in dependency order, and that is what CI does on every pull request. ```bash diff --git a/PLAN.md b/PLAN.md index 18eaa9a..d051438 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,6 +1,6 @@ # runicgateway.com — design of record -**Status:** phases 0–12 are built and deployed. **Revision 4 (2026-10-22) plans phases 13–18** — +**Status:** phases 0–14 are built (13 and 14 as part of revision 4). **Revision 4 (2026-10-22) plans phases 13–18** — the second game, RunicNPC, and taking game vocabulary out of the site at large — in §16. **Date:** 2026-08-19 (revision 3) · 2026-10-22 (revision 4) **Repo:** `RunicGateway/runicgateway.com` @@ -237,7 +237,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r **Decisions after D13 are recorded where they were taken**, in the section describing the phase that raised them, rather than appended here — a decision is only re-litigated when its reasoning is -somewhere other than the thing it decided. The count of record is **sixty-six**: +somewhere other than the thing it decided. The count of record is **sixty-nine**: | # | Where | What it settled | |---|---|---| @@ -256,6 +256,8 @@ somewhere other than the thing it decided. The count of record is **sixty-six**: | D59 | §6, "How phase 12 delivered it" | The container publishes on every interface (amending D55), with `SITE_BIND_ADDR` to narrow it | | D60–D65 | §16, "The six decisions" | Per-game sidebar groups, RunicNPC inside Rust with its own page, the RunicNPC API carried in full with a drift check, game names allowed but game vocabulary confined, a twelfth check that enforces it (amending D53), Rust screenshots last and from the rigs | | D66 | §16, "How phase 13 built it" | The hero and footer show the Module API only; a protocol and a bundle belong to a game | +| D67 | §16, "One rule the phases need" | A screenshot belongs to its game: a capture tagged with a game may carry its words, beside the game's name | +| D68–D69 | §16, "How phase 14 built it" | A game's real example on a core page is a tagged, labelled block; *Verify the whole stack* stays in Getting started, game-neutral (amending D60) | --- @@ -1669,6 +1671,10 @@ a mechanism rather than diligence: reflows while it decodes. No browser and no game server — the capture tool is an authoring script whose output is committed, exactly like the brand assets. - **`scripts/checkTokens.mjs`** — no colour literal outside the token file (§7). +- **`scripts/checkTerms.mjs`** — added in phase 14 for D63 and D64, the twelfth check. No game's + vocabulary outside that game's section: its docs group, its sidebar group, or a + `` block (D68). Reads the docs and both sidebar trees; phase 17 widens it to all of + `src/`. - `astro check` plus a production build, in CI on every PR. --- @@ -1925,7 +1931,9 @@ screen and the NPC profile editor. ### One rule the phases need, recorded so review can object -**A screenshot belongs to its game.** Phase 9's UO captures carry the word "Shard" in the pixels and +Adopted by the org lead on 2026-10-22 as **D67**. + +**D67 — A screenshot belongs to its game.** Phase 9's UO captures carry the word "Shard" in the pixels and in their alt text. Alt text has to describe the image honestly, so a capture tagged with a game in `screens.mjs` may use that game's words in its caption and alt. The page showing it must name the game next to it. `checkTerms.mjs` reads the tag. The homepage's "What it looks like" therefore either @@ -2022,6 +2030,70 @@ cover, and phase 17 is the phase that writes that list. Phase 13 does the half t check: `checkFacts` now holds Module-Rust's declared capabilities against `platform.json`, so a capability the module adds turns this repo red today. The page-side assertion lands with the page. +### How phase 14 built it + +Three decisions, taken on 2026-10-22 when the restructure reached pages D60 had not placed. The +count of record is now **sixty-nine**. D67 is the screenshot rule above, adopted that day. + +**D68 — a game's real example on a core page is a tagged, labelled block.** Some core pages need +a real example rather than an invented one: *The module manifest* shows a manifest, *Scheduled +events* names a module's budget dimensions, and the *Installer CLI* has to print +`--game `, because that is the flag. §1 prefers the real thing, since an invented +example is checked by nothing. So the example keeps its game's words and says whose they are: +`src/components/GameExample.astro` prints the game's name above the block, read from +`platform.json` so the label cannot disagree with the attribute, and `checkTerms.mjs` allows that +game's words, and only that game's, between the tags. It is D67's bargain applied to text: a +game's words are honest on a core page when the game is named right beside them. The +alternatives were an invented neutral example (the Integration Kit's template could not serve: it +mounts `/clans`) and prose only, which would have left the core pages less concrete. + +**D69 — *Verify the whole stack* stays in Getting started, game-neutral.** This amends D60, which +had Getting started end on the hand-off page. The core page now teaches the four links in the +chain — the plugin loaded, the game server reached the sidecar, the sidecar is healthy, the site +is ingesting — which are the same for both games, and sends the reader to their game for the +commands. The Ultima Online commands became that group's *Verify the bridge*. The Ultima Online +host requirements moved onto *Connect a ServUO shard*, and the Ultima Online rows of +*Troubleshooting* became the group's own *Troubleshooting* page. + +**What moved, and what stayed.** The Ultima Online group has nine pages: an overview (*The Ultima +Online module*, which took *Install a game module*'s "what the `uo` module adds"), *Connect a +ServUO shard*, *Verify the bridge*, *The shard connection*, *Client files*, *Troubleshooting*, +*Bridge.cfg*, *sidecar.toml* and the *Shard event catalog*. Five pages changed URL, and each old +URL answers with a real `301` from the server: `src/config/redirects.mjs`, asserted by +`test/headers.test.mjs`. *Connect a game server* did **not** move. Its URL is now the +game-neutral hand-off page (*Connect your game server*), which is a better landing for an old link +than a redirect into one game. Until phase 15, its Rust row links to `docs` +`main:rust-link/INSTALL.md`. + +**The core pages use core's words** (D63): a Team, a game server, the sidecar, the bridge plugin, +the game module. Detail that was really one game's moved into that game's pages rather than being +dropped. *The bridge*'s guild-roster passage, for example, is now a general rule about Team rosters, +with the Ultima Online specifics in its event catalog. Three passages were checked against Rust before they were +generalised: both bridges dial loopback newline-delimited JSON with one writer thread, both +sidecars answer `/health` with *plugin connected*, both plugins ship `EventsEnabled` off, and +both modules encrypt their sidecar token with core's key. `canonicalDocs` is now grouped by +game, and the Rust group carries four documents. Discord's *guild* became *Discord server* +everywhere except the one label core's Discord Bot screen really prints, *Guild (server) ID*, +which is the check's only allowlist entry, as D64 predicted. + +**`checkTerms.mjs` is the twelfth check.** It reads every docs page, frontmatter included, and +both sidebar trees as data. It knows 16 words, each tagged with its game. Link targets, bare URLs, +imports and `{…}` expressions are not read, because an address is not prose and an expression +renders a value from `src/data/`, which phase 17 brings into scope. Fenced code is read. An +allowlist entry that stops matching fails the check, so the list cannot rot. Before it was +trusted it was run against a planted page: five violations (a word in prose, in a title, in link +text, in a code fence, and inside another game's block) all failed, and a URL and an expression +carrying the same words both passed. + +**Two findings outside this repository**, reported rather than fixed (they belong to other +repositories): + +- **Core's own Teams panel says "shard".** `website` `client/src/lib/teamAdmin.js` tells an + administrator the list "is not a confirmed empty shard". The docs page now paraphrases it + rather than quoting it. +- **The installer's `--help` says "Which game's shard side"** for `--game`, and `--site-url` names + "Admin → Shard (uo-link)" for both games. + --- [api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md diff --git a/README.md b/README.md index df26147..bb2358c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ it before changing anything here. **Status: phase 12 of 12 — delivery. The site is built.** Fifty pages: ten marketing, legal and app pages and forty of documentation, with real screenshots of the product, full-text search, a -per-page Content-Security-Policy, eleven checks that fail the build when the platform moves out from +per-page Content-Security-Policy, twelve checks that fail the build when the platform moves out from under a claim, and a closed-beta signup backed by SQLite on a bind mount. This phase is the part that makes it a deployment rather than a repository — the container image, the compose file, the publishing workflow and [`DEPLOY.md`](DEPLOY.md). @@ -51,11 +51,12 @@ a publication**. ## The checks, and why they are not optional -Eleven of them, from `PLAN.md` §12. None is a linter; each one enforces a promise the site makes +Twelve of them, from `PLAN.md` §12. None is a linter; each one enforces a promise the site makes that would otherwise decay quietly. ```bash npm run check:sidebar # the rendered docs tree still matches the planned one +npm run check:terms # no game's vocabulary outside that game's section npm run check:screens # every screenshot has an entry, at the size declared npm run check:tokens # no colour literal outside the token file npm run check:brand # the branding pipeline's two quiet failures diff --git a/astro.config.mjs b/astro.config.mjs index 74fa2b5..e898d03 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,6 +4,7 @@ import node from '@astrojs/node'; import starlight from '@astrojs/starlight'; import { inlineScriptHashes, inlineStyleHashes } from './src/config/cspHashes.mjs'; +import { docsRedirects } from './src/config/redirects.mjs'; import { docsSidebar } from './src/config/sidebar.mjs'; /** @@ -34,6 +35,10 @@ export default defineConfig({ format: 'directory', }, + // Pages that moved into a game's own sidebar group (PLAN.md §16, D60). See the file. + redirects: docsRedirects, + + security: { csp: { directives: [ diff --git a/package.json b/package.json index 8a03975..fb3dcb6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "check:quickstart": "node scripts/checkQuickstart.mjs", "check:reference": "node scripts/checkReference.mjs", "check:sidebar": "node scripts/checkSidebar.mjs", + "check:terms": "node scripts/checkTerms.mjs", "check:screens": "node scripts/checkScreens.mjs", "check:a11y": "node scripts/checkA11y.mjs", "check:csp": "node scripts/checkCsp.mjs", @@ -31,7 +32,7 @@ "brand:assets": "node scripts/buildBrandAssets.mjs", "screens:capture": "node scripts/captureScreens.mjs", "csp:hashes": "node scripts/checkCsp.mjs --reset && astro build && node scripts/checkCsp.mjs --write && astro build && node scripts/checkCsp.mjs", - "verify": "npm run check:sidebar && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference && npm run test:served && npm run check:a11y && npm run check:csp", + "verify": "npm run check:sidebar && npm run check:terms && npm run check:screens && npm run check:tokens && npm run check:brand && npm run check:datasafety && npm run check && npm test && npm run build && npm run check:links && npm run check:facts && npm run check:quickstart && npm run check:reference && npm run test:served && npm run check:a11y && npm run check:csp", "test:served": "node --test test/headers.test.mjs" }, "dependencies": { diff --git a/scripts/checkReference.mjs b/scripts/checkReference.mjs index 93bd51b..a248646 100644 --- a/scripts/checkReference.mjs +++ b/scripts/checkReference.mjs @@ -168,7 +168,7 @@ async function run() { // // Existence only. A link to a document that moved is the single most likely way this // section breaks, and it is exactly what a build can answer. - for (const docPath of Object.keys(canonicalDocs)) { + for (const docPath of Object.values(canonicalDocs).flatMap((group) => Object.keys(group))) { const url = `${BASE}/api/v1/repos/${ORG}/docs/contents/${docPath}?ref=main`; const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); if (res.ok) ok(`canonical doc ${docPath}`); diff --git a/scripts/checkTerms.mjs b/scripts/checkTerms.mjs new file mode 100644 index 0000000..45d953e --- /dev/null +++ b/scripts/checkTerms.mjs @@ -0,0 +1,271 @@ +#!/usr/bin/env node +/** + * checkTerms.mjs — PLAN.md §16, D63 and D64. The twelfth check, added in phase 14. + * + * D63: outside a game's own section, the site may NAME the games it supports, but it may not + * use their VOCABULARY. Core concepts keep core's names — a Team, a game server, the sidecar, + * the bridge plugin, the game module — because that is what core itself calls them, and a + * site that says "shard" on a core page is describing one game's deployment as though it were + * the platform. + * + * D64: a convention that holds only while everyone remembers it does not hold. This is the + * same argument `checkTokens.mjs` makes for colours, applied to words. The vocabulary problem + * was measured before this check existed — "shard" 227 times in 45 files — and none of that + * was anybody's decision; it was the residue of a site written while there was one game. + * + * What it reads, as of phase 14: + * + * - every page under `src/content/docs`, frontmatter included (a title and a description + * are what a reader sees first, in the sidebar and in search results); + * - `src/config/sidebar.mjs`, as DATA rather than as text: every group label and every + * page label, in both trees. Its comments are not scanned, because a comment is not + * something a reader of the site ever sees. + * + * Phase 17 widens it to all of `src/`, once the marketing pages have a game page to move + * their game content to. That is also when it learns D67's screenshot tag. + * + * Where a game's words are allowed: + * + * - in that game's own docs directory (`GAME_DIRS`), and its own sidebar group — a group + * whose label is the game's `name` in `platform.json`; + * - between `` tags on a core page (D68). The component prints the + * game's name above the block, so the words arrive already labelled. + * + * What is NOT read, deliberately: + * + * - link targets and bare URLs. A repository is called what it is called, and an address is + * not prose. The LINK TEXT is read, so `[Module-uo](…)` fails and `[releases](…)` passes; + * - `import` lines and `{…}` expressions. They are code, and what an expression renders is + * a value from `platform.json` or `src/data/`, which phase 17 brings into scope. + * + * node scripts/checkTerms.mjs + * + * No token and no network: everything it reads is in this repository. + */ + +import { readFileSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { docsSidebar, plannedSidebar } from '../src/config/sidebar.mjs'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8')); + +/** + * The words, each tagged with the game it belongs to. Matching is case-insensitive and on + * word boundaries, so "shard" does not match `SHARD_VISIBILITY` and "wipe" does not match + * "wiped" unless it is listed. + * + * A word is on this list because it is a game's name for something core has its own name for + * (shard → game server, guild and clan → Team), or because it names one game's component + * (uo-link → the sidecar, ServUO → the game server). Game NAMES — Ultima Online, UO, Rust — + * are not on it: D63 allows them everywhere. Steam is not on it either: Steam sign-in is a + * core identity provider, even though Rust was its first user. + */ +const TERMS = [ + { game: 'uo', word: 'shard', re: /\bshards?\b/gi }, + { game: 'uo', word: 'ServUO', re: /\bservuo\b/gi }, + { game: 'uo', word: 'uo-link', re: /\buo-?link\b/gi }, + { game: 'uo', word: 'Module-uo', re: /\bmodule-uo\b/gi }, + { game: 'uo', word: 'Bridge.cfg', re: /\bbridge\.cfg\b/gi }, + { game: 'uo', word: 'cliloc', re: /\bclilocs?\b/gi }, + { game: 'uo', word: 'guild', re: /\bguilds?\b/gi }, + { game: 'uo', word: 'IDOC', re: /\bidocs?\b/gi }, + { game: 'rust', word: 'rust-link', re: /\brust-?link\b/gi }, + { game: 'rust', word: 'Rust-Plugins', re: /\brust-plugins\b/gi }, + { game: 'rust', word: 'Module-Rust', re: /\bmodule-rust\b/gi }, + { game: 'rust', word: 'Oxide', re: /\boxide\b/gi }, + { game: 'rust', word: 'Carbon', re: /\bcarbon\b/gi }, + { game: 'rust', word: 'uMod', re: /\bumod\b/gi }, + { game: 'rust', word: 'clan', re: /\bclans?\b/gi }, + { game: 'rust', word: 'wipe', re: /\bwipes?\b/gi }, +]; + +/** Each game's own docs directory. A page under one may use that game's words. */ +const GAME_DIRS = { + uo: 'src/content/docs/docs/ultima-online/', + rust: 'src/content/docs/docs/rust/', +}; + +/** + * The allowlist: an exact phrase in one file that may use a word the rule would otherwise + * refuse. Short on purpose, and every entry says why. An entry that no longer matches its + * file fails the check too, so the list cannot rot into a set of permanent exemptions. + */ +const ALLOW = [ + { + file: 'src/content/docs/docs/administration/notifications-and-email.mdx', + text: 'Guild (server) ID', + why: "Discord's own word, not a game's: it is the label on core's Discord Bot screen.", + }, +]; + +for (const game of Object.keys(GAME_DIRS)) { + if (!platform.games[game]) throw new Error(`checkTerms: GAME_DIRS names "${game}", which platform.json does not.`); +} +for (const t of TERMS) { + if (!platform.games[t.game]) throw new Error(`checkTerms: "${t.word}" is tagged "${t.game}", which platform.json does not know.`); +} + +const failures = []; +const fail = (where, detail) => failures.push({ where, detail }); + +/** Replace a span with spaces, keeping newlines, so later line numbers stay true. */ +const blank = (text) => text.replace(/[^\n]/g, ' '); + +/** The game whose directory holds this file, or null for a core page. */ +function gameOfPath(relative) { + const posix = relative.split(path.sep).join('/'); + for (const [game, dir] of Object.entries(GAME_DIRS)) if (posix.startsWith(dir)) return game; + return null; +} + +/** + * Turn an MDX source into the text a reader would actually see, with everything that is not + * prose blanked out. Returns that text plus the GameExample regions, as offsets into it. + */ +function proseOf(source, relative) { + let text = source; + + // GameExample regions first, while the tags are still intact. Recorded, then the tags + // themselves are blanked so the attribute does not read as prose. + const regions = []; + for (const m of text.matchAll(/([\s\S]*?)<\/GameExample>/g)) { + const game = m[1]; + if (!platform.games[game]) { + fail(`${relative}:${lineOf(source, m.index)}`, ` names a game platform.json does not have`); + } + regions.push({ game, start: m.index, end: m.index + m[0].length }); + } + text = text.replace(/<\/?GameExample\b[^>]*>/g, blank); + + text = text.replace(//g, blank); // HTML comments + text = text.replace(/^import\s.*$/gm, blank); // MDX imports + text = text.replace(/\]\([^)\s]*\)/g, (m) => '] ' + blank(m.slice(2))); // link targets, not their text + text = text.replace(/\b(?:href|src)=(?:"[^"]*"|\{`[^`]*`\})/g, blank); // JSX/HTML link targets + text = text.replace(/\bhttps?:\/\/[^\s)>"'`]+/g, blank); // bare URLs + + // `{…}` expressions, outside code fences only — a fenced JSON example's braces are prose. + // Innermost first, repeatedly, so nested and multi-line expressions both go. + const parts = text.split(/(^```[\s\S]*?^```)/m); + for (let i = 0; i < parts.length; i += 2) { + let prev; + do { + prev = parts[i]; + parts[i] = parts[i].replace(/\{[^{}]*\}/g, blank); + } while (parts[i] !== prev); + } + text = parts.join(''); + + return { text, regions }; +} + +function lineOf(source, index) { + return source.slice(0, index).split('\n').length; +} + +/** Hits for every term the given context may not use. */ +function scan(text, allowedGame) { + const hits = []; + for (const t of TERMS) { + if (t.game === allowedGame) continue; + t.re.lastIndex = 0; + for (const m of text.matchAll(t.re)) hits.push({ term: t, value: m[0], index: m.index }); + } + return hits; +} + +async function* walk(dir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* walk(full); + else if (/\.mdx?$/.test(entry.name)) yield full; + } +} + +// ── 1. The documentation pages ────────────────────────────────────────────── +let files = 0; +const allowUsed = new Set(); + +for await (const file of walk(path.join(ROOT, 'src', 'content', 'docs'))) { + const relative = path.relative(ROOT, file).split(path.sep).join('/'); + const posix = relative; + const source = readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); + files++; + + let { text, regions } = proseOf(source, relative); + + for (const [i, entry] of ALLOW.entries()) { + if (entry.file !== posix) continue; + if (text.includes(entry.text)) { + allowUsed.add(i); + text = text.split(entry.text).join(blank(entry.text)); + } + } + + const pageGame = gameOfPath(relative); + for (const hit of scan(text, pageGame)) { + const region = regions.find((r) => hit.index >= r.start && hit.index < r.end); + if (region && region.game === hit.term.game) continue; + const where = region ? `inside ` : pageGame ? `in the ${platform.games[pageGame].name} group` : 'on a core page'; + fail( + `${relative}:${lineOf(source, hit.index)}`, + `"${hit.value}" is ${platform.games[hit.term.game].name} vocabulary, ${where}`, + ); + } +} + +for (const [i, entry] of ALLOW.entries()) { + if (!allowUsed.has(i)) { + fail(`allowlist: ${entry.file}`, `"${entry.text}" no longer appears there — remove the entry`); + } +} + +// ── 2. The sidebar, as data ───────────────────────────────────────────────── +const gameByName = new Map(Object.entries(platform.games).map(([key, g]) => [g.name, key])); +const trees = [ + ['docsSidebar', docsSidebar.map((g) => [g.label, g.items.map((i) => i.label)])], + ['plannedSidebar', Object.entries(plannedSidebar)], +]; +let labels = 0; +for (const [treeName, groups] of trees) { + for (const [groupLabel, items] of groups) { + const game = gameByName.get(groupLabel) ?? null; + for (const label of [groupLabel, ...items]) { + labels++; + for (const hit of scan(label, game)) { + fail( + `src/config/sidebar.mjs ${treeName} → ${groupLabel}`, + `"${label}" uses "${hit.value}", ${platform.games[hit.term.game].name} vocabulary, ${game ? `in the ${platform.games[game].name} group` : 'in a core group'}`, + ); + } + } + } +} + +// ── Report ────────────────────────────────────────────────────────────────── +if (failures.length === 0) { + console.log( + `checkTerms: ${files} docs pages and ${labels} sidebar labels use only their own game's words ` + + `(${TERMS.length} terms, ${ALLOW.length} allowlisted phrase${ALLOW.length === 1 ? '' : 's'}).`, + ); +} else { + console.error(`\ncheckTerms: ${failures.length} game word(s) outside their game's section:\n`); + for (const f of failures) console.error(` ✗ ${f.where}\n ${f.detail}`); + console.error(` +PLAN.md §16, D63: name the games anywhere, but keep their vocabulary in their own section. +Use core's word instead — a Team, a game server, the sidecar, the bridge plugin, the game +module. If a core page genuinely needs a game's real example, wrap it in + (D68), which labels it with the game's name. Add to the allowlist +in this script only for a word that is not a game's at all, and say why. +`); + process.exit(1); +} diff --git a/src/components/GameExample.astro b/src/components/GameExample.astro new file mode 100644 index 0000000..841b3c1 --- /dev/null +++ b/src/components/GameExample.astro @@ -0,0 +1,37 @@ +--- +import platform from '../data/platform.json'; + +/** + * A worked example that belongs to one game, quoted on a core page (PLAN.md §16, D68). + * + * D63 keeps a game's vocabulary inside that game's section, but a core page sometimes needs a + * real example rather than an invented one: a module manifest, an event dimension, the flag + * that tells the installer which game it is setting up. §1 prefers the real thing, because an + * invented example is checked by nothing and goes stale without anybody noticing. + * + * So the example keeps its game's words and says whose they are. This prints the game's name + * above the block — the same bargain D67 strikes for a screenshot: a game's words are honest + * on a core page when the game is named right beside them. `scripts/checkTerms.mjs` reads the + * `game` attribute and allows that game's words, and only that game's, between the tags. + * + * The name comes from `platform.json` rather than from the caller, so the label cannot say + * one game while the attribute that the check reads says another. + */ +interface Props { + /** A key of `platform.json`'s `games`: `uo` or `rust`. */ + game: keyof typeof platform.games; +} + +const { game } = Astro.props; +const entry = platform.games[game]; +if (!entry) { + throw new Error( + `GameExample: unknown game "${game}". Use a key of platform.json's games: ${Object.keys(platform.games).join(', ')}.`, + ); +} +--- + +
+
{entry.name}
+
+
diff --git a/src/config/redirects.mjs b/src/config/redirects.mjs new file mode 100644 index 0000000..60fc76e --- /dev/null +++ b/src/config/redirects.mjs @@ -0,0 +1,22 @@ +/** + * Docs pages that moved, and where they went — PLAN.md §16, D60. + * + * Every page that moves keeps its old URL working, because the old URL is in somebody's + * bookmarks, in an installer's output and in search results. With the Node adapter these are + * not files in `dist/client`: the server answers each one with a real `301`. + * + * Its own file, rather than inline in `astro.config.mjs`, so that `test/headers.test.mjs` can + * read the same list and assert every entry against the running server — the config cannot be + * imported by plain Node, because Starlight ships TypeScript. + * + * Phase 14 moved the Ultima Online pages into their own group. `connect-a-game-server` did + * NOT move: its URL is now the game-neutral hand-off page, which is a better landing for an + * old link than a redirect into one game would be. + */ +export const docsRedirects = { + '/docs/administration/the-shard-connection': '/docs/ultima-online/the-shard-connection/', + '/docs/administration/client-files': '/docs/ultima-online/client-files/', + '/docs/reference/bridge-cfg': '/docs/ultima-online/bridge-cfg/', + '/docs/reference/sidecar-toml': '/docs/ultima-online/sidecar-toml/', + '/docs/reference/event-catalog': '/docs/ultima-online/event-catalog/', +}; diff --git a/src/config/sidebar.mjs b/src/config/sidebar.mjs index b954176..0fe21c5 100644 --- a/src/config/sidebar.mjs +++ b/src/config/sidebar.mjs @@ -1,5 +1,6 @@ /** - * The documentation sidebar — PLAN.md §10 "Documentation", five groups, ~37 pages. + * The documentation sidebar — PLAN.md §10 "Documentation": five core groups, then one group + * per game after them (§16, D60). * * The pages themselves land in phases 7 and 8. This file exists in phase 1 because the * shape of the journey is a design decision that is already made, and because a sidebar @@ -22,7 +23,7 @@ export const docsSidebar = [ { label: 'Install the site', slug: 'docs/getting-started/install-the-site' }, { label: 'First run', slug: 'docs/getting-started/first-run' }, { label: 'Install a game module', slug: 'docs/getting-started/install-a-game-module' }, - { label: 'Connect a game server', slug: 'docs/getting-started/connect-a-game-server' }, + { label: 'Connect your game server', slug: 'docs/getting-started/connect-a-game-server' }, { label: 'Verify the whole stack', slug: 'docs/getting-started/verify-the-whole-stack' }, ], }, @@ -42,8 +43,6 @@ export const docsSidebar = [ { label: 'Engagement rules', slug: 'docs/administration/engagement-rules' }, { label: 'Message templates', slug: 'docs/administration/message-templates' }, { label: 'Managing modules', slug: 'docs/administration/managing-modules' }, - { label: 'The shard connection', slug: 'docs/administration/the-shard-connection' }, - { label: 'Client files', slug: 'docs/administration/client-files' }, { label: 'Maintenance and upgrades', slug: 'docs/administration/maintenance-and-upgrades' }, { label: 'Troubleshooting', slug: 'docs/administration/troubleshooting' }, ], @@ -77,13 +76,28 @@ export const docsSidebar = [ items: [ { label: 'Environment variables', slug: 'docs/reference/environment-variables' }, { label: 'Installer CLI', slug: 'docs/reference/installer-cli' }, - { label: 'sidecar.toml', slug: 'docs/reference/sidecar-toml' }, - { label: 'Bridge.cfg', slug: 'docs/reference/bridge-cfg' }, { label: 'HTTP API', slug: 'docs/reference/http-api' }, - { label: 'Shard event catalog', slug: 'docs/reference/event-catalog' }, { label: 'Canonical documents', slug: 'docs/reference/canonical-documents' }, ], }, + // One group per game, after Reference (PLAN.md §16, D60). Everything above this line is + // core and is held to core's words by checkTerms.mjs (D63, D64). A group whose label is a + // game's `name` in platform.json is that game's group, and may use that game's words — so + // the label is load-bearing: renaming it here without renaming the game turns it core. + { + label: 'Ultima Online', + items: [ + { label: 'The Ultima Online module', slug: 'docs/ultima-online' }, + { label: 'Connect a ServUO shard', slug: 'docs/ultima-online/connect-a-servuo-shard' }, + { label: 'Verify the bridge', slug: 'docs/ultima-online/verify-the-bridge' }, + { label: 'The shard connection', slug: 'docs/ultima-online/the-shard-connection' }, + { label: 'Client files', slug: 'docs/ultima-online/client-files' }, + { label: 'Troubleshooting', slug: 'docs/ultima-online/troubleshooting' }, + { label: 'Bridge.cfg', slug: 'docs/ultima-online/bridge-cfg' }, + { label: 'sidecar.toml', slug: 'docs/ultima-online/sidecar-toml' }, + { label: 'Shard event catalog', slug: 'docs/ultima-online/event-catalog' }, + ], + }, ]; /** @@ -105,7 +119,7 @@ export const plannedSidebar = { 'Install the site', 'First run', 'Install a game module', - 'Connect a game server', + 'Connect your game server', 'Verify the whole stack', ], Administration: [ @@ -122,8 +136,6 @@ export const plannedSidebar = { 'Engagement rules', 'Message templates', 'Managing modules', - 'The shard connection', - 'Client files', 'Maintenance and upgrades', 'Troubleshooting', ], @@ -148,10 +160,18 @@ export const plannedSidebar = { Reference: [ 'Environment variables', 'Installer CLI', - 'sidecar.toml', - 'Bridge.cfg', 'HTTP API', - 'Shard event catalog', 'Canonical documents', ], + 'Ultima Online': [ + 'The Ultima Online module', + 'Connect a ServUO shard', + 'Verify the bridge', + 'The shard connection', + 'Client files', + 'Troubleshooting', + 'Bridge.cfg', + 'sidecar.toml', + 'Shard event catalog', + ], }; diff --git a/src/content/docs/docs/administration/configuration.mdx b/src/content/docs/docs/administration/configuration.mdx index f5bbc2d..0efe512 100644 --- a/src/content/docs/docs/administration/configuration.mdx +++ b/src/content/docs/docs/administration/configuration.mdx @@ -16,7 +16,7 @@ Two places hold configuration, and the line between them is not arbitrary. The rule behind the split: **anything that needs a restart or a shell is environment; anything an administrator should be able to change without either is in the panel.** That -is why the Discord bot token, the OAuth client secrets and the shard's auth token are *not* +is why the Discord bot token, the OAuth client secrets and the game module's sidecar token are *not* environment variables — they are entered in the panel and stored encrypted. ## Settings diff --git a/src/content/docs/docs/administration/content.mdx b/src/content/docs/docs/administration/content.mdx index 478d115..1702b56 100644 --- a/src/content/docs/docs/administration/content.mdx +++ b/src/content/docs/docs/administration/content.mdx @@ -27,7 +27,7 @@ glance. diff --git a/src/content/docs/docs/administration/engagement-rules.mdx b/src/content/docs/docs/administration/engagement-rules.mdx index f03dd33..ae87228 100644 --- a/src/content/docs/docs/administration/engagement-rules.mdx +++ b/src/content/docs/docs/administration/engagement-rules.mdx @@ -168,8 +168,8 @@ which is where a person's record belongs. A module declares its own triggers and its own audiences, in its own vocabulary, and it may ship rules and message bodies to go with them. The Ultima Online module ships a large family of them — houses falling to ruin, vendors running out of gold, a governor being seated, a -guild's fortunes — written in the voice of an in-world office rather than a system alert. +Team's fortunes — written in the voice of an in-world office rather than a system alert. All of them arrive **disabled**, like every other seeded rule. Read the list in -**Admin → Engagement → Triggers**, turn on the ones your shard should send, and check the +**Admin → Engagement → Triggers**, turn on the ones your site should send, and check the send log the first time each one fires. diff --git a/src/content/docs/docs/administration/events.mdx b/src/content/docs/docs/administration/events.mdx index fc1aef3..89ecb36 100644 --- a/src/content/docs/docs/administration/events.mdx +++ b/src/content/docs/docs/administration/events.mdx @@ -4,6 +4,7 @@ description: Author an event as phases and steps, price it against this deployme --- import { Aside } from '@astrojs/starlight/components'; +import GameExample from '../../../../components/GameExample.astro'; An **event** is a scheduled, bounded, audited change to a live game world. You write it once as a sequence of phases, publish a version of it, put it on the calendar, and it runs — at @@ -71,8 +72,13 @@ start doing things to your world. Each row has two controls: whether the action is permitted on this deployment at all, and its **per-run caps** — how much of a budget dimension one run may consume. Dimensions are -declared by the module (`uo.creatures`, `uo.bosses`, `uo.rewards` and so on), and consumption -is counted in the database with a conditional update, not checked in application code. +declared by the module, and consumption is counted in the database with a conditional +update, not checked in application code. + + +The Ultima Online module declares `uo.creatures`, `uo.bosses`, `uo.rewards` and others — +one per kind of thing an event can spend. + That distinction matters more than it sounds. A stolen admin session has already passed every role check there is; it still cannot exceed the cap, because the cap is a condition on the @@ -135,18 +141,23 @@ restart puts every borrowed value back too. ## The game server has its own switches -They live on the shard host, outside the site's reach, and the site cannot turn them on. +They live in the bridge plugin's config on the game server's host, outside the site's reach, +and the site cannot turn them on. -**`EventsEnabled` is off by default, and it is a different switch from `AdminWriteEnabled`.** -Turning the admin plane on is consenting to staff moderation driven from a screen somebody is -looking at. Turning this on is consenting to the site changing and watching your world -unattended. One switch could not honestly express both. +**`EventsEnabled` is off by default**, in both games. Turning it on is consenting to the site +changing and watching your world unattended. -Beside it sit the game's own ceilings — how many creatures one call may spawn, how long a gate -may stand, how much one run may own in total, how often the world may be saved. **They refuse -rather than clamp**, for the same reason the caps do: a quietly shortened request leaves the -two halves disagreeing about what actually happened. See -[Bridge.cfg](/docs/reference/bridge-cfg/) for every key. + +`EventsEnabled` is a different switch from `AdminWriteEnabled`. Turning the admin plane on is +consenting to staff moderation driven from a screen somebody is looking at; turning events on +is consenting to unattended changes. One switch could not honestly express both. Every key +is in [`Bridge.cfg`](/docs/ultima-online/bridge-cfg/). + + +Beside it sit the game's own ceilings — how much one call may place, how long something it +creates may stand, how much one run may own in total. **They refuse rather than clamp**, for +the same reason the caps do: a quietly shortened request leaves the two halves disagreeing +about what actually happened. ## What players see diff --git a/src/content/docs/docs/administration/maintenance-and-upgrades.mdx b/src/content/docs/docs/administration/maintenance-and-upgrades.mdx index a167ac1..48298b8 100644 --- a/src/content/docs/docs/administration/maintenance-and-upgrades.mdx +++ b/src/content/docs/docs/administration/maintenance-and-upgrades.mdx @@ -102,7 +102,7 @@ Three rules for whatever proxy you use: - **Terminate TLS at the proxy.** The app speaks HTTP; it is not meant to hold a certificate. -## Upgrading the shard side +## Upgrading the game server side A different deployment on a different host, and it moves on its own schedule: @@ -111,22 +111,18 @@ sudo runicgateway update # re-resolves the bundle; --verify to see it first sudo runicgateway doctor # confirm afterwards ``` -`update` replaces the sidecar and restarts its service, re-syncs the overlay, and tells you -when ServUO needs restarting — it never restarts your shard itself. Because it resolves a -**bundle**, the sidecar and the plugin move together and cannot end up disagreeing about the -protocol. +`update` replaces the sidecar and restarts its service, redeploys the bridge plugin, and +tells you when the game server needs restarting — it never restarts your game server itself. +Because it resolves a **bundle**, the sidecar and the plugin move together and cannot end up +disagreeing about the protocol. Add the game's own flags as you did at install; see the +[Installer CLI](/docs/reference/installer-cli/). - +Each game can have its own day-two chores on top of this. Ultima Online's are in [its +group](/docs/ultima-online/client-files/#after-a-client-update). diff --git a/src/content/docs/docs/administration/message-templates.mdx b/src/content/docs/docs/administration/message-templates.mdx index 05112bd..fec1a2a 100644 --- a/src/content/docs/docs/administration/message-templates.mdx +++ b/src/content/docs/docs/administration/message-templates.mdx @@ -7,7 +7,7 @@ import { Aside } from '@astrojs/starlight/components'; Every message your site sends — password resets, invitations, notifications — is a **template** you can edit. They ship working, so a fresh site mails correctly before you -open this screen at all. You come here when you want it to sound like your shard. +open this screen at all. You come here when you want it to sound like your community. **Admin → Engagement → Templates.** @@ -105,7 +105,7 @@ Test sends are recorded in the send log like any other message, including when t You do not start from a blank page. Pick a message that is close to what you want, press **Duplicate**, and give the copy a key. -The **key** is how a rule refers to the template — `notify.house-idoc`, say. Lowercase +The **key** is how a rule refers to the template — `notify.team-welcome`, say. Lowercase letters, digits, dots and dashes, and it cannot be changed later, so pick one that will still make sense in a year. diff --git a/src/content/docs/docs/administration/moderation.mdx b/src/content/docs/docs/administration/moderation.mdx index d3e4070..3dbcbae 100644 --- a/src/content/docs/docs/administration/moderation.mdx +++ b/src/content/docs/docs/administration/moderation.mdx @@ -10,7 +10,7 @@ not overlap. | Screen | Is about | Comes from | |---|---|---| -| **Moderation** | Your **Discord** guild — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live | +| **Moderation** | Your **Discord** server — bans, kicks, mutes, warnings, joins, leaves, filter and spam hits | the bot, captured live | | **Reports** | **Team forum content** members have reported | the site | | **Appeals** | Sanctions people are asking you to reverse | the site | diff --git a/src/content/docs/docs/administration/notifications-and-email.mdx b/src/content/docs/docs/administration/notifications-and-email.mdx index 7adb945..5cc6a22 100644 --- a/src/content/docs/docs/administration/notifications-and-email.mdx +++ b/src/content/docs/docs/administration/notifications-and-email.mdx @@ -82,7 +82,7 @@ at the wrong host. Which legs exist depends on what has registered one: - **Discord `#news`** is core's, and needs the bot below. -- **A module may add its own.** The `uo` module adds an in-game town crier, so a news post +- **A module may add its own.** The Ultima Online module adds an in-game town crier, so a news post is announced to players who are logged into the game and never visit the site. A leg brings its own settings with it — the town crier's duration is a module setting, not @@ -90,7 +90,7 @@ a core one — which is why they are documented with the module rather than here ## The Discord bot -**Admin → Discord Bot**: enable it, give it the guild (server) ID and the bot token, and +**Admin → Discord Bot**: enable it, give it the **Guild (server) ID** and the bot token, and save. The token is stored **encrypted in the database** and is never an environment variable. diff --git a/src/content/docs/docs/administration/teams.mdx b/src/content/docs/docs/administration/teams.mdx index 19357a1..577432f 100644 --- a/src/content/docs/docs/administration/teams.mdx +++ b/src/content/docs/docs/administration/teams.mdx @@ -9,8 +9,8 @@ Teams are a core platform primitive: membership, roles, forums, notifications, m and the Discord integrations are all core's, and none of it knows what a Team *is* in your game. -**Core cannot create a Team.** Teams arrive from the installed module — with the `uo` -module, they are the shard's guilds. On a deployment with no module, the Team machinery is +**Core cannot create a Team.** Teams arrive from the installed game module, which maps +whatever the game calls its groups of players onto a Team. On a deployment with no module, the Team machinery is present and permanently empty. That is not a bug to work around; it is the contract that lets the same forum, notification and moderation code serve any game. @@ -25,15 +25,14 @@ reconciles them. **Admin → Teams** shows a sync panel per module: last attempt, last success, consecutive failures and the last error, with **Sync now** and **Resync now**. -The wording on that panel is exact and worth reading: - -> Core has never had an answer it could trust. What is shown below is not a confirmed empty -> shard. +The wording on that panel is exact and worth reading. Until a sync has succeeded, it says +that core has never had an answer it could trust, and that the list below it is not a +confirmed empty one. An empty list therefore means one of two very different things — there are no Teams, or -nobody could ask. The panel tells you which, and a *last success: never* with a *last -error* of `no uo-link configured` is the second. Fix -[the shard connection](/docs/administration/the-shard-connection/) and sync again. +nobody could ask. The panel tells you which: a *last success: never* with a *last error* +that names the game connection is the second. Fix the game module's connection to its game +server and sync again. Each game's group in the sidebar covers its own connection screen. ## Forums @@ -105,8 +104,8 @@ event leaves the site. **Voice channels** — gives each Team a Discord voice channel of its own, with access granted by a per-Team role, so a Team's members can see and join theirs and nobody else -can. It needs the bot reachable, and members need a linked Discord account and guild -membership. +can. It needs the bot reachable, and members need a linked Discord account and membership of +your Discord server. Its three settings deserve a thought each: @@ -114,10 +113,10 @@ Its three settings deserve a thought each: |---|---| | **Minimum members** | How large a Team must be to get a channel. Every active member counts, linked account or not. | | **Grace window (days)** | How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel; zero removes it on the next pass. | -| **Staff roles** | Roles that can see and join every Team's channel. Guild administrators already can, so this is for staff who are not administrators. | +| **Staff roles** | Roles that can see and join every Team's channel. Discord server administrators already can, so this is for staff who are not administrators. | -