feat: stage 0, an empty RunicNPC that releases through CI
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m44s

The repository RunicNPC is built in (docs/runicnpc/PLAN.md §9, stage 0):

- plugin/RunicNPC.cs: `// Requires: Kits` (D217), `[Info]` with the 0.0.0
  placeholder the release stamps, `RunicNpc_ApiVersion()` (API 1), and
  `rnpc.status`, which reports the version and which hooks have fired. It
  spawns nothing.
- plugin.toml: the API version, the framework floors it was loaded on
  (Oxide 2.0.7726, Carbon 2.0.259) and requires_plugins = ["Kits"].
- scripts/checkPlugin.js, adapted from Rust-Plugins': every hook listed and
  void unless written down; chat-command signatures; every RunicNpc_ call
  reachable by Call (the HumanNPC trap, PLAN.md §1.2); ApiVersion,
  `// Requires:` and [Info] agreeing with plugin.toml. 23 self-tests, including
  the real plugin and a CRLF checkout.
- PR Checks on PRs into main and edge; the release workflow on main, with
  Rust-Plugins' release engine unchanged and an adapter that ships
  runicnpc-<ver>.tar.gz (runicnpc/RunicNPC.cs + manifest.json) and SHA256SUMS.
  No bundle dispatch until stage 4.
- tools/: the rig panel scripts, with the panel and server ids moved into a
  git-ignored tools/rigs.json. `con.js` became `console.js`: CON is a reserved
  device name on Windows, and git there cannot open the file.
- README, CONTRIBUTING (edge-based flow, AI disclosure, borrow-not-copy),
  SECURITY, the code of conduct, issue and PR templates.

`feat:` so the cutover to main cuts the first release, 0.1.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-29 21:01:49 -05:00
parent 8641d8fb2e
commit 249f2e513f
19 changed files with 2027 additions and 4 deletions

336
scripts/checkPlugin.js Normal file
View File

@@ -0,0 +1,336 @@
#!/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.create(null)
/** 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)
const hooks = methods.filter((x) => HOOK_NAME.test(x.name))
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()