Files
runicnpc-rust/scripts/checkPlugin.js
wtclaude 21d3c477dc feat: API 4, factions, escort, allies, tether, turrets, kit extras and PVE (stage 5)
Stage 5's behaviour (docs runicnpc/PLAN.md, D253-D272):

- profiles gain faction, relations (exceptions), alertRadius, turrets,
  hurtByPlayers, hurtsPlayers and kitUse, and the guard role; the faction
  table rides in profiles.json, one row per pair, both ways (D254, D268);
- our own sensing and fight beside Rust's design: only the factions a
  profile hunts, within its leash of its anchor (D263), the target made
  Horror for the length of our own shot, and Rust's attack routine;
- shooting back at whoever hurt it (D268), the alert radius (D256), clan,
  team or player allies that are never targeted and are defended with
  what they own (D257), escort as an order that walks home when its
  charge is gone (D269), and a placement's tether through ZoneManager
  (D272);
- turrets: ignore through CanBeTargeted, and always through a Harmony
  patch on the safe-zone sentries for our always NPCs only (D267);
- the kit's extras, each opted into and used up: heal, melee,
  flamethrower, grenades and rockets (D260, D265);
- TruePVE/NextGenPVE answered both ways unless a profile turns a direction
  off, and the opt-out enforced by RunicNPC itself (D261, D271);
- API 4: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether and the
  OnRunicNpcEscortEnded hook; rnpc follow, rnpc.faction and tether=.

Compiled and loaded on both rigs (Oxide and Carbon); the sentry patch
applies on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 07:20:59 -05:00

351 lines
14 KiB
JavaScript

#!/usr/bin/env node
//
// Static checks on RunicNPC, run on every pull request and again on the commit
// being released.
//
// This plugin has no unit tests and cannot have any in the ordinary sense: it is
// deployed as SOURCE and compiled by Oxide or Carbon against game assemblies that
// exist only on a Rust server. There is no way to build it here, and the nearest
// thing to a compiler this repository owns is a reader. It is the bridge's
// checker (Rust-Plugins' scripts/checkPlugin.js) adapted to what RunicNPC can get
// silently wrong.
//
// Both frameworks bind hooks, chat commands and `Call` targets **by name and
// arity, through reflection**, with no compile-time check and no warning when a
// name matches nothing. So:
//
// 1. A hook the plugin implements but does not list in `ExpectedHooks` is
// invisible to `rnpc.status`, the instrument that answers "does this hook
// fire on this framework". A name listed and never implemented reports
// silent for ever, which reads exactly like a hook the framework dropped.
//
// 2. A hook that ANSWERS changes what the game does: it can cancel a death,
// stop a turret targeting, or replace a corpse's loot. RunicNPC WILL answer
// some hooks — that is how an NPC plugin works (PLAN.md §3) — but each one
// must be a decision written down, so the rule is inverted: EVERY hook is
// `void` unless it is listed in `ANSWERS_DELIBERATELY` below, with a reason.
// A list of "dangerous" hook names would have to be maintained against a
// catalogue in another repository, and the first one somebody forgot would
// be the one that passed. There is nothing to forget this way.
//
// 3. A chat command with the wrong signature is never called, and nobody says
// so: the admin types `/rnpc` and nothing happens.
//
// 4. An API method (`RunicNpc_*`) that is `public` without `[HookMethod]` is
// unreachable: Oxide's `Call` finds a non-public method by name, and a public
// one only when it carries that attribute. The spike found HumanNPC's
// `RefreshNPC` and `RemoveNPC` in exactly this state (PLAN.md §1.2).
//
// 5. The facts declared twice must agree:
// • `ApiVersion` in the plugin and `api` in plugin.toml. The release copies
// plugin.toml into the manifest the installer and the bridge read, so a
// disagreement ships a RunicNPC that answers one version and claims
// another.
// • the plugin's `// Requires:` lines and plugin.toml's `requires_plugins`.
// The first stops the framework loading RunicNPC without Kits; the
// second is what `doctor` reports. Kits must be in both (D217).
// • exactly one `[Info("RunicNPC", "Runic Gateway", "…")]`, because the
// release stamps its version into that one line.
//
// Dependency-free by design, like every check script in this project: it runs on
// a bare Node with no install step, which is also how a contributor runs it.
//
// node scripts/checkPlugin.js
const fs = require('fs')
const path = require('path')
const ROOT = path.resolve(__dirname, '..')
const PLUGIN = path.join(ROOT, 'plugin', 'RunicNPC.cs')
const PLUGIN_TOML = path.join(ROOT, 'plugin.toml')
/**
* Hooks this plugin answers on purpose, and why.
*
* Empty in stage 0, which spawns nothing. From stage 2 an entry here is a
* deliberate decision to let RunicNPC change what the game does — and, because
* the plugin shares the server with everyone else's entities, every one of them
* must answer for RunicNPC's OWN NPCs only and return null for anything else.
* That half cannot be read statically; it is the reviewer's to check, which is
* why the reason goes here where the reviewer will see it.
*/
const ANSWERS_DELIBERATELY = Object.assign(Object.create(null), {
// Stage 5 (D259, D264): turrets: ignore. Answers false for RunicNPC's own NPCs whose profile says
// ignore, so no auto turret, flame turret or shotgun trap targets them; null for everything else.
CanBeTargeted: 'turrets: ignore (D259, D264), our own NPCs only',
// Stage 5 (D261, D271): TruePVE's and NextGenPVE's question. Answers only for one of ours facing a
// real player, with the profile's two PVE checkboxes; null for everything else.
CanEntityTakeDamage: 'PVE allowed both ways, or the profile opt-out (D261, D271), our own NPCs only',
// Stage 5 (D271): cancels damage only when one of ours whose profile says it cannot hurt players
// hits a real player. Its other job, defending an ally or escort (D257, D269), never answers.
OnEntityTakeDamage: 'a harmless profile never hurts a player (D271), our own NPCs only',
})
/** Plugins RunicNPC must require, whatever else it requires (D217). */
const REQUIRED_PLUGINS = ['Kits']
/** Anything shaped like this is a game hook, by both frameworks' own convention. */
const HOOK_NAME = /^(?:On|Can)[A-Z]\w*$/
/** Every call of the public API carries this prefix (PLAN.md §4). */
const API_NAME = /^RunicNpc_\w+$/
/**
* The parameter list both frameworks bind a chat command on: the caller, the command word, and
* whatever followed it. Names are the author's; the types are not.
*/
const CHAT_SIGNATURE = /^BasePlayer\s+\w+,\s*string\s+\w+,\s*string\[\]\s+\w+$/
/**
* Method declarations, as this file cares about them: any attributes directly above, the access
* modifier, the return type and the name. Deliberately narrow — it matches the plugin's own
* single style (`private [static] <type> <Name>(`) rather than trying to parse C#. A method written
* some other way is not matched, which would let a hook through, so the shape is asserted by the
* self-test against the real plugin rather than assumed. `\r?` because a Windows checkout has
* CRLF endings, and an attribute line that failed to match there would hide a [HookMethod].
*/
const METHOD =
/((?:^[ \t]*\[[^\]\r\n]*\][ \t]*\r?\n)*)^[ \t]*(private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(/gm
/** The attribute the release stamps a version into. */
const INFO = /^[ \t]*\[Info\("RunicNPC", "Runic Gateway", "[^"]*"\)\]/gm
function readExpectedHooks(source) {
const block = /ExpectedHooks\s*=\s*\{([\s\S]*?)\}\s*;/.exec(source)
if (!block) return null
return block[1]
.split(',')
.map((entry) => /"([^"]+)"/.exec(entry))
.filter(Boolean)
.map((m) => m[1])
}
function readMethods(source) {
const found = []
let m
METHOD.lastIndex = 0
while ((m = METHOD.exec(source)) !== null) {
found.push({ attributes: m[1], access: m[2], returns: m[3].trim(), name: m[4] })
}
return found
}
/**
* Every `[ChatCommand("x")]` and the signature of the method under it.
*
* **A chat command binds by reflection, exactly like a hook**, and fails the same silent way: a
* method with the wrong parameter list is never called and neither framework says a word.
*/
function readChatCommands(source) {
const found = []
const attribute =
/\[ChatCommand\("([^"]+)"\)\]\s*(?:\/\/[^\n]*\n\s*)*(?:private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(([^)]*)\)/g
let match
while ((match = attribute.exec(source)) !== null) {
found.push({
command: match[1],
returns: match[2].trim(),
name: match[3],
params: match[4].replace(/\s+/g, ' ').trim(),
})
}
return found
}
/** The plugin names in `// Requires: A, B` lines at the top of the file, in order. */
function readRequires(source) {
const names = []
const line = /^\s*\/\/\s*Requires:\s*(.+)$/gm
let m
while ((m = line.exec(source)) !== null) {
for (const name of m[1].split(',')) {
if (name.trim()) names.push(name.trim())
}
}
return names
}
/** `requires_plugins = ["A", "B"]`, one line, quoted strings only — or null if it is not that. */
function readTomlRequires(toml) {
const m = /^\s*requires_plugins\s*=\s*(\[.*\])\s*$/m.exec(toml)
if (!m) return null
try {
const list = JSON.parse(m[1])
return Array.isArray(list) && list.every((x) => typeof x === 'string') ? list : null
} catch {
return null
}
}
function check(source, toml) {
const problems = []
const expected = readExpectedHooks(source)
if (!expected) {
return ['could not find the ExpectedHooks array in the plugin source']
}
const methods = readMethods(source)
// An `override` is never a hook: a hook is a plugin method the framework finds by name, and the
// plugin class overrides nothing shaped like one. What does override `On*` methods is the NPC's
// own classes (RunicNpcPlayer.OnDied overrides Rust's ScientistNPC), and Rust, not the
// framework, calls those.
const hooks = methods.filter((x) => HOOK_NAME.test(x.name) && !/\boverride\b/.test(x.returns))
const hookNames = new Set(hooks.map((x) => x.name))
// 1. Every hook the plugin implements is one `rnpc.status` can report on.
for (const hook of hooks) {
if (!expected.includes(hook.name)) {
problems.push(
`${hook.name} is implemented but missing from ExpectedHooks, so rnpc.status cannot report it`
)
}
}
// ... and no phantom entries.
for (const name of expected) {
if (!hookNames.has(name)) {
problems.push(`ExpectedHooks lists ${name}, but no method of that name is implemented`)
}
}
// 2. Every hook is void, unless answering is a decision somebody wrote down.
for (const hook of hooks) {
if (hook.returns === 'void') continue
if (hook.name in ANSWERS_DELIBERATELY) continue
problems.push(
`${hook.name} returns ${hook.returns}, not void — a hook that answers changes what the game ` +
'does. If RunicNPC must answer it, add it to ANSWERS_DELIBERATELY in scripts/checkPlugin.js ' +
'with the reason, and answer for RunicNPC\'s own NPCs only.'
)
}
// 3. Every chat command has the signature the frameworks bind, and one name binds once.
const chat = readChatCommands(source)
const seen = new Set()
for (const cmd of chat) {
if (cmd.returns !== 'void') {
problems.push(
`/${cmd.command} (${cmd.name}) returns ${cmd.returns}, not void — a chat command's return ` +
'value is not read, and a non-void signature is the shape that silently binds nothing.'
)
}
if (!CHAT_SIGNATURE.test(cmd.params)) {
problems.push(
`/${cmd.command} (${cmd.name}) takes (${cmd.params}), not (BasePlayer, string, string[]). ` +
'Both frameworks bind a chat command by reflection on that exact signature, so this one ' +
'would never be called and neither would log it.'
)
}
if (seen.has(cmd.command)) {
problems.push(`/${cmd.command} is declared twice; only one of them can ever be bound`)
}
seen.add(cmd.command)
}
// 4. Every API method is one `Call` can reach.
for (const method of methods.filter((x) => API_NAME.test(x.name))) {
if (method.access === 'public' && !/\[HookMethod\(/.test(method.attributes)) {
problems.push(
`${method.name} is public without [HookMethod], so Oxide's Call cannot reach it — make it ` +
'private, as every RunicNpc_ call is (PLAN.md §4).'
)
}
}
// 5a. The API version, declared twice.
const inCode = /ApiVersion\s*=\s*(\d+)\s*;/.exec(source)
const inToml = /^\s*api\s*=\s*(\d+)\s*$/m.exec(toml)
if (!inCode) problems.push('could not read ApiVersion from the plugin source')
if (!inToml) problems.push('could not read `api` from plugin.toml')
if (inCode && inToml && inCode[1] !== inToml[1]) {
problems.push(
`the plugin answers API ${inCode[1]} and plugin.toml declares ${inToml[1]}. The release ` +
'copies plugin.toml into the manifest the installer and the bridge read, so this would ship ' +
'a RunicNPC that claims one version and answers another.'
)
}
// 5b. The required plugins, declared twice.
const requires = readRequires(source)
const tomlRequires = readTomlRequires(toml)
if (!tomlRequires) {
problems.push('could not read `requires_plugins` from plugin.toml as a one-line array of strings')
} else {
const a = [...new Set(requires)].sort().join(', ')
const b = [...new Set(tomlRequires)].sort().join(', ')
if (a !== b) {
problems.push(
`the plugin's // Requires: lines name [${a}] and plugin.toml's requires_plugins names [${b}]. ` +
'The first is what stops the framework loading RunicNPC; the second is what doctor reports.'
)
}
}
for (const name of REQUIRED_PLUGINS) {
if (!requires.includes(name)) {
problems.push(
`the plugin does not declare // Requires: ${name}. ${name} is required (D217), and without ` +
'the line the framework loads RunicNPC on a server that cannot equip a single NPC.'
)
}
}
// 5c. The one line the release stamps.
const infoCount = (source.match(INFO) || []).length
if (infoCount !== 1) {
problems.push(
`expected exactly one [Info("RunicNPC", "Runic Gateway", "…")] attribute, found ${infoCount}. ` +
'The release stamps its version into that line; zero or two would ship a plugin whose ' +
'reported version is a lie.'
)
}
return problems
}
function main() {
const source = fs.readFileSync(PLUGIN, 'utf8')
const toml = fs.readFileSync(PLUGIN_TOML, 'utf8')
const problems = check(source, toml)
if (problems.length > 0) {
console.error('RunicNPC failed its static checks:\n')
for (const p of problems) console.error(` • ${p}`)
console.error('')
process.exit(1)
}
const expected = readExpectedHooks(source)
const api = /ApiVersion\s*=\s*(\d+)\s*;/.exec(source)[1]
const calls = readMethods(source).filter((x) => API_NAME.test(x.name)).length
console.log(
`plugin ok — API ${api}, ${calls} API call(s), ${expected.length} hook(s) declared, ` +
`requires ${readRequires(source).join(', ')}`
)
}
module.exports = {
check,
readExpectedHooks,
readMethods,
readChatCommands,
readRequires,
readTomlRequires,
HOOK_NAME,
API_NAME,
}
if (require.main === module) main()