From ecc556d7acc3331065bad665621acb7902fcbb95 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 22 Oct 2026 15:49:07 -0500 Subject: [PATCH 1/2] feat(docs): phase 16 - the RunicNPC subgroup, its API carried in full and drift-checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md §16 phase 16, with the org lead's D72-D74 (count of record 74). - Eight pages nested in the Rust group at /docs/rust/runicnpc/ (D72): the overview, Install RunicNPC, Commands, NPC profiles, NPC placements, Cost and caps, Profile fields (the one field reference the editor, rnpc.profile set, the data file and the API all share) and The API. - The API page carries every call, hook and data shape (D62), in the site's own words, with no decision numbers and no per-call version history (D74). Signatures print from src/data/runicnpc.mjs through Signature.astro. - checkReference.mjs gains two sections. Against docs main:runicnpc/API.md: the API version, 24 call and 10 hook signatures, and the field names of the profile, RunicNpc_List rows, placements, routes and owners (D73), plus exactly one per call on api.mdx. Against runicnpc-rust main:plugin/RunicNPC.cs: console verbs, permissions, place options and config keys. Six planted drifts all failed before it was trusted. - checkSidebar and checkTerms read nested sidebar groups via flatLabels(). - The Rust overview, Events zones and rewards, and Canonical documents link the new pages; the canonical documents gain the four runicnpc/ docs. Both NPC admin screens were walked on the walk site first (D36); nothing was saved. npm run verify is green. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- PLAN.md | 91 +++- scripts/checkReference.mjs | 218 ++++++++ scripts/checkSidebar.mjs | 8 +- scripts/checkTerms.mjs | 6 +- src/components/Signature.astro | 21 + src/components/Ticks.astro | 35 ++ src/config/sidebar.mjs | 43 ++ .../docs/reference/canonical-documents.mdx | 4 + .../docs/rust/events-zones-and-rewards.mdx | 2 +- src/content/docs/docs/rust/index.mdx | 4 +- src/content/docs/docs/rust/runicnpc/api.mdx | 362 ++++++++++++ .../docs/docs/rust/runicnpc/commands.mdx | 104 ++++ .../docs/docs/rust/runicnpc/cost-and-caps.mdx | 79 +++ src/content/docs/docs/rust/runicnpc/index.mdx | 75 +++ .../docs/docs/rust/runicnpc/install.mdx | 115 ++++ .../docs/rust/runicnpc/npc-placements.mdx | 68 +++ .../docs/docs/rust/runicnpc/npc-profiles.mdx | 110 ++++ .../docs/rust/runicnpc/profile-fields.mdx | 152 ++++++ src/data/reference.mjs | 4 + src/data/runicnpc.mjs | 513 ++++++++++++++++++ 20 files changed, 2005 insertions(+), 9 deletions(-) create mode 100644 src/components/Signature.astro create mode 100644 src/components/Ticks.astro create mode 100644 src/content/docs/docs/rust/runicnpc/api.mdx create mode 100644 src/content/docs/docs/rust/runicnpc/commands.mdx create mode 100644 src/content/docs/docs/rust/runicnpc/cost-and-caps.mdx create mode 100644 src/content/docs/docs/rust/runicnpc/index.mdx create mode 100644 src/content/docs/docs/rust/runicnpc/install.mdx create mode 100644 src/content/docs/docs/rust/runicnpc/npc-placements.mdx create mode 100644 src/content/docs/docs/rust/runicnpc/npc-profiles.mdx create mode 100644 src/content/docs/docs/rust/runicnpc/profile-fields.mdx create mode 100644 src/data/runicnpc.mjs diff --git a/PLAN.md b/PLAN.md index 93bb72a..1dc4741 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 **seventy-one**: +somewhere other than the thing it decided. The count of record is **seventy-four**: | # | Where | What it settled | |---|---|---| @@ -259,6 +259,7 @@ somewhere other than the thing it decided. The count of record is **seventy-one* | 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) | | D70–D71 | §16, "How phase 15 built it" | The Rust group is fifteen pages, one per admin screen where it can be; the Rust event kinds are checked against the module's own classification | +| D72–D74 | §16, "How phase 16 built it" | The RunicNPC subgroup is eight pages with a shared profile-field reference; the API check compares signatures and every data shape's field names; the site's API page carries the current API only, with no decision numbers and no version history | --- @@ -2172,6 +2173,94 @@ same day, each in its own repository's PR into `edge`: helpers**; the bundle ships three. The org lead approved a separate `docs` PR for these: `docs` #370. +### How phase 16 built it + +Three decisions, taken by the org lead on 2026-10-22 before any page was written. The count of +record is now **seventy-four**. + +**D72 — the RunicNPC subgroup is eight pages, one of them a shared profile-field reference.** D61 +named five topics. The subgroup sits at the end of the Rust group, nested, at +`/docs/rust/runicnpc/`: *RunicNPC* (the overview), *Install RunicNPC*, *Commands*, *NPC profiles*, +*NPC placements*, *Cost and caps*, *Profile fields* and *The API*. The site's two NPC screens get a +page each, as the Rust group's screens do, and the cost warning and caps get their own page rather +than a section of *Install*. ***Profile fields* is the one page every way of writing a profile +links to**: the site's editor (whose sections it follows, with each field's label), `rnpc.profile +set` (which takes the same JSON paths), the data file, and the API. So the API page shows calls, +hooks, placements and routes, and links there for a profile's shape rather than repeating it. The +alternatives were D61's five pages, and seven without the shared reference. + +**D73 — the API check compares signatures and field names.** D62 named calls, hooks and +signatures. The check also compares the field names of every data shape `API.md` gives: a +profile's fields, `RunicNpc_List`'s keys, a placement's keys, a route's keys, and the owners. A +field added to the profile upstream turns this site red the same way a new call does. The +alternative, calls and hooks only, would have let the largest and most-changed shape drift +unnoticed. + +**D74 — the site's API page carries the current API only.** `API.md` explains itself with decision +numbers (*D258, D269*) and plan stages, and tags each call with the API version that added it. The +site page drops both. Decision numbers mean nothing to a plugin author outside this project — the +reason `Module-Rust` #59 removed them from the admin screens — and the version history is +`API.md`'s to keep. The page states the current API version, read from `platform.json`, and shows +how to check it before calling. The alternatives were keeping the "since API n" tags, and copying +`API.md` faithfully, decision numbers included. + +**The drift check is two sections of `checkReference.mjs`**, so the count of checks stays at +twelve, as the plan said: + +- **Against `docs` `main:runicnpc/API.md`**, through the `contents` endpoint: the API version + against `platform.json`; all 24 calls by whole signature, parsed from their headings; all 10 + hooks by signature, from their table; and the field names of the five shapes (D73). A profile + field cell that names two fields (`hurtByPlayers`, `hurtsPlayers`), or a bare name after a dotted + one (`phases[].damageDealt`, `aimCone`), is read the way a person reads it. And the page itself: + every call must have exactly one `` on `api.mdx`, so "carried in full" is + held by the build, not by whoever last edited the page. +- **Against `runicnpc-rust` `main:plugin/RunicNPC.cs`**: the console verbs it registers, the + permissions it registers (and that every command's permission is one of them), the `key=` + options `ParseOptions` accepts, and the JSON keys of its config classes. These are names on the + *Commands* and *Cost and caps* pages, and §12 has checked every name the Reference pages write + since phase 8. D62 did not ask for this half; it is the phase-15 precedent (the bridge plugin's + `RunicGateway.json` is read from its source the same way) applied to the two new pages that list + names. + +Signatures print from `src/data/runicnpc.mjs` through `src/components/Signature.astro`, which +renders them with Starlight's ``, so the signature a reader copies is the one the check +compared. Descriptions live beside them as short strings, written in the site's words, and +`src/components/Ticks.astro` renders their backticks, bold and links — never HTML. Before the check +was trusted, six drifts were planted: a parameter added to a call, a hook renamed, a `RunicNpc_List` +key renamed, a profile field renamed, a command verb renamed, and one call's `` removed +from the page. All six failed, each naming the side it broke. + +**The sidebar check learned nested groups.** `checkSidebar.mjs` and `checkTerms.mjs` read both +trees through `flatLabels()` in `sidebar.mjs`, which writes a nested page as `RunicNPC › Commands`. +The plan's order, and its vocabulary, are held for a subgroup exactly as for a group. + +**Every admin screen was walked first** (D36), signed in as the walk admin on the walk site against +both rigs: *Rust NPC profiles* (the servers with their RunicNPC version, API and last push; the +profile list; the faction table; the editor opened on a new profile, through every section) and +*Rust NPC placements* (the server picker, the map with its layers, a waiting placement whose +profile had gone, and the cost warning above the list). Nothing was saved. Two claims were then +checked in `Module-Rust`'s server before they were written: the push tick is 30 s with a +quarter-hourly audit (`npcSync.js`), and saving a profile asks every covered server that answers +for its kits. + +**What else changed.** The Rust overview's staff table links the two NPC screens to their pages, +and its "In this group" table gains the subgroup. *Events, zones and rewards* links its *Place +NPCs* step to the profiles page. The canonical documents gain `runicnpc/API.md`, `COMMANDS.md`, +`INSTALL.md` and `PLAN.md` under Rust, and that page now names the API page as the one deliberate +exception to "every page links out". + +**Findings outside this repository**, reported rather than fixed: + +- **The Rust bundle's Oxide floor is lower than RunicNPC's.** `v2/rust/current.json` says Oxide + 2.0.7585, which is the bridge plugin's floor. But the bundle requires RunicNPC, whose own + `plugin.toml` says 2.0.7726 — and its comment notes that RunicNPC "has never run on" 7585. So + the installer will accept a server that RunicNPC has never been tried on. The site states both + numbers where each belongs (the Rust overview quotes the bundle's, the RunicNPC pages + RunicNPC's) and claims nothing about the gap. +- **`API.md`'s profile table breaks in two places.** The `boss` rows and the `loot` rows each + follow a blank line with no header row, so Gitea renders them as plain text full of pipes rather + than as tables. The check parses them line by line and is not affected; a reader of `API.md` is. + --- [api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md diff --git a/scripts/checkReference.mjs b/scripts/checkReference.mjs index 6e961e9..8def4b9 100644 --- a/scripts/checkReference.mjs +++ b/scripts/checkReference.mjs @@ -48,6 +48,19 @@ import { rustPluginConfig, rustEventKinds, } from '../src/data/reference.mjs'; +import { + runicnpcOwners, + runicnpcCalls, + runicnpcHooks, + runicnpcListKeys, + runicnpcPlacementKeys, + runicnpcRouteKeys, + runicnpcProfileFields, + runicnpcCommands, + runicnpcPermissions, + runicnpcPlaceOptions, + runicnpcConfig, +} from '../src/data/runicnpc.mjs'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); const platform = JSON.parse(readFileSync(path.join(ROOT, 'src/data/platform.json'), 'utf8')); @@ -150,6 +163,196 @@ function frozenListOf(source, name) { return [...code.matchAll(/'([^'\n]+)'/g)].map((x) => x[1]); } +/** + * One section of a Markdown document: from the line starting with `heading` to the next + * heading of the same or a higher level. Null when there is no such heading, which every + * caller reports — a section that silently vanished must not read as an empty one. + */ +function mdSection(md, heading) { + const lines = md.split(/\r?\n/); + const start = lines.findIndex((l) => l.startsWith(heading)); + if (start < 0) return null; + const level = heading.match(/^#+/)[0].length; + const stop = new RegExp(`^#{1,${level}} `); + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (stop.test(lines[i])) { + end = i; + break; + } + } + return lines.slice(start + 1, end).join('\n'); +} + +/** The backticked names in the first cell of every table row of a section. */ +function firstCellNames(section) { + return section + .split('\n') + .filter((l) => l.startsWith('| `')) + .map((l) => [...l.split('|')[1].matchAll(/`([^`]+)`/g)].map((m) => m[1])); +} + +const collapse = (s) => s.replace(/\s+/g, ' ').trim(); + +/** The top-level keys of the first ```json block in a section. */ +function jsonKeysOf(section) { + const m = section?.match(/```json\s*\n([\s\S]*?)\n```/); + if (!m) return null; + try { + return Object.keys(JSON.parse(m[1])); + } catch { + return null; + } +} + +async function checkRunicNpcApi(md) { + const hint = 'docs main:runicnpc/API.md'; + + // The version the specification describes, against the one platform.json quotes (and + // checkFacts holds to the plugin's own plugin.toml). Together they close the loop. + const version = Number(md.match(/\*\*API version (\d+)\*\*/)?.[1]); + if (version !== platform.runicnpc.api) { + fail('RunicNPC API version', `${hint} describes API ${version || '(none found)'}; platform.json says ${platform.runicnpc.api}`); + } else ok(`RunicNPC API version (${version})`); + + // Calls: `### \`RunicNpc_X(params)\` → \`returns\`` headings, compared by whole signature. + const theirCalls = new Map( + [...md.matchAll(/^### `(RunicNpc_\w+)\(([^)]*)\)` → `([^`]+)`\s*$/gm)].map((m) => [m[1], collapse(`(${m[2]}) → ${m[3]}`)]), + ); + const myCalls = new Map(Object.entries(runicnpcCalls).map(([n, c]) => [n, collapse(`(${c.params}) → ${c.returns}`)])); + compareSignatures('RunicNPC call', myCalls, theirCalls, hint); + + // Hooks: the first cell of each row of "Hooks it raises". + const hooksSection = mdSection(md, '## Hooks it raises'); + if (!hooksSection) fail('RunicNPC hooks', `no "## Hooks it raises" section in ${hint}`); + else { + const theirHooks = new Map( + [...hooksSection.matchAll(/^\| `(OnRunicNpc\w+)\(([^)]*)\)` \|/gm)].map((m) => [m[1], collapse(`(${m[2]})`)]), + ); + const myHooks = new Map(Object.entries(runicnpcHooks).map(([n, h]) => [n, collapse(`(${h.params})`)])); + compareSignatures('RunicNPC hook', myHooks, theirHooks, hint); + } + + // The data shapes, by field name (D73). + const shape = (label, heading, mine, read) => { + const section = mdSection(md, heading); + const theirs = section && read(section); + if (!theirs || theirs.length === 0) fail(label, `could not read the "${heading}" section of ${hint}`); + else compareSets(label, mine, theirs, `${hint} "${heading.replace(/^#+ /, '')}"`); + }; + + shape('RunicNPC owner', '## Owners', Object.keys(runicnpcOwners), (s) => firstCellNames(s).flat()); + shape('RunicNpc_List key', '### `RunicNpc_List', Object.keys(runicnpcListKeys), (s) => firstCellNames(s).flat()); + + // A profile's fields. A cell may name several (`hurtByPlayers`, `hurtsPlayers`), and a bare + // name after a dotted one shares its prefix (`phases[].damageDealt`, `aimCone`). + shape( + 'RunicNPC profile field', + '### A profile', + Object.values(runicnpcProfileFields).flatMap((group) => Object.keys(group)), + (s) => + firstCellNames(s).flatMap(([first, ...rest]) => { + const prefix = first.includes('.') ? first.slice(0, first.lastIndexOf('.') + 1) : ''; + return [first, ...rest.map((n) => (n.includes('.') ? n : prefix + n))]; + }), + ); + + // A placement's keys: the example's, plus the optional ones its bullets introduce. + shape('RunicNPC placement key', '### A placement', Object.keys(runicnpcPlacementKeys), (s) => { + const keys = jsonKeysOf(s); + return keys && [...new Set([...keys, ...[...s.matchAll(/^- `(\w+)`/gm)].map((m) => m[1])])]; + }); + shape('RunicNPC route key', '### A route', Object.keys(runicnpcRouteKeys), jsonKeysOf); + + // The page itself: every call has exactly one , so "in full" is held + // by the build and not by whoever last edited api.mdx. + const page = readFileSync(path.join(ROOT, 'src/content/docs/docs/rust/runicnpc/api.mdx'), 'utf8'); + const onPage = [...page.matchAll(/ m[1]); + let covered = true; + for (const call of Object.keys(runicnpcCalls)) { + const n = onPage.filter((c) => c === call).length; + if (n !== 1) { + covered = false; + fail(`RunicNPC API page: ${call}`, `has ${n} on api.mdx — every call needs exactly one, with its prose`); + } + } + if (covered) ok(`RunicNPC API page covers every call (${onPage.length})`); +} + +/** Compare name → signature maps: both directions by name, then each shared name by signature. */ +function compareSignatures(label, mine, theirs, hint) { + compareSets(label, [...mine.keys()], [...theirs.keys()], hint); + let same = true; + for (const [name, sig] of mine) { + if (theirs.has(name) && theirs.get(name) !== sig) { + same = false; + fail(`${label} ${name}`, `signature differs — here "${sig}", ${hint} "${theirs.get(name)}"`); + } + } + if (same) ok(`${label} signatures (${mine.size})`); +} + +/** The text of a C# method, by brace counting from the first brace after its name. */ +function methodBody(source, name) { + const start = source.search(new RegExp(`\\b${name}\\s*\\(`)); + if (start < 0) return null; + const open = source.indexOf('{', start); + let depth = 0; + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}' && --depth === 0) return source.slice(open + 1, i); + } + return null; +} + +function checkRunicNpcSource(cs) { + const hint = 'runicnpc-rust main:plugin/RunicNPC.cs'; + + // The verbs: every `rnpc.` console command. The chat command answers the same verbs. + compareSets( + 'RunicNPC command', + [...new Set(Object.values(runicnpcCommands).flatMap((s) => Object.keys(s)).map((usage) => usage.split(' ')[0]))], + [...cs.matchAll(/\[ConsoleCommand\("rnpc\.(\w+)"\)\]/g)].map((m) => m[1]), + `${hint} [ConsoleCommand]`, + ); + + // A command's permission must be one the plugin registers. + const perms = [...cs.matchAll(/const\s+string\s+Perm\w+\s*=\s*"(runicnpc\.\w+)"/g)].map((m) => m[1]); + compareSets('RunicNPC permission', Object.keys(runicnpcPermissions), perms, `${hint} Perm constants`); + for (const [section, commands] of Object.entries(runicnpcCommands)) { + for (const [usage, c] of Object.entries(commands)) { + if (c.perm.startsWith('runicnpc.') && !perms.includes(c.perm)) { + fail(`RunicNPC command ${usage}`, `(${section}) needs "${c.perm}", which the plugin does not register`); + } + } + } + + const options = methodBody(cs, 'ParseOptions'); + if (!options) fail('RunicNPC place option', `could not find ParseOptions in ${hint}`); + else { + compareSets( + 'RunicNPC place option', + Object.keys(runicnpcPlaceOptions), + [...options.matchAll(/case\s+"(\w+)":/g)].map((m) => m[1]), + `${hint} ParseOptions`, + ); + } + + // The config: `[JsonProperty("name")] public Type Field`, and a field whose type is another + // config class is a section, its keys written `section.key`. + const jsonKeys = (className, prefix = '') => { + const body = classBody(cs, className); + if (!body) return null; + return [...body.matchAll(/\[JsonProperty\("(\w+)"\)\]\s*public\s+(\w+)\s+\w+/g)].flatMap(([, key, type]) => { + const nested = /Config$/.test(type) ? jsonKeys(type, `${prefix}${key}.`) : null; + return nested ?? [`${prefix}${key}`]; + }); + }; + const configKeys = jsonKeys('PluginConfig'); + if (!configKeys) fail('RunicNPC.json', `could not find class PluginConfig in ${hint}`); + else compareSets('RunicNPC.json', Object.keys(runicnpcConfig), configKeys, `${hint} PluginConfig`); +} + async function run() { if (!TOKEN) { console.error('checkReference: GITEA_TOKEN is not set. This check cannot run anonymously.'); @@ -287,6 +490,21 @@ async function run() { } } + // ── 10. RunicNPC: the API, against its specification ────────────────────── + // + // PLAN.md §16, D62 and D73. The one place this site carries a contract in full rather than + // linking to it, so the one place it must be held to the contract most tightly: every call + // and hook by its whole signature, and every field name of every data shape, both ways. + // `docs` `main:runicnpc/API.md` is normative, so it is the authority, not the plugin source. + await checkRunicNpcApi(await raw('docs', 'runicnpc/API.md')); + + // ── 11. RunicNPC: the commands, permissions, options and config ─────────── + // + // Names, checked against the plugin's own source the way RunicGateway.json is (check 8): + // the console verbs it registers, the permissions it registers, the `key=` options + // `place` and `here` parse, and the JSON keys of its config classes. + checkRunicNpcSource(await raw('runicnpc-rust', 'plugin/RunicNPC.cs')); + // ── Report ──────────────────────────────────────────────────────────────── if (failures.length === 0) { console.log(`checkReference: ${checked.length} enumeration check(s) passed against their sources.`); diff --git a/scripts/checkSidebar.mjs b/scripts/checkSidebar.mjs index e650f0d..d956d84 100644 --- a/scripts/checkSidebar.mjs +++ b/scripts/checkSidebar.mjs @@ -22,13 +22,15 @@ * No token and no network: both trees are in this repository. */ -import { docsSidebar, plannedSidebar } from '../src/config/sidebar.mjs'; +import { docsSidebar, plannedSidebar, flatLabels } from '../src/config/sidebar.mjs'; const failures = []; const fail = (what, detail) => failures.push({ what, detail }); -const live = new Map(docsSidebar.map((g) => [g.label, g.items.map((i) => i.label)])); -const planned = new Map(Object.entries(plannedSidebar)); +// A nested group (RunicNPC inside Rust, phase 16) flattens to `Group › Page` labels, so its +// pages and their order are held to the plan exactly as a top-level group's are. +const live = new Map(docsSidebar.map((g) => [g.label, flatLabels(g.items)])); +const planned = new Map(Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)])); // ── Groups ────────────────────────────────────────────────────────────────── for (const label of live.keys()) { diff --git a/scripts/checkTerms.mjs b/scripts/checkTerms.mjs index 45d953e..ecee8e8 100644 --- a/scripts/checkTerms.mjs +++ b/scripts/checkTerms.mjs @@ -48,7 +48,7 @@ import { readdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; -import { docsSidebar, plannedSidebar } from '../src/config/sidebar.mjs'; +import { docsSidebar, plannedSidebar, flatLabels } 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')); @@ -232,8 +232,8 @@ for (const [i, entry] of ALLOW.entries()) { // ── 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)], + ['docsSidebar', docsSidebar.map((g) => [g.label, flatLabels(g.items)])], + ['plannedSidebar', Object.entries(plannedSidebar).map(([label, items]) => [label, flatLabels(items)])], ]; let labels = 0; for (const [treeName, groups] of trees) { diff --git a/src/components/Signature.astro b/src/components/Signature.astro new file mode 100644 index 0000000..9ed1d5f --- /dev/null +++ b/src/components/Signature.astro @@ -0,0 +1,21 @@ +--- +import { Code } from '@astrojs/starlight/components'; +import { runicnpcCalls } from '../data/runicnpc.mjs'; + +/** + * One RunicNPC call's signature, printed from `src/data/runicnpc.mjs` (PLAN.md §16, D62). + * + * The API page writes each call's prose by hand and prints its signature through this, so the + * signature a reader copies is the one `scripts/checkReference.mjs` holds against + * `docs` `main:runicnpc/API.md`. The check also requires exactly one of these per call. + */ +interface Props { + call: keyof typeof runicnpcCalls; +} + +const { call } = Astro.props; +const sig = runicnpcCalls[call]; +if (!sig) throw new Error(`Signature: "${call}" is not in runicnpcCalls (src/data/runicnpc.mjs).`); +--- + + diff --git a/src/components/Ticks.astro b/src/components/Ticks.astro new file mode 100644 index 0000000..a4e1b23 --- /dev/null +++ b/src/components/Ticks.astro @@ -0,0 +1,35 @@ +--- +/** + * A short description from a data file, rendered inline (PLAN.md §16, phase 16). + * + * Reference data in `src/data/` is plain strings, because a check reads the same file. Three + * inline marks are understood, and nothing else: `code`, **bold**, and [a link](/path/). The + * text is never parsed as HTML, so a description cannot inject markup. + */ +interface Props { + text: string; +} + +const { text } = Astro.props; + +type Token = { kind: 'text' | 'code' | 'strong'; value: string } | { kind: 'link'; value: string; href: string }; + +const tokens: Token[] = []; +const pattern = /`([^`]+)`|\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g; +let last = 0; +for (const m of String(text ?? '').matchAll(pattern)) { + if (m.index > last) tokens.push({ kind: 'text', value: text.slice(last, m.index) }); + if (m[1] !== undefined) tokens.push({ kind: 'code', value: m[1] }); + else if (m[2] !== undefined) tokens.push({ kind: 'strong', value: m[2] }); + else tokens.push({ kind: 'link', value: m[3], href: m[4] }); + last = m.index + m[0].length; +} +if (last < text.length) tokens.push({ kind: 'text', value: text.slice(last) }); +--- + +{tokens.map((t) => + t.kind === 'code' ? {t.value} + : t.kind === 'strong' ? {t.value} + : t.kind === 'link' ? {t.value} + : t.value +)} diff --git a/src/config/sidebar.mjs b/src/config/sidebar.mjs index 1eeaca6..07a87bc 100644 --- a/src/config/sidebar.mjs +++ b/src/config/sidebar.mjs @@ -116,6 +116,20 @@ export const docsSidebar = [ { label: 'sidecar.toml', slug: 'docs/rust/sidecar-toml' }, { label: 'RunicGateway.json', slug: 'docs/rust/runicgateway-json' }, { label: 'Event kinds', slug: 'docs/rust/event-kinds' }, + // §16, D61/D72: RunicNPC is a subgroup of Rust, nested so it reads as part of the game. + { + label: 'RunicNPC', + items: [ + { label: 'RunicNPC', slug: 'docs/rust/runicnpc' }, + { label: 'Install RunicNPC', slug: 'docs/rust/runicnpc/install' }, + { label: 'Commands', slug: 'docs/rust/runicnpc/commands' }, + { label: 'NPC profiles', slug: 'docs/rust/runicnpc/npc-profiles' }, + { label: 'NPC placements', slug: 'docs/rust/runicnpc/npc-placements' }, + { label: 'Cost and caps', slug: 'docs/rust/runicnpc/cost-and-caps' }, + { label: 'Profile fields', slug: 'docs/rust/runicnpc/profile-fields' }, + { label: 'The API', slug: 'docs/rust/runicnpc/api' }, + ], + }, ], }, ]; @@ -211,5 +225,34 @@ export const plannedSidebar = { 'sidecar.toml', 'RunicGateway.json', 'Event kinds', + { + RunicNPC: [ + 'RunicNPC', + 'Install RunicNPC', + 'Commands', + 'NPC profiles', + 'NPC placements', + 'Cost and caps', + 'Profile fields', + 'The API', + ], + }, ], }; + +/** + * A group's items as flat labels, a nested group's pages written `Group › Page` after the + * group's own label. Both trees go through this, so `checkSidebar.mjs` compares nested + * groups as strictly as top-level ones, and `checkTerms.mjs` reads every label. + * + * In `docsSidebar` a nested group is `{ label, items }`; in `plannedSidebar` it is + * `{ Label: [pages] }`. + */ +export function flatLabels(items) { + return items.flatMap((item) => { + if (typeof item === 'string') return [item]; + if (Array.isArray(item.items)) return [item.label, ...flatLabels(item.items).map((l) => `${item.label} › ${l}`)]; + if ('label' in item) return [item.label]; + return Object.entries(item).flatMap(([label, pages]) => [label, ...flatLabels(pages).map((l) => `${label} › ${l}`)]); + }); +} diff --git a/src/content/docs/docs/reference/canonical-documents.mdx b/src/content/docs/docs/reference/canonical-documents.mdx index 52f9ac3..295c206 100644 --- a/src/content/docs/docs/reference/canonical-documents.mdx +++ b/src/content/docs/docs/reference/canonical-documents.mdx @@ -15,6 +15,10 @@ A documentation site that quietly re-specifies a contract becomes a second sourc and second sources of truth drift. Every page here links out for exactly this reason, and this page is the index of what it links to. +One page is a deliberate exception: [RunicNPC's API](/docs/rust/runicnpc/api/) is carried in +full, because its readers are plugin authors outside this project. Its every signature and field +name is checked against `runicnpc/API.md` on every build, and that document still wins. + If you find a disagreement, it is worth reporting — it means a check is missing. diff --git a/src/content/docs/docs/rust/events-zones-and-rewards.mdx b/src/content/docs/docs/rust/events-zones-and-rewards.mdx index f1cf864..98f594a 100644 --- a/src/content/docs/docs/rust/events-zones-and-rewards.mdx +++ b/src/content/docs/docs/rust/events-zones-and-rewards.mdx @@ -35,7 +35,7 @@ Picked from the step's **Action** list, beside core's own: |---|---|---| | **The world** | Open a zone | A ZoneManager zone, at a monument or coordinates, with flags from a [preset](#zone-presets), messages on entering and leaving, and optionally a dome | | | Place crates | Crates, barrels and supply drops | -| | Place NPCs | Rust's scientists and guards, or RunicNPC profiles — placed through RunicNPC | +| | Place NPCs | Rust's scientists and guards, or [RunicNPC profiles](/docs/rust/runicnpc/npc-profiles/) — placed through RunicNPC | | | Call a world event | Rust's own: a patrol helicopter, an airdrop, the cargo ship, a Bradley, the Travelling Vendor and the rest | | **Counting** | Start counting participants | A tally of who is present in a zone or on the server, by seconds present, kills, or both | | | Record participants | Reads the tally into the run, so later steps can reward from it | diff --git a/src/content/docs/docs/rust/index.mdx b/src/content/docs/docs/rust/index.mdx index c215f03..a8bb32d 100644 --- a/src/content/docs/docs/rust/index.mdx +++ b/src/content/docs/docs/rust/index.mdx @@ -66,7 +66,8 @@ NPC kills, coins and backpack. See [Linking Steam accounts](/docs/rust/linking-s | Rust mod config | [Mod config from the site](/docs/rust/mod-config/) | | Rust visibility | [Who sees who is online](/docs/rust/who-sees-who-is-online/) | | Rust zone presets | [Events, zones and rewards](/docs/rust/events-zones-and-rewards/) | -| Rust NPC profiles, Rust NPC placements | RunicNPC, which has its own pages | +| Rust NPC profiles | [NPC profiles](/docs/rust/runicnpc/npc-profiles/), in the RunicNPC subgroup | +| Rust NPC placements | [NPC placements](/docs/rust/runicnpc/npc-placements/), in the RunicNPC subgroup | The module also adds a **Rust** section to each user's page under **Admin → Users** (their linked Steam accounts, permissions, coins and backpack), Rust steps and triggers to core's @@ -98,3 +99,4 @@ every list, count and last-reported time comes from what the site stored. | [sidecar.toml](/docs/rust/sidecar-toml/) | Changing the sidecar's binds, token, store or history | | [RunicGateway.json](/docs/rust/runicgateway-json/) | Changing the bridge plugin's own settings and bounds | | [Event kinds](/docs/rust/event-kinds/) | Knowing what the server can tell the site, and who may see each | +| [RunicNPC](/docs/rust/runicnpc/) | Our NPC plugin: placing NPCs, their profiles, and its API for other plugins — eight pages of its own | diff --git a/src/content/docs/docs/rust/runicnpc/api.mdx b/src/content/docs/docs/rust/runicnpc/api.mdx new file mode 100644 index 0000000..335a9a3 --- /dev/null +++ b/src/content/docs/docs/rust/runicnpc/api.mdx @@ -0,0 +1,362 @@ +--- +title: The API +description: RunicNPC's API for other Oxide and Carbon plugins, in full — every call, every hook it raises, and the shapes of an NPC, a placement and a route. +--- + +import { Aside, Code } from '@astrojs/starlight/components'; +import platform from '../../../../../data/platform.json'; +import Signature from '../../../../../components/Signature.astro'; +import Ticks from '../../../../../components/Ticks.astro'; +import { + runicnpcOwners, + runicnpcHooks, + runicnpcListKeys, + runicnpcPlacementKeys, + runicnpcRouteKeys, +} from '../../../../../data/runicnpc.mjs'; + + + +This is **API version {platform.runicnpc.api}**, shipped in RunicNPC +{platform.releases['runicnpc-rust']}. RunicNPC works without Runic Gateway, and so does +everything on this page. + +## Calling it + +RunicNPC is called by name through `Call`, like any Oxide or Carbon plugin API. Every call is +prefixed `RunicNpc_`, so no hook of another plugin is ever matched by accident. + +("RunicNpc_ApiVersion") ?? 0;', + `if (api < ${platform.runicnpc.api}) { /* too old for this caller: say so */ }`, + '', + 'BasePlayer npc = RunicNPC.Call("RunicNpc_Spawn", position, "warden", "plugin:MyPlugin", null) as BasePlayer;', + ].join('\n')} +/> + +**The API version moves when a call or a raised hook changes shape**, not on every release. The +release's manifest carries it too, so an installer can refuse a RunicNPC too old for its caller +before it is loaded. Check it before relying on a call. + +**A call that cannot do what it was asked returns null, 0 or false, and says why in the server +log.** Calls that change data return the reason as a string instead, and null means success. + +## Owners + +Every NPC has an owner, and the owner decides how long it lives. + + + + + {Object.entries(runicnpcOwners).map(([owner, o]) => ( + + ))} + +
OwnerWho uses itLifetime
{owner}
+ +## Spawning and removing + +### RunicNpc_ApiVersion + + + +The API version: {platform.runicnpc.api}. + +### RunicNpc_Spawn + + + +Spawns one NPC now, or returns null. + +- `owner` must be `run:` or `plugin:`. +- `overrides` may change any [profile field](/docs/rust/runicnpc/profile-fields/) for this NPC + only, in the profile's own shape, or be null. For example: + `{"health": 500, "names": ["Captain"], "movement": {"mode": "route:gate", "radius": 0}}`. + Arrays are replaced, not merged, and the merged profile is checked like a saved one. +- A roamer must stand on Rust's navmesh, within 3 m across and 2 m up or down of `at`. A sentry + may stand anywhere. +- It is refused until the map's navmesh is built, which takes 8–10 minutes on a map's first boot. + +It is refused for an unknown or refused profile, a bad owner, an override that fails, a spot off +the navmesh, a cap an admin has set, or a navmesh not built yet. + +### RunicNpc_Despawn + + + +Removes one NPC by its net id, and returns 1, or 0 if it is not one of RunicNPC's. A placement's +NPC comes back after its respawn delay. **For a boss, it also removes the adds that boss +summoned**, even once the boss is dead, and counts them in what it returns: whoever placed the +boss knows only the boss's net id. + +### RunicNpc_DespawnOwner + + + +Removes every NPC of `owner`, and returns how many it removed. + +### RunicNpc_IsRunicNpc + + + +Whether `entity` is one of RunicNPC's NPCs. + +### RunicNpc_ProfileOf + + + +The name of its profile, or null. + +### RunicNpc_CostWarning + + + +The [cost warning](/docs/rust/runicnpc/cost-and-caps/) for the NPCs the server plans, plus +`adding`: what they add to a server frame, idle and fighting, and how slowly each then reacts. A +caller that adds NPCs should show it to whoever added them. + +## Listing NPCs + +### RunicNpc_List + + + +The live NPCs of `owner`, or of every owner when it is null. Each row has: + + + + + {Object.entries(runicnpcListKeys).map(([key, k]) => ( + + ))} + +
KeyTypeWhat it is
{key}{k.type}
+ +## Orders for one NPC + +### RunicNpc_Escort + + + +One of RunicNPC's NPCs escorts `target`: a player, or any other entity — a crate it guards, a +vehicle. It keeps within a few metres, never targets it, and fights whoever damages it. **When the +target dies, is destroyed or, for a player, leaves the server**, it walks back to its spot, +carries on as its profile does there, and `OnRunicNpcEscortEnded` is raised. A null `target` +stops it. Null on success; refused for a sentry, which never moves, and for a player who is not on +the server. + +### RunicNpc_Ally + + + +Allies one NPC to `clan` (the game's own clan id), `team` (a Rust team id) or `player` (a Steam +id: that player, and their team if they have one), or to nobody with `none`. It **never targets** +the ally's people, and **defends them and what they own** — building blocks, doors, deployables: +whoever damages one of them within its leash is fought. Rust's own clans and teams only. Null on +success; refused for a clan or team the server does not have. + +### RunicNpc_Tether + + + +Holds one NPC inside a ZoneManager zone: it never chases or wanders out, and if it ends up outside +— pushed, or the zone moved — it forgets the fight and walks home. A null `zone` frees it. Refused +for a sentry, on a server without ZoneManager, for a zone the server does not have, and for a zone +that does not hold the NPC. + +## Profiles and factions + +### RunicNpc_Profiles + + + +`{"managed": bool, "profiles": {name: profile, …}, "factions": [pair, …], "refused": {name: reason, …}}`. +`managed` is whether a site manages this server. A refused profile is kept in the file but never +spawns — a kit the server lacks, for example. `factions` is the faction table. Every field of a +profile is on [Profile fields](/docs/rust/runicnpc/profile-fields/). + +### RunicNpc_SetProfiles + + + +Replaces the whole profile set with `all` (`{name: profile, …}`), as a site does on every push, +and **marks the server managed**: from then on, the console commands that edit profiles are +refused. It returns the profiles refused and why; the rest are in use at once. A placement whose +profile has gone waits, and spawns again when the profile returns. + +### RunicNpc_Factions + + + +The faction table: `[{"a": "bandits", "b": "guards", "relation": "hostile"}, …]`, one row per +pair, meaning both ways. A pair not listed is neutral, and a faction is always allied to itself. + +### RunicNpc_SetFactions + + + +Replaces the faction table, as a site does with every push, and **marks the server managed**. +Null on success; otherwise the first problem, and the old table is kept. Refused: a pair of one +faction with itself; a pair of `scientists` and `animals`, because RunicNPC does not change how +Rust's own NPCs treat each other; a relation other than `hostile`, `neutral` or `allied`; and a +pair given twice, in either order. + +## Placements + +A **placement** is a spot where a profile's NPCs stand and come back after they die. It is saved; +its NPCs never are. Its keys: + + + + + {Object.entries(runicnpcPlacementKeys).map(([key, what]) => ( + + ))} + +
KeyWhat it is
{key}
+ +```json +{ + "profile": "warden", + "position": { "x": 100.0, "y": 12.5, "z": -340.0 }, + "yaw": 90, + "count": 3, + "respawn": 300, + "respawnMode": "each", + "movement": { "mode": "route:gate", "radius": 0 } +} +``` + +A placement whose profile or zone has gone **waits**, and spawns again when it returns. **If the +ground under a placement goes later** — a player-built floor destroyed — a live NPC is put on the +nearest navmesh within 2 s, and the placement respawns its NPCs on the nearest navmesh until its +spot is walkable again. Rust itself would leave such an NPC standing in the air. + +### RunicNpc_Placements + + + +Each placement as `{id, placement, alive, waiting, lastError, note}`: `placement` is its JSON, +`alive` how many of its NPCs are alive, and `waiting` why it cannot spawn now, or null. `note` is +set while its NPCs stand somewhere other than its spot because the ground there went, and null +otherwise. + +### RunicNpc_AddPlacement + + + +**Creates a placement and names it as `/rnpc place` does**: the profile's name and the first free +number, `warden-3`. It is checked and refused exactly as `/rnpc place` is, with the same +sentences, so a placement made by another plugin or a website and one made in game never disagree. + +- `placement` has no id. **A `position` without `y` is a point on the map**: it is put on the + ground there by a ray down onto terrain and rock, never a building or a tree, then checked + against the navmesh like any other. A point off the map, or under water, is refused. +- The answer is `{id, position, built, cost}` — the name given, where it landed, whether that spot + is on something players built, and the cost warning — or `{error}`, the sentence `/rnpc place` + would have said. + +### RunicNpc_SetPlacement + + + +Adds or replaces a placement with the id you give. Null on success, or the reason. Replacing one +removes its NPCs and spawns them again from the new values. Every set logs the cost warning. + +A roamer's spot must be on Rust's navmesh, within 2 m up or down, when it is set; otherwise the +answer says how far off it is. The check is skipped while the map's navmesh is still being built, +and for a profile that does not exist yet. + +### RunicNpc_RenamePlacement + + + +Renames a placement, as `/rnpc rename` does: its live NPCs keep living under the new name. Null on +success, or why not — no such placement, the name taken, or not a name. + +### RunicNpc_RespawnPlacement + + + +Removes a placement's NPCs and spawns them again at once, as `/rnpc respawn` does. Returns how +many, or `-1` if there is no such placement. + +### RunicNpc_RemovePlacement + + + +Removes a placement and its NPCs. + +## Routes + +A route is a line of points an NPC walks. An NPC joins a route at its nearest point. + + + + + {Object.entries(runicnpcRouteKeys).map(([key, what]) => ( + + ))} + +
KeyWhat it is
{key}
+ +```json +{ "points": [ { "x": 1, "y": 2, "z": 3 }, { "x": 4, "y": 2, "z": 6 } ], "loop": true } +``` + +### RunicNpc_Routes + + + +Every route, as `{name: route, …}`. + +### RunicNpc_SetRoute + + + +Adds or replaces a route. Every point must be on Rust's navmesh. Null on success, or the reason. +**Unlike `/rnpc path`, it does not check that an NPC can walk from each point to the next**: a leg +Rust cannot path leaves the NPC standing at the last point it reached, so a caller that builds +routes should check its own legs. + +### RunicNpc_RemoveRoute + + + +Removes a route. Placements that walk it wait until it exists again. + +## Hooks it raises + +Every hook is called on every plugin, and **none of them answers**: a return value is ignored. + + + + + {Object.entries(runicnpcHooks).map(([hook, h]) => ( + + ))} + +
HookWhen
{`${hook}(${h.params})`}
+ +## Its files + +`data/RunicNPC/profiles.json` (the profiles, the faction table and whether a site manages them), +`placements.json` and `routes.json`, under `oxide/` or `carbon/`. From a plugin, change them +through the calls above rather than the files. See [Install RunicNPC](/docs/rust/runicnpc/install/#its-files) +for every file and who writes it. + + diff --git a/src/content/docs/docs/rust/runicnpc/commands.mdx b/src/content/docs/docs/rust/runicnpc/commands.mdx new file mode 100644 index 0000000..f2300d7 --- /dev/null +++ b/src/content/docs/docs/rust/runicnpc/commands.mdx @@ -0,0 +1,104 @@ +--- +title: Commands +description: Every /rnpc chat command and rnpc.* console command RunicNPC answers, the two permissions they need, and the options for placing NPCs. +--- + +import Ticks from '../../../../../components/Ticks.astro'; +import { + runicnpcCommands, + runicnpcPermissions, + runicnpcPlaceOptions, +} from '../../../../../data/runicnpc.mjs'; + +export const Commands = ({ section }) => ( + + + + {Object.entries(runicnpcCommands[section]).map(([usage, c]) => ( + + + + + + ))} + +
CommandDoesNeeds
{c.console ? `rnpc.${usage}` : `/rnpc ${usage}`}{c.perm.startsWith('runicnpc.') ? {c.perm} : c.perm}
+); + +## How commands are reached + +- **In chat:** `/rnpc …`. `/rnpc` alone lists the verbs you are allowed to use. +- **In a console:** `rnpc. …`, in a player's F1 console, the server console or RCON. The + same code answers both, with the same permissions. +- **Four verbs answer only in a console** — `profile`, `faction`, `status` and `reload`. In chat + they tell you to open F1. +- **The server console has every permission.** It has no position, so placing there takes `at=` + and `yaw=` instead of where you look. + +The verbs, the permissions and the placing options on this page are checked against the plugin's +own source on every build. + +## Permissions + + + + + {Object.entries(runicnpcPermissions).map(([perm, grants]) => ( + + ))} + +
PermissionGrants
{perm}
+ +On a Runic Gateway server both show in **Admin → Rust permissions** like any plugin's, and the +site grants them there — see [Permissions and groups](/docs/rust/permissions-and-groups/). + +## Placements + +A **placement** is a spot where a profile's NPCs stand and come back after they die. It is saved +on the server and survives restarts. Placements made in game are named after their profile and a +number, `bandit-1`, and can be renamed. The site's [placements screen](/docs/rust/runicnpc/npc-placements/) +edits the same list. + + + +### Options for `place` and `here` + +`key=value`, in any order: `/rnpc place raider count=3 respawn=600 mode=group`. + + + + + {Object.entries(runicnpcPlaceOptions).map(([key, o]) => ( + + ))} + +
OptionMeansDefault
{key}=
+ +**Every placement is checked against Rust's navmesh.** A roamer must stand on it; only a sentry, +or a passive NPC that stands, may stand off it. On a player-built floor the placement is made with +a warning: if the floor is destroyed, the NPCs fall back to the nearest navmesh, and return to +their spot once it is rebuilt. Every command that adds NPCs answers with the +[cost warning](/docs/rust/runicnpc/cost-and-caps/). + +## Routes + +A route is a line of points an NPC walks, recorded in game where you walk, one point at a time. +A recording is kept in memory only: a reload of RunicNPC discards it. A placement walks one with +`move=route:`. + + + +## At the console + +**On a Runic Gateway server the site manages the profiles and the faction table.** There, +`rnpc.profile create`, `set` and `delete`, and `rnpc.faction set` and `clear`, are refused with +"managed by its website"; `show` and `list` still answer. On a server with no site, they are how +profiles are made: see [Profile fields](/docs/rust/runicnpc/profile-fields/) for every field +`set` takes. + + + +A boss block or a press-E block is added with its defaults by `set boss on` or +`set use on`, and removed with `none`. Their fields are then set one at a time — +`boss.spawnLine The Juggernaut has risen!` — and a list of objects as JSON: +`boss.phases [{"at":0.5,"line":"…"}]`. Setting `role passive` also turns `hurtByPlayers` off. diff --git a/src/content/docs/docs/rust/runicnpc/cost-and-caps.mdx b/src/content/docs/docs/rust/runicnpc/cost-and-caps.mdx new file mode 100644 index 0000000..25be654 --- /dev/null +++ b/src/content/docs/docs/rust/runicnpc/cost-and-caps.mdx @@ -0,0 +1,79 @@ +--- +title: Cost and caps +description: What RunicNPC's NPCs cost a Rust server, measured rather than guessed, the warning that says so wherever NPCs are added, and the caps an operator can set. +--- + +import Ticks from '../../../../../components/Ticks.astro'; +import { runicnpcConfig } from '../../../../../data/runicnpc.mjs'; + +**RunicNPC has no caps by default.** Instead, everything that adds NPCs answers with what the +server's RunicNPC NPCs will then cost: `/rnpc place`, the site's +[placements screen](/docs/rust/runicnpc/npc-placements/), an event's *Place NPCs* step, the API's +`RunicNpc_CostWarning`, and the end of `rnpc.status`. A server's owner knows their hardware and +their players; a number someone else picked would be wrong for most of them. + +## The cost warning + +It reads like this: + +``` +RunicNPC: 100 NPC(s) on this server. Measured on a test server, they add about 3 ms to every +server frame while idle with a player near enough to wake them, and about 0.5 ms once they sleep +with nobody near. If all fight at once they add about 9 ms, and Rust's shared 2 ms AI budget then +lets each react only every 3 s. … +``` + +What it means: + +- **Idle NPCs are cheap, and sleeping ones cheaper.** NPCs that stand, wander or patrol with + nobody to fight add a little to a frame. One with no player within its sleep distance — 160 m + unless its profile says otherwise — walks home and sleeps, and then costs about a sixth of what + it did awake. +- **Fighting NPCs hit Rust's AI budget before the frame rate.** All of Rust's human NPCs, its own + scientists included, share one AI budget of 2 ms a frame (`aithinkmanager.framebudgetms`). With + many fighting at once, each reacts less often. The server stays smooth, and the NPCs get slower + to react. +- **Fighting other NPCs costs about what fighting players does.** That is a faction setting, and + off unless a profile opts in. On a map whose own AI is busy, each then reacts less often still; + the warning says by how much once any profile fights NPCs. + +## Where the figures come from + +They are measured, not guessed: 100 of the released NPC on both frameworks, on a 6000 map, as what +they add to the median server frame of the same session's empty server. Beside them is Rust's own +scientist, measured the same day. + +| 100 NPCs, over an empty server | RunicNPC on Oxide | RunicNPC on Carbon | Rust's scientist (Oxide / Carbon) | +|---|---|---|---| +| Idle, a player near enough to keep them awake | +2.7 to +2.9 ms | +1.5 ms | +1.0 / +1.0 ms | +| Idle, asleep with nobody within 160 m | — | +0.5 ms | — | +| Fighting 20 players | +7.2 ms | +9.0 ms | +7.5 / +8.0 ms | + +**Fighting costs what Rust's own AI costs.** The warning uses the worst figure in each row, +rounded up: 3 ms awake, 0.5 ms asleep and 9 ms fighting, per 100. The awake figure on Oxide is +higher than it should be, and is being worked on in +[runicnpc-rust#13](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/issues/13). Your +server's own figures depend on its CPU, its map and its players. + +## Caps + +To cap them anyway, set any of these in `config/RunicNPC.json` (Carbon: `configs/RunicNPC.json`) +and reload RunicNPC. Every cap is 0, meaning none, until you set it. + + + + + {Object.entries(runicnpcConfig).map(([key, what]) => ( + + ))} + +
KeyWhat it limits
{key}
+ +These keys are checked against the plugin's own source on every build. + +**A spawn over a cap is refused, naming the cap**, whether it came from `/rnpc`, the site, an +event step or another plugin. + +**The bridge has its own bound too.** On a Runic Gateway server, `EventsMaxNpcs` in the bridge +plugin's [RunicGateway.json](/docs/rust/runicgateway-json/) limits how many NPCs one event step may +place — whatever RunicNPC's caps allow. diff --git a/src/content/docs/docs/rust/runicnpc/index.mdx b/src/content/docs/docs/rust/runicnpc/index.mdx new file mode 100644 index 0000000..5dad313 --- /dev/null +++ b/src/content/docs/docs/rust/runicnpc/index.mdx @@ -0,0 +1,75 @@ +--- +title: RunicNPC +description: Runic Gateway's own NPC plugin for Rust — what it is, how it works with and without a Runic Gateway site, and where each page about it fits. +--- + +import platform from '../../../../../data/platform.json'; + +**RunicNPC** is an NPC plugin for Rust, on Oxide and Carbon, written by this project. It puts +NPCs where you want them, with the kit, health, aim, ranges and behaviour you give them, and +brings them back after they die. It is one file, `RunicNPC.cs`, compiled by the framework from +source like any uMod plugin. + +| | | +|---|---| +| **Release** | {platform.releases['runicnpc-rust']}, from [`runicnpc-rust`](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/releases) | +| **API version** | {platform.runicnpc.api} | +| **Runs on** | Oxide {platform.runicnpc.oxideMin} or Carbon {platform.runicnpc.carbonMin}, or newer | +| **Needs** | {platform.runicnpc.requiresPlugins.join(' and ')}. ZoneManager is optional, for holding NPCs inside a zone | + +## Two ways to run it + +**On its own.** RunicNPC runs on any Rust server, with no Runic Gateway site anywhere. You place +NPCs in game with `/rnpc`, edit their profiles at the console, and other plugins drive it through +its [API](/docs/rust/runicnpc/api/). Nothing on this page beyond this section is needed to use it +that way. + +**On a Runic Gateway server.** There it is **required**, and installed with the bridge from the +same bundle. Every NPC an event places is RunicNPC's, Rust's own scientists included. The site +then owns the NPC **profiles** and the **faction table**, and pushes them to each server; the +console commands that would edit them are refused with "managed by its website". **Placements +stay the server's**: they are made in game or from the site's map, and both see the same list. + +## What it is made of + +- **A profile** is a named description of an NPC: its names and kits, its role, how it moves, how + hard it is, whom it fights, and optionally a boss's phases, what pressing E on it does, and its + own loot. See [Profile fields](/docs/rust/runicnpc/profile-fields/). +- **A placement** is a spot where a profile's NPCs stand and come back after they die. It is saved + on the server and survives restarts. **The NPCs themselves are never saved**: at boot, each + placement spawns fresh ones. +- **A route** is a line of points, recorded in game, that an NPC walks. +- **An owner.** Every NPC has one, and the owner decides how long it lives: a placement, an event + run, or another plugin. An event's NPCs go when the event is torn down, and another plugin's go + when it unloads. + +**The roles.** A *roamer* moves, chases and fights. A *sentry* stands still and shoots. A *guard* +holds its spot, chases within its leash and walks back. A *passive* NPC never fights, and pressing +E on it says a line or opens a window — a quest-giver or a quartermaster. Any role but passive can +be a **boss**: a health bar at the top of the screen, phases as its health falls, and lines said +in game. + +**Factions.** With no faction settings an NPC fights players only. Profiles can be put on sides, +made hostile to Rust’s scientists or animals, or to each other; through the API, one NPC can +also be allied to a clan, a team or a player, and defend what they own. + +**On a Runic Gateway site** RunicNPC's NPCs also show up elsewhere: their names in a server’s +[feed](/docs/rust/), NPC kills on the leaderboard, bosses and event NPCs on the +[live map](/docs/rust/the-live-map/), and a step's "3 of 8 left" on a running event's page. An +event places them with its *Place NPCs* step and can wait on them dying or on a boss's health; see +[Events, zones and rewards](/docs/rust/events-zones-and-rewards/). + +**It never phones home.** RunicNPC makes no network call, downloads nothing and scans nothing at +boot. + +## In this group + +| Page | Read it when | +|---|---| +| [Install RunicNPC](/docs/rust/runicnpc/install/) | Putting it on a server, with or without Runic Gateway, and after a Rust update | +| [Commands](/docs/rust/runicnpc/commands/) | Placing NPCs and recording routes in game, and the console | +| [NPC profiles](/docs/rust/runicnpc/npc-profiles/) | Authoring profiles and the faction table on the site | +| [NPC placements](/docs/rust/runicnpc/npc-placements/) | Placing NPCs from the site's map | +| [Cost and caps](/docs/rust/runicnpc/cost-and-caps/) | Knowing what NPCs cost a server, and capping them | +| [Profile fields](/docs/rust/runicnpc/profile-fields/) | Every field of a profile, for the editor, the console and the API alike | +| [The API](/docs/rust/runicnpc/api/) | Writing a plugin that spawns, orders or listens to RunicNPC's NPCs | diff --git a/src/content/docs/docs/rust/runicnpc/install.mdx b/src/content/docs/docs/rust/runicnpc/install.mdx new file mode 100644 index 0000000..db74368 --- /dev/null +++ b/src/content/docs/docs/rust/runicnpc/install.mdx @@ -0,0 +1,115 @@ +--- +title: Install RunicNPC +description: Putting RunicNPC on a Rust server — from the Runic Gateway bundle, or by hand on a server with no site — its files, and what to check after a Rust update. +--- + +import { Aside, Code } from '@astrojs/starlight/components'; +import platform from '../../../../../data/platform.json'; + +## What it needs + +| | | +|---|---| +| **Oxide {platform.runicnpc.oxideMin} or Carbon {platform.runicnpc.carbonMin}**, or newer | The builds it is known good on. Its release manifest states them, and the installer checks them | +| **[Kits](https://umod.org/plugins/kits)** | **Required.** A profile equips its NPCs with Kits kits, and RunicNPC declares `// Requires: Kits`, so neither framework loads it without Kits | +| **[ZoneManager](https://umod.org/plugins/zone-manager)** | Optional. Only holding NPCs inside a zone uses it | + +**Kits and ZoneManager are reported, never installed.** The installer's `doctor` says when either +is missing; you install them from uMod. + + + +## On a Runic Gateway server + +There is nothing to do by hand. RunicNPC is part of the Rust bundle — {platform.games.rust.bundle.tag} +carries RunicNPC {platform.games.rust.bundle.runicnpc} — beside the bridge plugin and the sidecar, +and it arrives the way they do. See [Install the bridge](/docs/rust/install-the-bridge/). + +- **The installer** places `RunicNPC.cs` beside the bridge plugin, checked against the bundle's + checksum, and refuses a bundle without it. `doctor --game rust` **fails** when RunicNPC is + missing, and warns when the file was edited by hand. `uninstall` removes `RunicNPC.cs` and keeps + its data. +- **The Pterodactyl egg** places it the same way on every install and reinstall. + +Once the server has started, **Admin → Rust servers** shows it as complete. A server without +RunicNPC, or with one older than the bridge needs, reads *Incomplete* with the reason, and every +*Place NPCs* step on it is refused. **Admin → Rust NPC profiles** lists each server's RunicNPC +version and API. + +**The first push adopts what the server had.** When the site first pushes profiles to a server, +the profiles that server already had become site profiles for that server alone, so nothing on it +changes. See [NPC profiles](/docs/rust/runicnpc/npc-profiles/). + +## Without Runic Gateway + +1. Download `runicnpc-.tar.gz` and `SHA256SUMS` from the + [releases page](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/releases), and check + the tarball: `sha256sum -c SHA256SUMS`. +2. Install Kits from uMod if the server does not have it. +3. Copy `runicnpc/RunicNPC.cs` from the tarball into `oxide/plugins/` or `carbon/plugins/`. The + framework compiles and loads it at once. +4. At the server console, run `rnpc.status`. It should say `navmesh=ready` — a fresh map builds + its navmesh for several minutes after its first boot — and `swap fields: … missing=- added=-`. +5. Grant `runicnpc.place` or `runicnpc.admin` to whoever places NPCs, then `/rnpc place raider` + where you look. + +**It comes with examples.** On its first load RunicNPC writes four example kits into Kits' data +file — `rnpc_raider`, `rnpc_campguard`, `rnpc_sniper` and `rnpc_juggernaut`, each where its name is +free and each behind a Kits permission nobody is granted, so no player can claim one. On a server +no site manages it also adds four example profiles: a raider, a camp guard, a sniper and the +Juggernaut, a boss. It then reloads Kits once so the new kits are known. **Nothing is written +again**, even if you delete them. + + + +## Its files + +Under `oxide/`, or `carbon/` on Carbon. + +| File | What it holds | Who writes it | +|---|---|---| +| `plugins/RunicNPC.cs` | The plugin | The installer, the egg, or you | +| `config/RunicNPC.json` (Carbon: `configs/`) | The caps and the spawn budget — see [Cost and caps](/docs/rust/runicnpc/cost-and-caps/) | You | +| `data/RunicNPC/profiles.json` | The profiles and the faction table, and whether a site manages them | The site, on a Runic Gateway server; otherwise `rnpc.profile` and `rnpc.faction` | +| `data/RunicNPC/placements.json` | The placements | `/rnpc`, the API, and the site's map | +| `data/RunicNPC/routes.json` | The recorded routes | `/rnpc path` | +| `data/RunicNPC/state.json` | Its own bookkeeping: whether the examples were written | RunicNPC | + +After editing `profiles.json` or `routes.json` by hand, run `rnpc.reload`. + +## After a Rust update + +Rust's monthly update can change the scientist RunicNPC is built on. After it: + +1. **Wait for the navmesh.** `rnpc.status` says `navmesh=building` while Rust rebuilds it, about + ten minutes on a large map. Placements wait, and spawn when it is ready; a spawn through the + API is refused until then. +2. **Check the swap.** The `swap fields` line should end `missing=- added=-`. Anything else means + Rust's scientist has changed since this RunicNPC was released: update RunicNPC. On a Runic + Gateway server, `update --game rust` or an egg reinstall takes the current bundle. +3. **Check the hooks.** `rnpc.status` names any hook that has never fired as `silent`. One that + stays silent long after players have been on is the first sign Rust renamed it, because + neither framework reports a hook that matches nothing. + +In the week before each monthly forced wipe, every new Rust staging build is tried against +RunicNPC, so a release that needs fixing is usually out before the update is. + +## Reading `rnpc.status` + + + +The rest of its answer lists the profiles it refuses and why, the placements waiting and why, the +faction pairs, the caps, and the [cost warning](/docs/rust/runicnpc/cost-and-caps/). diff --git a/src/content/docs/docs/rust/runicnpc/npc-placements.mdx b/src/content/docs/docs/rust/runicnpc/npc-placements.mdx new file mode 100644 index 0000000..a4ba3bb --- /dev/null +++ b/src/content/docs/docs/rust/runicnpc/npc-placements.mdx @@ -0,0 +1,68 @@ +--- +title: NPC placements +description: Placing RunicNPC's NPCs from the site's live map — how a click becomes a placement, and editing, renaming, respawning and removing what is already placed. +--- + +**Admin → Rust NPC placements** shows one server's RunicNPC placements on its live map, and +places new ones where you click. **Placements live on the server**, not on the site: an admin's +`/rnpc place` in game and this screen edit the same list, and both see each other's at once. So +every read and write here is a live round trip to the game, and a server that is down has nothing +to show. + +A server appears in the **Server** list once its RunicNPC can take placements from the site. If +none can yet, the screen says why for each. + +## Placing NPCs + +**Click the map where they should stand.** The site sends the point — a spot on the map, with no +height — and the server does the rest, exactly as `/rnpc place` would: + +- it **puts the point on the ground**, by a ray down onto terrain and rock — never a building or a + tree; +- it **checks the spot against Rust's navmesh**, and refuses a roamer's spot that is off it, with + how far off it is; +- it refuses a point off the map or under water; +- it **names the placement** after its profile and the first free number, `raider-3`. + +The form under the map takes what `/rnpc place` takes: + +| Field | Means | +|---|---| +| **Profile** | The profiles this server is pushed — see [NPC profiles](/docs/rust/runicnpc/npc-profiles/) | +| **How many** | How many NPCs stand there; all but the first are scattered within 3 m | +| **Respawn (s)** | Seconds after a death before an NPC comes back | +| **Come back** | *each*, after its own death, or *together*, once all are dead | +| **Movement** | The profile's own, `wander`, `monument`, or one of the server's recorded routes. Not shown for a sentry | +| **Radius (m)** | How far a wanderer strays | +| **Keep inside zone** | A ZoneManager zone its NPCs never leave. The server refuses a zone it does not have, or one that does not hold the spot | + +**The answer** says the placement's name, where it landed and how high, and the +[cost warning](/docs/rust/runicnpc/cost-and-caps/) for the NPCs the server will then have. If the +spot is on something players built, it says so: if that is destroyed, the NPCs stand on the +nearest navmesh until it is rebuilt. + +**A roof or the top of a building is placed in game.** The map only knows the ground; stand on +the roof and use `/rnpc place` or `/rnpc here`. + +## What is already placed + +Each placement is a pin on the map — yellow, or grey while it waits — and a row in **Placements**: +its name, how many of which profile, how many are alive, the respawn, the movement, its zone and +where it is. Above the list is the cost warning for everything this server runs now. + +| Button | Does | +|---|---| +| **Edit** | Change any of the form's fields. Its NPCs are removed and spawn again from the new values | +| **Rename** | Give it a new name. Its live NPCs keep living under it | +| **Respawn** | Remove its NPCs and spawn them again at once | +| **Remove** | Remove the placement and its NPCs, after a second click to confirm | + +**A placement can wait.** Its row says why in red — *profile 'guard' is missing*, for example, +because the profile was deleted or is no longer pushed to this server; or its route or zone has +gone; or the map's navmesh is still being built after an update. A waiting placement spawns again +by itself once the thing it waits for is back. A grey note under a row says its NPCs stand +somewhere other than their spot because the ground there went; *Last spawn failed* gives the last +error. + +**Recording a route is done in game**, with `/rnpc path` — see +[Commands](/docs/rust/runicnpc/commands/#routes). Once saved, it is offered in **Movement** here. diff --git a/src/content/docs/docs/rust/runicnpc/npc-profiles.mdx b/src/content/docs/docs/rust/runicnpc/npc-profiles.mdx new file mode 100644 index 0000000..e0568d0 --- /dev/null +++ b/src/content/docs/docs/rust/runicnpc/npc-profiles.mdx @@ -0,0 +1,110 @@ +--- +title: NPC profiles +description: Authoring RunicNPC's profiles and faction table on the site — which servers each profile is for, how the site pushes them, what the first push adopts, and the editor's sections. +--- + +import { Aside } from '@astrojs/starlight/components'; + +**Admin → Rust NPC profiles** is where a Runic Gateway site authors RunicNPC's profiles. There is +no in-game profile editor: on a server a site manages, the profiles and the faction table are the +site's, and the console commands that would change them are refused. Where NPCs stand is a +separate question, answered on [NPC placements](/docs/rust/runicnpc/npc-placements/), in game with +`/rnpc place`, or by an event's *Place NPCs* step. + +The screen has four parts: the servers, the profiles, the faction table, and — once there are +any — the profiles a first push replaced. + +## Servers + +One row per Rust server: its RunicNPC version and API, and when the site last pushed to it, or +why the last push failed. A server without RunicNPC, or with one too old to be managed, shows the +reason instead and is left alone; its events keep Rust's own scientists. + +**The site pushes on its own.** Every half-minute it asks each server whether it needs a push — +the first one, a change to its set, a restart, a failure worth retrying, or a quarter-hourly +audit — and pushes only then. A saved profile reaches each server within about half a minute. +**Push now** does it at once, and says what happened: how many profiles went, what was adopted, +and any RunicNPC refused, with the reason. + +**A refusal shows under its server.** A push replaces the server's whole set, and RunicNPC keeps +every profile it can accept: one it refuses — a kit that server lacks, say — is listed under the +server in red, and the rest are in use at once. + +## The first push adopts what the server had + +A server that ran RunicNPC before the site managed it already has profiles, and placements that +use them. So **before the first push, the site reads that server's own profiles and imports them +as profiles for that server alone**, then pushes them back. Nothing on the server changes, and its +placements keep spawning. An adopted profile says *adopted from* its server in the list. + +Where a site profile of the same name already covered that server, the site's wins, and the +server's own is kept under **Replaced**, with **Restore** to put it back in use on its server and +**Delete** to drop it. + +## Profiles + +Each row shows a profile's label and RunicNPC name, the servers it is for, its role, whether it is +a boss or has its own loot, its health, kits and movement, and how its kills count. **New profile** +and **Edit** open the editor below the list. + +### Which servers + +A profile is for **every server** — including servers added later — or for the ones ticked. Each +server is pushed only the profiles that cover it, so two servers can each have a different +`warden`. + +### The editor + +**Name** is RunicNPC's name for the profile — 1 to 40 of a-z, 0-9, `_` and `-` — and what +`/rnpc place` and an event step use. The rest is in sections, each a group of +[profile fields](/docs/rust/runicnpc/profile-fields/), which says what every field does and its +limits: + +| Section | What it sets | Shown | +|---|---|---| +| **Looks** | Names, kits, prefab and role | Always | +| **Moving** | How it moves, its wander radius, and when it sleeps | Not for a sentry, which never moves | +| **Fighting** | Health, damage dealt and taken, aim, vision, ranges, and the health thresholds an event can wait for | Always | +| **Press E** | A line in the player's chat, or a window | A passive profile only | +| **Boss** | The health bar, the lines said in game, and the phases | Any role but passive | +| **Loot** | What the corpse starts with, the rows always rolled, the pool, and the crate | Always; off keeps Rust's scientist loot | +| **Sides** | Faction, its own exceptions, alert radius, turrets, and whether players and it can hurt each other | Always | +| **The kit's extras** | Healing, grenades, melee, rockets and the flamethrower | Always | + +**Choosing *Passive* changes two other fields** for you: it stops players hurting the NPC and +makes it stand where it is placed. Choosing another role again sets them back. + +**The editor speaks in friendlier units than the file.** Health thresholds, a loot row's chance +and a phase's *At health* are percentages here, and a locked crate's hack time is in minutes; the +file and the API hold fractions and seconds. The site converts both ways. + +**Kits are checked when you save.** Every server the profile is for that answers is asked for +its kits, and a kit any of them lacks refuses the save, naming the server and the kit. A server +that does not answer is left to RunicNPC, which refuses the profile there at the next push. + +**Kills count** is the site's, not RunicNPC's: how the leaderboard and chat titles count this +profile's kills. + +| Choice | Counts | +|---|---| +| **Per server** (the default) | Kills of this name on the server a leaderboard is for | +| **Every server with this name** | Kills of this name on every server | +| **This profile only** | Kills of this profile only, on whichever servers it is pushed to | + +**Delete profile** removes it from every server it covered at the next push. Placements that use +it **wait** rather than vanish, and spawn again if a profile of that name comes back. + +## The faction table + +How factions treat each other, for the whole site, pushed to every server with the profiles. One +row is both ways: *bandits ↔ guards: hostile* makes each hunt the other. A pair that is not listed +is neutral, and a faction is always allied to itself. `scientists` and `animals` are Rust's own; a +profile hostile to scientists fights them, and they only take cover. A profile's own exceptions, +set in its **Sides**, win over the table. + + diff --git a/src/content/docs/docs/rust/runicnpc/profile-fields.mdx b/src/content/docs/docs/rust/runicnpc/profile-fields.mdx new file mode 100644 index 0000000..fdb8c10 --- /dev/null +++ b/src/content/docs/docs/rust/runicnpc/profile-fields.mdx @@ -0,0 +1,152 @@ +--- +title: Profile fields +description: Every field of a RunicNPC profile, by its JSON path — the same path the site's editor, rnpc.profile set, the data file and the API all use — with its limits and what it does. +--- + +import Ticks from '../../../../../components/Ticks.astro'; +import { runicnpcProfileFields } from '../../../../../data/runicnpc.mjs'; + +export const Fields = ({ group }) => ( + + + + {Object.entries(runicnpcProfileFields[group]).map(([path, f]) => ( + + + + + + + ))} + +
FieldOn the siteAllowedWhat it does
{path}{f.site ?? not in the editor}
+); + +A profile is a named description of an NPC. Its fields are the same wherever a profile is +written, so this one page serves all four places: + +- **The site's [profile editor](/docs/rust/runicnpc/npc-profiles/)**, whose sections this page + follows, and whose label for each field is in the second column. +- **The console**, where `rnpc.profile set ` takes the path in the first + column: `rnpc.profile set warden ranges.sense 40`. +- **The data file**, `data/RunicNPC/profiles.json`, on a server no site manages. +- **[The API](/docs/rust/runicnpc/api/)**: `RunicNpc_SetProfiles` takes profiles in this shape, + and `RunicNpc_Spawn`'s overrides change any of these fields for one NPC. + +The paths are checked against the API's specification on every build. **The site's editor shows +some values in friendlier units**: health thresholds, a loot row's chance and a phase's health as +percentages, and the hack time in minutes. Everywhere else they are fractions and seconds. + +**A profile is checked whole when it is saved.** A kit the server does not have refuses the +profile then, not when an NPC spawns; a refused profile is kept, and listed with its reason, but +never spawns. + +## Looks + + + +## Moving + + + +## Fighting + + + +## Press E + + + +A passive profile, as it looks in the file: + +```json +{ + "names": ["Quartermaster"], "kits": ["outpost_clothes"], "role": "passive", + "movement": { "mode": "stand", "radius": 0 }, + "hurtByPlayers": false, + "use": { "mode": "chat", "lines": ["Back again, {player}?", "Supplies are short."], "title": "", "text": "" } +} +``` + +## Boss + + + +A boss gives no reward of its own. On a Runic Gateway server, an event's reward steps do, and can +name everyone who hurt it. + +## Loot + + + +## Sides + + + +**Whom it fights besides players** is decided by its own senses: only factions it is hostile to, +within its sense range of itself and its chase range of its spot, so it never walks into a +monument to look for scientists. Rust's scientists never fight back, by Rust's own rule; they take +cover. Whoever shoots one of RunicNPC's NPCs, it fights back, whatever the table says. + +## The kit's extras + + + +## A whole profile + +A roamer that is also a boss, with its own loot, as it looks in the file and through the API: + +```json +{ + "names": ["Warden", "Old Warden"], + "kits": ["warden_rifle", "warden_smg"], + "prefab": "scientistnpc_roam", + "role": "roamer", + "movement": { "mode": "wander", "radius": 20 }, + "health": 250, + "damageDealt": 1.0, + "damageTaken": { "head": 1.0, "body": 1.0, "legs": 1.0 }, + "aimCone": 2.0, + "ranges": { "sense": 30, "loseTarget": 40, "chase": 40, "attack": 30 }, + "visionCone": -0.8, + "sleepDistance": 160, + "healthThresholds": [0.5], + "faction": "bandits", + "relations": { "scientists": "hostile" }, + "alertRadius": 40, + "turrets": "default", + "hurtByPlayers": true, + "hurtsPlayers": true, + "kitUse": { "heal": false, "grenades": false, "melee": false, "rockets": false, "flamethrower": false }, + "boss": { + "barDistance": 100, + "announce": "chat", + "spawnLine": "{name} has risen!", + "deathLine": "{name} has fallen to {killer}.", + "phases": [ + { "at": 0.5, "adds": { "profile": "raider", "count": 3 }, "line": "{name} calls for help!" }, + { "at": 0.25, "damageDealt": 1.5, "speed": 1.3, "kit": "warden_heavy", "line": "{name} is enraged!" } + ] + }, + "use": null, + "loot": { + "start": "rust", + "always": [ + { "item": "scrap", "min": 100, "max": 300, "chance": 1 }, + { "item": "rope", "min": 1, "max": 1, "chance": 0.5 } + ], + "pool": { + "pick": 1, + "nothing": 85, + "rows": [ + { "item": "lmg.m249", "min": 1, "max": 1, "weight": 10 }, + { "item": "explosive.timed", "min": 1, "max": 1, "weight": 5 } + ] + }, + "crate": "locked", + "hackSeconds": 600, + "corpseSeconds": 0, + "dropTable": true + } +} +``` diff --git a/src/data/reference.mjs b/src/data/reference.mjs index 1c99ff3..1e8a0c6 100644 --- a/src/data/reference.mjs +++ b/src/data/reference.mjs @@ -412,5 +412,9 @@ export const canonicalDocs = { 'modules/rust/PLAN.md': 'The Rust module design of record', 'modules/rust/OPERATING.md': "Running Oxide: uMod's operator pages, plus the notes on RaidableBases and Guarded Crate", 'modules/rust/CARBON.md': 'Where Carbon differs from Oxide, and nowhere else', + 'runicnpc/API.md': "RunicNPC's API for other plugins — normative over the site's copy", + 'runicnpc/COMMANDS.md': "RunicNPC's commands and permissions", + 'runicnpc/INSTALL.md': 'Installing RunicNPC, with or without Runic Gateway', + 'runicnpc/PLAN.md': "RunicNPC's design of record, and why each behaviour is shaped as it is", }, }; diff --git a/src/data/runicnpc.mjs b/src/data/runicnpc.mjs new file mode 100644 index 0000000..1883e67 --- /dev/null +++ b/src/data/runicnpc.mjs @@ -0,0 +1,513 @@ +/** + * RunicNPC, as the site carries it — PLAN.md §16, phase 16 (D62, D72–D74). + * + * D62 made the API an exception to §1: the site carries it IN FULL rather than linking to it, + * because its readers are plugin authors outside this project. `docs` `main:runicnpc/API.md` + * stays normative, and `scripts/checkReference.mjs` holds everything below that API.md names + * against it in both directions (D73): every call's and hook's signature, and the field names + * of every data shape. A signature that differs by one parameter fails the build. + * + * The commands, permissions, `/rnpc place` options and config keys are names too, and are + * checked the same way against the plugin's own source, `runicnpc-rust` `main:plugin/RunicNPC.cs`. + * + * Descriptions are written in the site's own words and are NOT checked (the same rule as + * reference.mjs). D74: they carry no decision numbers and no "added in API n" history — the + * site describes the current API only. Backticks in a description render as code + * (`src/components/Ticks.astro`). + */ + +/** Who owns an NPC, which decides how long it lives. API.md "Owners". */ +export const runicnpcOwners = { + 'run:': { + who: 'An event run, through the bridge', + lifetime: 'Never saved. Removed by its caller, or by `RunicNpc_DespawnOwner`', + }, + 'plugin:': { + who: 'Any other plugin — use your own plugin’s name', + lifetime: 'Never saved. **Removed when that plugin unloads**', + }, + 'placement:': { + who: 'RunicNPC itself, for a placement. A caller cannot spawn with this owner', + lifetime: 'The NPC is never saved; the placement is, and spawns a fresh one at boot', + }, +}; + +/** + * Every call, as `name: { params, returns }`. The signature compared is + * `name(params) → returns`, whitespace collapsed. The prose for each is on the API page, + * under a `` for it; the check also requires every call here to have + * exactly one. + */ +export const runicnpcCalls = { + RunicNpc_ApiVersion: { params: '', returns: 'int' }, + RunicNpc_Spawn: { params: 'Vector3 at, string profile, string owner, JObject overrides', returns: 'BasePlayer' }, + RunicNpc_Despawn: { params: 'ulong netId', returns: 'int' }, + RunicNpc_DespawnOwner: { params: 'string owner', returns: 'int' }, + RunicNpc_List: { params: 'string owner', returns: 'List>' }, + RunicNpc_Profiles: { params: '', returns: 'JObject' }, + RunicNpc_SetProfiles: { params: 'JObject all', returns: 'Dictionary' }, + RunicNpc_Placements: { params: '', returns: 'List>' }, + RunicNpc_SetPlacement: { params: 'string id, JObject placement', returns: 'string' }, + RunicNpc_RemovePlacement: { params: 'string id', returns: 'bool' }, + RunicNpc_AddPlacement: { params: 'JObject placement', returns: 'Dictionary' }, + RunicNpc_RenamePlacement: { params: 'string from, string to', returns: 'string' }, + RunicNpc_RespawnPlacement: { params: 'string id', returns: 'int' }, + RunicNpc_Routes: { params: '', returns: 'JObject' }, + RunicNpc_SetRoute: { params: 'string name, JObject route', returns: 'string' }, + RunicNpc_RemoveRoute: { params: 'string name', returns: 'bool' }, + RunicNpc_IsRunicNpc: { params: 'BaseEntity entity', returns: 'bool' }, + RunicNpc_ProfileOf: { params: 'BaseEntity entity', returns: 'string' }, + RunicNpc_Factions: { params: '', returns: 'JArray' }, + RunicNpc_SetFactions: { params: 'JArray pairs', returns: 'string' }, + RunicNpc_Escort: { params: 'ulong netId, BaseEntity target', returns: 'string' }, + RunicNpc_Ally: { params: 'ulong netId, string kind, string id', returns: 'string' }, + RunicNpc_Tether: { params: 'ulong netId, string zone', returns: 'string' }, + RunicNpc_CostWarning: { params: 'int adding', returns: 'string' }, +}; + +/** Every hook RunicNPC raises. None answers: a return value is ignored. */ +export const runicnpcHooks = { + OnRunicNpcSpawned: { + params: 'BasePlayer npc, string profile, string owner', + when: 'After an NPC spawns and is equipped.', + }, + OnRunicNpcHealth: { + params: 'BasePlayer npc, string profile, float threshold', + when: 'The first time its health falls to one of its profile’s `healthThresholds`.', + }, + OnRunicNpcDied: { + params: 'BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors', + when: + 'When it dies. `contributors` maps each player’s Steam id to the health that player took from it, the killing blow included, after Rust’s protection — never more than its health in all.', + }, + OnRunicNpcDespawned: { + params: 'BasePlayer npc, string owner', + when: 'When it is removed without dying. Not raised while RunicNPC itself unloads.', + }, + OnRunicNpcPlacementChanged: { + params: 'string id, string change, string previous', + when: + 'A placement was set, removed or renamed, through the API or in game. `change` is `set`, `removed` or `renamed`; `previous` is the old name for a rename, and null otherwise.', + }, + OnRunicNpcEscortEnded: { + params: 'BasePlayer npc, string profile, string owner', + when: 'Whom it escorted died, was destroyed or left the server, and it is walking back to its spot.', + }, + OnRunicNpcBossSpawned: { + params: 'BasePlayer npc, string profile, string owner', + when: 'After `OnRunicNpcSpawned`, for a profile with a `boss` block, once its spawn line has been said.', + }, + OnRunicNpcBossPhase: { + params: 'BasePlayer npc, string profile, string owner, int phase, float at', + when: + 'It entered a phase. `phase` counts from 1 and `at` is the phase’s health fraction. Raised once the phase’s changes apply and its line is said; a hit that crosses two thresholds raises both, highest first.', + }, + OnRunicNpcBossDied: { + params: 'BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors', + when: + 'After `OnRunicNpcDied`, for a boss, with the same contributors, once its death line has been said. RunicNPC gives no reward itself.', + }, + OnRunicNpcUsed: { + params: 'BasePlayer npc, string profile, BasePlayer player', + when: 'A player pressed E on a passive NPC and was answered. At most once a second per player.', + }, +}; + +/** The keys of each row `RunicNpc_List` returns. */ +export const runicnpcListKeys = { + netId: { type: 'ulong', what: 'Its net id, which `RunicNpc_Despawn` and the orders take' }, + profile: { type: 'string', what: 'Its profile’s name' }, + name: { type: 'string', what: 'The name it was given from its profile’s `names`' }, + owner: { type: 'string', what: 'Its owner' }, + kit: { type: 'string', what: 'The kit picked for it' }, + placement: { type: 'string', what: 'Its placement’s id, or null' }, + position: { type: '{x, y, z}', what: 'Where it is' }, + home: { type: '{x, y, z}', what: 'Where it spawned: what it wanders around, and walks back to before it sleeps' }, + health: { type: 'float', what: 'Its health now' }, + maxHealth: { type: 'float', what: 'Its full health' }, + movement: { type: 'string', what: '`wander`, `monument` or `route:`' }, + rest: { type: 'string', what: '`Awake`, `GoingHome` or `Asleep`' }, + state: { type: 'string', what: 'Rust’s own AI state: `Idle`, `Roam`, `Chase`, `Combat` and so on' }, + leashes: { type: 'int', what: 'How many times it has given up a chase at its chase range' }, + regrounded: { + type: 'int', + what: 'How many times the ground went from under it and it was put on the nearest navmesh', + }, + faction: { type: 'string', what: 'Its faction, or `profile:` when its profile has none' }, + target: { + type: 'string', + what: 'The prefab of the NPC, animal or turret it is fighting, or null. Players are not reported here', + }, + engaged: { type: 'bool', what: 'Whether it is fighting that target now — Rust fights players first' }, + escort: { type: 'string', what: 'Whom it escorts: a player’s Steam id, or an entity’s prefab; null for none' }, + ally: { type: 'string', what: '`clan:`, `team:` or `player:`, or null' }, + tether: { type: 'string', what: 'The ZoneManager zone it is held in, or null' }, + shotsMarked: { + type: 'int', + what: 'Shots it made at an NPC target; each marks the target hostile for that shot only', + }, + kitUsed: { + type: 'object', + what: '`heals`, `throws`, `rockets` and `meleeHits`: how much of its kit’s extras it has used', + }, + role: { type: 'string', what: 'Its profile’s role: `roamer`, `sentry`, `guard` or `passive`' }, + boss: { + type: 'object', + what: 'For a boss, `phase` (0 before the first, then 1, 2 …) and `damagers`, how many players have hurt it; null for any other NPC', + }, +}; + +/** A placement's keys, as `RunicNpc_SetPlacement` and `RunicNpc_AddPlacement` take them. */ +export const runicnpcPlacementKeys = { + profile: 'The profile its NPCs are made from', + position: '`{x, y, z}`. Through `RunicNpc_AddPlacement`, a position without `y` is a point on the map, put on the ground there', + yaw: 'Which way they face, in degrees', + count: 'How many NPCs stand there. All but the first are scattered within 3 m of the spot', + respawn: 'Seconds after a death before an NPC comes back; at least 1', + respawnMode: '`each`: every NPC comes back `respawn` after its own death. `group`: none comes back until all are dead, then all together', + movement: 'Optional. `{mode, radius}`, overriding the profile’s', + tether: 'Optional. A ZoneManager zone its NPCs never leave. A zone the server lacks, or one that does not hold the spot, is refused', +}; + +/** A route's keys. */ +export const runicnpcRouteKeys = { + points: 'At least two `{x, y, z}` points, each on Rust’s navmesh', + loop: 'True: the last point leads back to the first. False: the route is walked back and forth', +}; + +/** + * A profile's fields, by their JSON path — the same path `rnpc.profile set` takes and the + * file and the API carry. Grouped as the site's profile editor groups them, with the label + * it shows (`site`); null where the editor has no control for a field. + * + * API.md names the boss phase fields `phases[].…`, and so does this list, so the two compare + * as written; on the page they sit under `boss.phases`. + */ +export const runicnpcProfileFields = { + Looks: { + names: { site: 'NPC names', rule: 'at least one', meaning: 'One is picked for each NPC. The first is the profile’s name on the leaderboard' }, + kits: { + site: 'Kits', + rule: 'at least one; each must exist in Kits', + meaning: 'One is picked for each NPC, which is equipped with it. A kit the server lacks refuses the profile when it is saved, not when an NPC spawns', + }, + prefab: { site: 'Prefab', rule: 'a plain Rust `scientistnpc_*` prefab', meaning: 'What it is built from' }, + role: { + site: 'Role', + rule: '`roamer`, `sentry`, `guard` or `passive`', + meaning: + 'A **roamer** moves, chases and fights. A **sentry** never moves, and may stand off the navmesh. A **guard** holds its spot facing the way it was placed, chases within its chase range, and walks back. A **passive** NPC never fights — it takes no target, does not shoot back and raises no alarm — and pressing E on it does what its `use` says', + }, + }, + Moving: { + 'movement.mode': { + site: 'How it moves', + rule: '`wander`, `monument`, `route:`, or `stand` for a passive profile only', + meaning: + 'Wander around its spot, follow Rust’s own paths in the monument it stands in, or walk a recorded route. A passive NPC that stands holds its spot, may stand off the navmesh, and turns to whoever presses E on it. A placement may override it', + }, + 'movement.radius': { site: 'Wander radius (m)', rule: 'above 0 for `wander`', meaning: 'How far from home it wanders' }, + sleepDistance: { + site: 'Sleeps with nobody within (m)', + rule: '0 or more', + meaning: 'With no player this close it walks home, then sleeps. 0: it never sleeps', + }, + }, + Fighting: { + health: { site: 'Health', rule: 'above 0', meaning: 'Its health, and its maximum' }, + damageDealt: { site: 'Damage dealt ×', rule: '0 or more', meaning: 'Multiplies its weapon’s damage; 1 is the weapon’s full damage' }, + aimCone: { site: 'Aim spread', rule: '0 or more', meaning: 'How much its aim spreads. Rust’s scientists use 2' }, + visionCone: { site: 'Vision cone', rule: '−1 to 1', meaning: 'Rust’s vision cone' }, + damageTaken: { + site: 'Damage taken × head, body, legs', + rule: '`head`, `body` and `legs`, each 0 or more', + meaning: + 'Multiplies the damage it takes to the head, the legs (legs and feet) and the body — everything else, including hits with no body part, like fire and explosions', + }, + 'ranges.sense': { site: 'Notices players at (m)', rule: 'above 0', meaning: 'How far it notices players' }, + 'ranges.loseTarget': { site: 'Forgets them at (m)', rule: 'at least `sense`', meaning: 'How far a target must get before it is forgotten' }, + 'ranges.chase': { + site: 'Chases up to (m from home)', + rule: '0 or more', + meaning: + 'How far from home it will chase; 0 is no limit. At that distance it waits 5 s for the target to come back within range, then gives up and walks home', + }, + 'ranges.attack': { site: 'Shoots from (m)', rule: 'above 0', meaning: 'How far it shoots from. This replaces the weapon’s own range' }, + healthThresholds: { + site: 'Health thresholds (%)', + rule: 'fractions between 0 and 1', + meaning: 'Where `OnRunicNpcHealth` is raised, once each — and so what an event’s phase can wait for', + }, + }, + 'Press E': { + use: { + site: 'Press E', + rule: 'required on a passive profile, refused on any other', + meaning: + 'What pressing E on it does, from up to 3 m. `mode` `chat` says one of `lines` (picked at random, each at most 256 characters) in that player’s chat. `mode` `window` opens a window with `title` (blank: its name), `text` (at most 2,000 characters) and a Close button. `{player}` in either is the player’s name', + }, + }, + Boss: { + boss: { + site: 'It is a boss', + rule: 'null, or the block below; not on a passive profile', + meaning: 'Makes a roamer, sentry or guard a boss. It keeps moving and fighting as its role does', + }, + 'boss.barDistance': { + site: 'Bar shown within (m)', + rule: 'above 0, at most 1,000; default 100', + meaning: + 'Players this close see its health bar at the top of the screen, and so does every player who has hurt it, wherever they are, until it dies or despawns. A player near two bosses sees the nearer', + }, + 'boss.announce': { + site: 'Lines said in', + rule: '`chat` or `popup`; default `chat`', + meaning: 'Where its lines are said. `popup` uses PopupNotifications where it is loaded, and chat where it is not', + }, + 'boss.spawnLine': { + site: 'When it spawns, to everyone', + rule: 'at most 256 characters; blank says nothing', + meaning: 'Said to everyone on the server. `{name}` is its name', + }, + 'boss.deathLine': { + site: 'When it dies, to everyone', + rule: 'at most 256 characters; blank says nothing', + meaning: 'Said to everyone on the server. `{killer}` is the player who landed the killing blow, empty when there was none', + }, + 'boss.phases': { + site: 'Phases', + rule: 'a list; each `at` a fraction between 0 and 1, no two alike', + meaning: + 'When its health falls to a phase’s `at`, that phase applies from then on. Every other part of a phase is optional, and changes only that boss’s own copy of its profile', + }, + 'phases[].damageDealt': { site: 'Damage dealt ×', rule: '0 or more', meaning: 'Replaces the profile’s' }, + 'phases[].aimCone': { site: 'Aim spread', rule: '0 or more', meaning: 'Replaces the profile’s' }, + 'phases[].speed': { site: 'Speed ×', rule: 'above 0, at most 5', meaning: 'A multiple of Rust’s own speed for it: 1.3 is a third faster' }, + 'phases[].ranges': { site: 'Notices at, Forgets at, Chases to, Shoots from', rule: 'as the profile’s `ranges`', meaning: 'Replace the profile’s' }, + 'phases[].kit': { + site: 'Swaps to kit', + rule: 'a Kits kit the server has', + meaning: 'Its inventory is stripped, the kit given and its weapon equipped', + }, + 'phases[].adds': { + site: 'Summons (profile), How many', + rule: '`profile`: one that is not a boss; `count`: 1 to 20', + meaning: + 'NPCs of that profile placed on a 6 m ring around it, one per server frame. They belong to its owner, so an event’s teardown removes them, and they fight on after it dies', + }, + 'phases[].line': { + site: 'Line', + rule: 'at most 256 characters', + meaning: 'Said only to the players near it (within `barDistance`) and to those who have hurt it', + }, + }, + Loot: { + loot: { + site: 'Its own loot', + rule: 'null, or the block below', + meaning: 'Null keeps Rust’s scientist loot', + }, + 'loot.start': { + site: 'The corpse starts with', + rule: '`rust` (default), `kit` or `none`', + meaning: + 'What the corpse holds before the table: Rust’s scientist loot and the clothes it wore; what it carried (its kit) and the clothes; or nothing at all, clothes included', + }, + 'loot.always': { + site: 'Always rolled', + rule: 'up to 30 rows: `item`, `min`, `max`, `chance` (above 0, at most 1), and an optional `skin`', + meaning: 'Each row rolls on its own. `item` is a Rust short name — `scrap`, `rifle.ak`, `explosive.timed`', + }, + 'loot.pool': { + site: 'The pool', + rule: '`pick` (0 to its rows), `nothing` (a weight, 0 or more), and up to 30 `rows`, each with a `weight` above 0 instead of a chance', + meaning: + 'At most `pick` draws by weight, never the same row twice; a draw of "nothing" uses one up. A kill never pays out more than `pick` pool rows, however lucky — the fair cap on rare items', + }, + 'loot.crate': { + site: 'The table goes into', + rule: 'null (the corpse), `wooden`, `military`, `elite` or `locked`', + meaning: + 'The table goes into one of Rust’s crates beside the corpse, with Rust’s own loot off, and stays until emptied; a locked crate keeps Rust’s two-hour removal while unhacked. The always-rows plus `pick` must fit its slots: 6, 6, 12 or 24 (the corpse has 24)', + }, + 'loot.hackSeconds': { + site: 'Hack time (minutes)', + rule: '1 to 86,400; default 900', + meaning: 'A locked crate’s hack time, counted once a player starts the hack', + }, + 'loot.corpseSeconds': { + site: 'The corpse stays', + rule: 'null (Rust’s own 600 s), 0 (no corpse), or more', + meaning: 'How long the corpse lasts', + }, + 'loot.dropTable': { + site: null, + rule: 'true (default) or false', + meaning: + 'False rolls no table; the start and the corpse’s time still apply. An event’s Place NPCs step sends it as false for the NPCs it places, through a spawn’s overrides', + }, + }, + Sides: { + faction: { + site: 'Faction', + rule: 'null, or 1–40 of a-z, 0-9, _ and -; not `scientists` or `animals`', + meaning: 'Profiles of one faction are allies. With none, its allies are its own profile’s NPCs', + }, + relations: { + site: 'Its own exceptions', + rule: 'keys: a faction, `scientists`, `animals` or `profile:`; values `hostile`, `neutral` or `allied`', + meaning: + 'Its own exceptions, which win over the faction table. **A profile with no faction settings fights players only**', + }, + alertRadius: { + site: 'Alert radius (m)', + rule: '0 or more; default 40', + meaning: 'Allies this close learn whoever attacked one of them, seen or not. 0 is off', + }, + turrets: { + site: 'Turrets', + rule: '`default`, `ignore` or `always`', + meaning: + '`default`: as Rust’s scientists — player turrets shoot it, Outpost’s and Bandit Camp’s sentries do not. `ignore`: no turret targets it. `always`: those sentries too. It shoots back at a turret that shoots it', + }, + hurtByPlayers: { + site: 'Players can hurt it', + rule: 'true or false; default true, but false for a passive profile unless it is given', + meaning: + 'Off makes it unkillable by players. RunicNPC enforces it itself, and answers TruePVE’s and NextGenPVE’s damage checks the same way. Turrets, fire and other NPCs are not affected', + }, + hurtsPlayers: { + site: 'It can hurt players', + rule: 'true or false; default true', + meaning: 'Off makes it harmless to players. It still fights NPCs and turrets', + }, + }, + 'The kit’s extras': { + kitUse: { + site: 'The kit’s extras', + rule: '`heal`, `grenades`, `melee`, `rockets`, `flamethrower`, each true or false', + meaning: + 'The extras its kit gives that it uses, each only if true and each used up from its inventory, so its loot is what is left. The main weapon — the belt’s first slot — is always used and never runs out', + }, + }, +}; + +/** + * The commands, keyed by how they are typed after `/rnpc ` or `rnpc.`. The first word is the + * verb, which is what the check compares against the plugin's `[ConsoleCommand("rnpc.…")]` + * list. `perm` is the permission it needs; `console` marks the verbs that answer only in a + * console. + */ +export const runicnpcCommands = { + Placements: { + 'place [options]': { + perm: 'runicnpc.place', + does: 'Places the profile’s NPCs where you are looking, and answers with the placement’s name and the cost warning.', + }, + 'here [options]': { + perm: 'runicnpc.place', + does: + 'The same, a metre in front of you and facing you. If something is in the way, or there is no ground or navmesh there, it stands where you do and the reply says so.', + }, + 'remove [placement]': { + perm: 'runicnpc.place', + does: 'Removes a placement and its NPCs: the one you name, or the one the NPC you are looking at belongs to.', + }, + 'rename ': { perm: 'runicnpc.place', does: 'Renames a placement. Its live NPCs stay where they are.' }, + 'near [radius]': { + perm: 'runicnpc.place', + does: 'The placements and live NPCs around you, with distances and any note — waiting, or off the navmesh.', + }, + info: { + perm: 'runicnpc.place', + does: 'The NPC you are looking at: name, profile, placement or owner, health, state and target.', + }, + profiles: { perm: 'runicnpc.place', does: 'The profiles this server has, and any it refuses, with the reason.' }, + 'follow ': { + perm: 'runicnpc.place', + does: + 'The placement’s live NPCs escort a player: they fight whoever attacks that player, and walk back to their spot if the player dies or leaves. For trying an escort out — it is kept in memory only, so a respawn or a reload ends it.', + }, + 'tp ': { perm: 'runicnpc.admin', does: 'Teleports you to a placement.' }, + 'respawn ': { perm: 'runicnpc.admin', does: 'Respawns a placement’s NPCs now, or every placement’s.' }, + 'clear ': { + perm: 'runicnpc.admin', + does: 'Removes every NPC an owner has: a stuck event run (`run:42`), for example, or another plugin’s (`plugin:Name`).', + }, + }, + Routes: { + 'path record ': { perm: 'runicnpc.place', does: 'Starts recording a route where you walk.' }, + 'path point': { + perm: 'runicnpc.place', + does: 'Adds the spot you stand on. A point the previous one has no walkable path to is refused.', + }, + 'path undo': { perm: 'runicnpc.place', does: 'Drops the last point.' }, + 'path save [loop|back]': { + perm: 'runicnpc.place', + does: 'Saves the route: `loop` walks back to the first point and round again, `back` walks it back and forth.', + }, + 'path cancel': { perm: 'runicnpc.place', does: 'Abandons the recording.' }, + 'path list': { perm: 'runicnpc.place', does: 'The saved routes.' }, + 'path delete ': { + perm: 'runicnpc.place', + does: 'Deletes a route. A placement that walks it waits until a route of that name exists again.', + }, + }, + 'At the console': { + 'profile list': { perm: 'runicnpc.admin', console: true, does: 'The same as `/rnpc profiles`.' }, + 'profile show ': { perm: 'runicnpc.admin', console: true, does: 'A profile in full, as JSON, and why it is refused if it is.' }, + 'profile create [from=]': { + perm: 'runicnpc.admin', + console: true, + does: 'A new profile, empty or copied from another.', + }, + 'profile set ': { + perm: 'runicnpc.admin', + console: true, + does: 'Sets one field of a profile by its path — `health 200`, `ranges.sense 40`, `kits a,b`. See [Profile fields](/docs/rust/runicnpc/profile-fields/).', + }, + 'profile delete ': { + perm: 'runicnpc.admin', + console: true, + does: 'Deletes a profile. Placements that use it wait until a profile of that name exists again.', + }, + 'faction list': { perm: 'runicnpc.admin', console: true, does: 'The faction table: which factions fight, ignore or help each other.' }, + 'faction set hostile|neutral|allied': { perm: 'runicnpc.admin', console: true, does: 'Sets one pair, both ways.' }, + 'faction clear ': { perm: 'runicnpc.admin', console: true, does: 'Removes a pair.' }, + status: { + perm: 'the server console, RCON, or an admin’s F1', + console: true, + does: 'The version and API, the hooks that have fired, the navmesh, NPC counts by owner, profiles, placements, routes, the caps and the cost warning.', + }, + reload: { perm: 'the server console, RCON, or an admin’s F1', console: true, does: 'Reads profiles and routes again after a hand edit of their files.' }, + help: { perm: 'anyone', console: true, does: 'The verbs you may use, and nothing else — the same list as `/rnpc` alone in chat.' }, + }, +}; + +/** RunicNPC's two permissions. */ +export const runicnpcPermissions = { + 'runicnpc.place': 'Placing, removing, renaming and inspecting NPCs, recording routes, and `follow`', + 'runicnpc.admin': 'Everything `runicnpc.place` grants, plus teleporting, respawning, clearing an owner, and the console-only verbs', +}; + +/** The options `place` and `here` take, as `key=value`, in any order. */ +export const runicnpcPlaceOptions = { + count: { means: 'How many NPCs stand there', default: '1' }, + respawn: { means: 'Seconds after a death before the NPC comes back', default: '300' }, + mode: { means: '`each`: every NPC on its own timer. `group`: none until all are dead, then all at once', default: '`each`' }, + move: { means: '`wander`, `monument` or `route:`', default: 'the profile’s' }, + radius: { means: 'How far a wanderer strays, in metres', default: 'the profile’s' }, + tether: { means: 'A ZoneManager zone the NPCs never leave. Needs ZoneManager', default: 'none' }, + at: { means: 'Server console only, which has no position: `at=x,y,z`', default: '—' }, + yaw: { means: 'Server console only: which way they face, in degrees', default: '—' }, +}; + +/** `config/RunicNPC.json` (Carbon: `configs/RunicNPC.json`), by JSON path. */ +export const runicnpcConfig = { + 'caps.total': 'RunicNPC’s NPCs on the server', + 'caps.perOwner': 'NPCs of one owner: one placement, one event run, or one other plugin', + 'caps.perProfile': 'NPCs of one profile', + 'caps.spawnsPerSecond': 'How fast NPCs are spawned', + spawnBudgetMs: + 'Milliseconds of each server frame placements may spend spawning; 8 by default. One NPC takes about 5 ms to spawn, so a large placement is spread over frames instead of stalling one', +}; -- 2.49.1 From 297cc83db5942d4cbff5d853df336a4cbd0c6e96 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 22 Oct 2026 16:02:40 -0500 Subject: [PATCH 2/2] docs(plan): record the Oxide floor ruling and the API.md table fix The bundle's Oxide 2.0.7585 is the original compatible version and stays until the CI staging drill reports an issue (org lead, 2026-10-22). The API.md table break is fixed in docs #371. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- PLAN.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/PLAN.md b/PLAN.md index 1dc4741..b23a90c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2249,17 +2249,18 @@ NPCs* step to the profiles page. The canonical documents gain `runicnpc/API.md`, `INSTALL.md` and `PLAN.md` under Rust, and that page now names the API page as the one deliberate exception to "every page links out". -**Findings outside this repository**, reported rather than fixed: +**Two findings outside this repository**, reported in the PR: -- **The Rust bundle's Oxide floor is lower than RunicNPC's.** `v2/rust/current.json` says Oxide - 2.0.7585, which is the bridge plugin's floor. But the bundle requires RunicNPC, whose own - `plugin.toml` says 2.0.7726 — and its comment notes that RunicNPC "has never run on" 7585. So - the installer will accept a server that RunicNPC has never been tried on. The site states both - numbers where each belongs (the Rust overview quotes the bundle's, the RunicNPC pages - RunicNPC's) and claims nothing about the gap. -- **`API.md`'s profile table breaks in two places.** The `boss` rows and the `loot` rows each - follow a blank line with no header row, so Gitea renders them as plain text full of pipes rather - than as tables. The check parses them line by line and is not affected; a reader of `API.md` is. +- **The Rust bundle's Oxide floor is lower than RunicNPC's — and that is intended.** + `v2/rust/current.json` says Oxide 2.0.7585, the bridge plugin's floor, while RunicNPC's own + `plugin.toml` says 2.0.7726. The org lead ruled on 2026-10-22 that **2.0.7585 is the original + compatible version and stays**. It moves only when the CI staging drill reports an issue, and then + as part of the update that fixes it. The site states each number where it belongs (the Rust + overview quotes the bundle's, the RunicNPC pages RunicNPC's) and claims nothing about the gap. +- **`API.md`'s profile table broke in two places.** The `boss` rows and the `loot` rows each + followed a blank line with no header row, so Gitea rendered them as plain text full of pipes. + The check parses them line by line and was not affected. Fixed at the org lead's ask in `docs` + #371, which removes the two blank lines and changes nothing else. --- -- 2.49.1