All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m38s
RunicNPC now spawns its own NPC: Rust's scientist with its ScientistNPC and ScientistBrain swapped for ours, copying a fixed field list generated from Rust's unmodified assembly (D232, tools/fieldlist). Profiles live in data/RunicNPC/profiles.json in the approved shape (D238); placements persist with each/group respawn (D236) and wait for a missing profile or route (D237); roamers wander, follow Rust's monument paths, or walk a route (D233, D234), with our own chase where Rust's needs an AI zone; sentries hold their spot; NPCs walk home and sleep past 160 m of any player (D235). Spawns wait for the navmesh and are spread over frames; caps are off by default and the cost warning is shown instead (D227). The whole PLAN.md section 4 API and its hooks are in, documented in docs/runicnpc/API.md. tools/RunicNpcTest.cs is the stage 2 harness; every group passed on both rigs, and after a plugin reload and a server restart. checkPlugin no longer counts an override (RunicNpcPlayer.OnDied) as a hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
324 lines
12 KiB
JavaScript
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 CanBeTargeted(BaseCombatEntity entity, AutoTurret turret)
|
|
{
|
|
return true;
|
|
}`
|
|
|
|
const problems = check(source({ expected: ['CanBeTargeted'], methods }), toml())
|
|
assert.match(problems[0], /CanBeTargeted 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), [])
|
|
})
|