Files
runicnpc-rust/scripts/checkPlugin.test.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

324 lines
12 KiB
JavaScript

// A check is worth what it catches, so this breaks it every way it claims to catch.
//
// The cases that matter most are the last ones: `checkPlugin.js` finds hooks and
// API calls with deliberately narrow regexes, and a regex that silently matches
// NOTHING passes every other check in this file and every check in CI while
// asserting nothing at all. So the real plugin source is read too, and the parse
// is asserted against names known to be in it.
//
// node --test scripts/checkPlugin.test.js
//
// Named individually rather than `node --test scripts/`: directory mode is not
// portable across the Node versions this project runs on.
const test = require('node:test')
const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const {
check,
readExpectedHooks,
readMethods,
readRequires,
readTomlRequires,
HOOK_NAME,
API_NAME,
} = require('./checkPlugin')
const INFO_LINE = ' [Info("RunicNPC", "Runic Gateway", "0.0.0")]'
/** A minimal plugin that passes, as the baseline every case below deviates from. */
function source({
expected = ['OnServerInitialized'],
methods,
api = 1,
requires = '// Requires: Kits',
info = INFO_LINE,
} = {}) {
const body =
methods ??
` private void OnServerInitialized()
{
}
private int RunicNpc_ApiVersion() => ApiVersion;`
return `${requires}
namespace Oxide.Plugins
{
${info}
internal class RunicNPC : RustPlugin
{
private const int ApiVersion = ${api};
private static readonly string[] ExpectedHooks =
{
${expected.map((e) => `"${e}"`).join(', ')}
};
${body}
}
}`
}
const toml = ({ api = 1, requires = '["Kits"]' } = {}) =>
`api = ${api}\nmin_oxide_version = "2.0.7585"\nrequires_plugins = ${requires}\n`
test('a plugin that follows the rules passes', () => {
assert.deepEqual(check(source(), toml()), [])
})
// ── Hooks ──────────────────────────────────────────────────────────────────
test('a hook missing from ExpectedHooks is caught, because rnpc.status could not report it', () => {
const problems = check(source({ expected: [] }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /OnServerInitialized is implemented but missing from ExpectedHooks/)
})
test('a name listed but never implemented is caught, because it reports silent for ever', () => {
const problems = check(source({ expected: ['OnServerInitialized', 'OnEntityDeath'] }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /ExpectedHooks lists OnEntityDeath, but no method/)
})
test('a hook that answers is caught unless it is written down', () => {
const methods = ` private object OnNpcTarget(BaseEntity npc, BaseEntity target)
{
return null;
}`
const problems = check(source({ expected: ['OnNpcTarget'], methods }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /OnNpcTarget returns object, not void/)
assert.match(problems[0], /ANSWERS_DELIBERATELY/)
})
test('returning null is not good enough — the signature is the rule', () => {
// `return null` today is one edit away from `return true` tomorrow, and the
// edit that breaks it looks harmless in a diff. A void method cannot be turned
// into a veto without changing its signature, which is visible.
const methods = ` private bool CanLootEntity(BasePlayer player, StorageContainer container)
{
return true;
}`
const problems = check(source({ expected: ['CanLootEntity'], methods }), toml())
assert.match(problems[0], /CanLootEntity returns bool, not void/)
})
test('a method that is not shaped like a hook is left alone', () => {
const methods = ` private Dictionary<string, object> Describe(BasePlayer npc)
{
return null;
}
private static string Column(int index)
{
return null;
}`
assert.deepEqual(check(source({ expected: [], methods }), toml()), [])
assert.ok(!HOOK_NAME.test('Describe'))
assert.ok(HOOK_NAME.test('OnServerInitialized'))
assert.ok(HOOK_NAME.test('CanBeTargeted'))
})
test('an override shaped like a hook belongs to the NPC classes, not the framework, and is left alone', () => {
const methods = ` private void OnServerInitialized()
{
}
public class RunicNpcPlayer : ScientistNPC
{
public override void OnDied(HitInfo info)
{
}
protected override void OnStateChanged()
{
}
}`
assert.deepEqual(check(source({ methods }), toml()), [])
})
// ── The API ────────────────────────────────────────────────────────────────
test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => {
// The state the spike found HumanNPC's RefreshNPC and RemoveNPC in (PLAN.md §1.2).
const methods = ` private void OnServerInitialized()
{
}
public int RunicNpc_ApiVersion() => ApiVersion;`
const problems = check(source({ methods }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /RunicNpc_ApiVersion is public without \[HookMethod\]/)
})
test('a public API call WITH [HookMethod] is reachable and passes', () => {
const methods = ` private void OnServerInitialized()
{
}
[HookMethod("RunicNpc_ApiVersion")]
public int RunicNpc_ApiVersion() => ApiVersion;`
assert.deepEqual(check(source({ methods }), toml()), [])
})
test('[HookMethod] is still seen in a CRLF checkout', () => {
// A Windows clone has CRLF endings; an attribute regex that only knew `\n`
// stopped seeing [HookMethod] there and failed a reachable call.
const methods = ` private void OnServerInitialized()
{
}
[HookMethod("RunicNpc_ApiVersion")]
public int RunicNpc_ApiVersion() => ApiVersion;`
assert.deepEqual(check(source({ methods }).replace(/\n/g, '\r\n'), toml()), [])
})
test('an API version that disagrees with plugin.toml is caught', () => {
const problems = check(source({ api: 2 }), toml({ api: 1 }))
assert.equal(problems.length, 1)
assert.match(problems[0], /answers API 2 and plugin\.toml declares 1/)
})
test('a missing api key in plugin.toml is caught', () => {
const problems = check(source(), 'requires_plugins = ["Kits"]\n')
assert.equal(problems.length, 1)
assert.match(problems[0], /could not read `api` from plugin\.toml/)
})
// ── Required plugins (D217) ────────────────────────────────────────────────
test('a plugin without // Requires: Kits is caught, even when the toml agrees', () => {
const problems = check(source({ requires: '' }), toml({ requires: '[]' }))
assert.equal(problems.length, 1)
assert.match(problems[0], /does not declare \/\/ Requires: Kits/)
})
test('// Requires: and requires_plugins that disagree are caught', () => {
const problems = check(
source({ requires: '// Requires: Kits, ZoneManager' }),
toml({ requires: '["Kits"]' })
)
assert.equal(problems.length, 1)
assert.match(problems[0], /name \[Kits, ZoneManager\] and plugin\.toml's requires_plugins names \[Kits\]/)
})
test('a requires_plugins that is not a one-line array of strings is caught', () => {
const problems = check(source(), toml({ requires: 'Kits' }))
assert.equal(problems.length, 1)
assert.match(problems[0], /could not read `requires_plugins`/)
assert.equal(readTomlRequires('requires_plugins = [1]'), null)
})
test('several // Requires: lines and comma lists are all read', () => {
assert.deepEqual(readRequires('// Requires: Kits\n// Requires: ZoneManager, Economics\n'), [
'Kits',
'ZoneManager',
'Economics',
])
})
// ── The stamped line ───────────────────────────────────────────────────────
test('a missing [Info] line is caught, because the release has nothing to stamp', () => {
const problems = check(source({ info: ' [Info("RunicNpc", "Runic Gateway", "0.0.0")]' }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /exactly one \[Info\("RunicNPC", "Runic Gateway", "…"\)\] attribute, found 0/)
})
test('two [Info] lines are caught', () => {
const problems = check(source({ info: `${INFO_LINE}\n${INFO_LINE}` }), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /found 2/)
})
// ── Chat commands ──────────────────────────────────────────────────────────
//
// None exist until stage 3 (`/rnpc`), so these run on fixtures only. Stage 3
// adds the real-plugin assertion below, as the bridge's checker has.
const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args'
function withChat(name, signature, returns = 'void') {
return source({
expected: [],
methods: ` [ChatCommand("${name}")]
private ${returns} CmdThing(${signature})
{
}`,
})
}
test('a correctly shaped chat command passes', () => {
assert.deepEqual(check(withChat('rnpc', GOOD_SIGNATURE), toml()), [])
})
test('a chat command taking the wrong player type is caught', () => {
const problems = check(withChat('rnpc', 'IPlayer player, string command, string[] args'), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/)
})
test('a chat command that returns something is caught', () => {
const problems = check(withChat('rnpc', GOOD_SIGNATURE, 'object'), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /returns object, not void/)
})
test('two chat commands answering to one name is caught', () => {
const both = source({
expected: [],
methods: ` [ChatCommand("rnpc")]
private void CmdOne(${GOOD_SIGNATURE})
{
}
[ChatCommand("rnpc")]
private void CmdTwo(${GOOD_SIGNATURE})
{
}`,
})
const problems = check(both, toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /declared twice/)
})
// ── The real plugin ────────────────────────────────────────────────────────
const ROOT = path.resolve(__dirname, '..')
const real = fs.readFileSync(path.join(ROOT, 'plugin', 'RunicNPC.cs'), 'utf8')
const realToml = fs.readFileSync(path.join(ROOT, 'plugin.toml'), 'utf8')
test('the parser actually reads the real plugin, rather than quietly matching nothing', () => {
const methods = readMethods(real)
const names = new Set(methods.map((m) => m.name))
// Raise these floors as the plugin grows; they are what stops a regex that
// matches nothing from passing every case above.
assert.ok(methods.length >= 40, `only found ${methods.length} methods in the real plugin`)
assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser')
assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser')
const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name)
assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call')
assert.ok(api.includes('RunicNpc_Spawn'), 'RunicNpc_Spawn was not found as an API call')
assert.ok(readExpectedHooks(real).length >= 1)
assert.deepEqual(readRequires(real), ['Kits'])
})
test('the real plugin and plugin.toml pass', () => {
assert.deepEqual(check(real, realToml), [])
})