feat: stage 2, the NPC and its API (API 2)
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
This commit is contained in:
2026-09-30 01:31:04 -05:00
parent 914047f1b0
commit f8084955e8
9 changed files with 2937 additions and 42 deletions

View File

@@ -179,7 +179,11 @@ function check(source, toml) {
}
const methods = readMethods(source)
const hooks = methods.filter((x) => HOOK_NAME.test(x.name))
// 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.

View File

@@ -126,6 +126,25 @@ test('a method that is not shaped like a hook is left alone', () => {
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', () => {
@@ -287,12 +306,13 @@ test('the parser actually reads the real plugin, rather than quietly matching no
// 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 >= 5, `only found ${methods.length} methods in the real plugin`)
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'])