- README: status is stage 9; the installer and egg install it now; API 6; the swap reports 55/55 npc fields; COMMANDS.md and INSTALL.md are linked; a new "update surface" table lists every Rust type and member the swap and the brain depend on, where, and what catches a change (compile, the field list, or only a running server). The staging drill checks exactly that list. - tools/RunicNpcTest.cs 0.7.0: rnt.cost measures 100 of RunicNPC's own NPCs against the same session's empty server, idle (awake) and fighting 20 invulnerable stand-ins, with a second empty baseline at the end and the bar taken against the lower. `idle` skips the fight, `sentry` makes them stand still, `asleep` leaves out the keeper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
10 KiB
RunicNPC
Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon.
Other plugins drive it through an API, and admins use it directly in game through chat commands. It
works on its own, and on a Runic Gateway server the
website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is
docs/runicnpc/PLAN.md.
Status: stage 9, hardening for v1.0.0. The NPC, its profiles, placements and routes; factions, fights, escorts and tethers; bosses and passive NPCs; loot. Admins use the
/rnpccommands, other plugins the API (version 6). On a Runic Gateway server RunicNPC is required (D310): the website authors its profiles and faction table, edits its placements, and every NPC an event places is RunicNPC's.
Requirements
- Kits — required. A profile names kits, and that is how every
RunicNPC NPC is equipped (D217). The plugin declares
// Requires: Kits, so neither framework loads it without Kits. - Oxide 2.0.7726 or Carbon 2.0.259, or newer: the builds it has been loaded on
(
plugin.toml).
Installing it
On a Runic Gateway server, the installer and the Pterodactyl egg install it from the Rust bundle, pinned and checksummed (D224). On any other server:
- Download
runicnpc-<version>.tar.gzandSHA256SUMSfrom this repository's releases, and check the tarball against it (sha256sum -c SHA256SUMS). - Copy
runicnpc/RunicNPC.csintooxide/plugins/orcarbon/plugins/. The framework compiles and loads it on the write.
runicnpc/manifest.json, beside it, states the release's version, commit, API version, framework
floors, required plugins, and the sha256 of every file it ships.
Checking it
rnpc.status
Answers at the server console and over RCON: the version, the API version, and which of the plugin's hooks have fired. A hook that never fires is the first sign a Rust or framework update has renamed it, because neither framework reports a hook that matches nothing.
In game
/rnpc in chat, and the same verbs as rnpc.<verb> in a console (F1, the server console or RCON).
/rnpc alone lists the ones you may use. Grant runicnpc.place to place and manage NPCs, and
runicnpc.admin for everything (it includes runicnpc.place); the server console has both.
/rnpc place bandit count=3 respawn=300 mode=group move=route:gate
> Placed bandit-1: 3 × 'bandit' (roamer, route:gate), respawn 300 s, group.
/rnpc path record gate then walk, and at each point: /rnpc path point
/rnpc path save loop (or back, to walk it back and forth)
/rnpc near · /rnpc info · /rnpc rename bandit-1 gateguards · /rnpc remove
place uses the spot you look at, here the spot you stand on; the server console gives
at=x,y,z. A roamer must stand on Rust's navmesh; only a sentry may stand off it. Every placement
answers with what the server's NPCs cost. On a standalone server, profiles are made from a console
with rnpc.profile create|set|delete. Every command and its permission is in
docs/runicnpc/COMMANDS.md,
and installing and running it in
docs/runicnpc/INSTALL.md.
For other plugins
Every call is prefixed RunicNpc_ and reached through Call:
[PluginReference] private Plugin RunicNPC;
int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0;
The API is version 6, documented in
docs/runicnpc/API.md:
spawning and removing NPCs by owner, profiles and the faction table, placements (created and named as in
game, from a map point), routes, escorts, allies and tethers, bosses, loot, the cost warning, and the hooks it
raises.
The API version moves when a call or a raised hook changes shape, not on every release.
Repository layout
| Path | What |
|---|---|
plugin/RunicNPC.cs |
The plugin. The only file a server gets. |
plugin.toml |
Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. |
scripts/checkPlugin.js |
The static checks run on every pull request and again before a release (see its header). |
tools/ |
Developer scaffolding for the test rigs, never shipped: the panel scripts (see tools/rigs.example.json); RunicNpcHarness.cs, the stage 1 and 5 measurement plugin; RunicNpcTest.cs, the test harness (rnt.run all, then rnt.after after a reload or restart) and stage 9's performance check (rnt.cost, below); and fieldlist/ with managed.js, which regenerate the swap's field list (below). |
After a Rust update: the swap's field list
Our NPC is Rust's scientist with two components swapped, and the swap copies a fixed list of fields (D232),
generated from Rust's unmodified assembly. rnpc.status reports it as swap fields: npc=55/55 brain=32/32 missing=- added=-. On Carbon, where the assembly is unmodified, added names any field Rust has added since
the list was made. To regenerate it:
node tools/managed.js carbon <dir> # the Carbon rig's RustDedicated_Data/Managed
dotnet run --project tools/fieldlist -- <dir> # rewrites the block in plugin/RunicNPC.cs
Use the Carbon rig: Oxide's patcher makes Rust's private fields public, so its assembly no longer says which fields Rust itself serialises.
The update surface: what a Rust update can break
Everything RunicNPC takes from Rust's own classes for the swap and the brain, in one place. A Rust update
that changes any of it breaks RunicNPC; the staging drill (PLAN.md stage 9, D308) checks exactly this list
against every new staging build. Caught by says how: compile means the plugin no longer compiles
against the new assembly; field list means tools/fieldlist's regenerated list differs from the
plugin's (and rnpc.status says missing= or added= at run time); run time means only a running server
shows it, so the drill's live half and rnpc.status are what catch it.
| Rust type | What RunicNPC depends on | Where (plugin/RunicNPC.cs) |
Caught by |
|---|---|---|---|
ScientistNPC (prefabs scientistnpc_*) |
Subclassed by RunicNpcPlayer; the swap replaces the prefab's component before spawn and copies SwapNpcFields (55 fields up its chain to BaseNetworkable) |
#region The swap, CreateNpc |
compile; field list |
ScientistBrain |
Subclassed by RunicNpcBrain; the swap copies SwapBrainFields (32 fields of BaseAIBrain) |
#region The swap |
compile; field list |
ScientistNPC / HumanNPC virtuals |
displayName, AttackerInfo(PlayerLifeStory.DeathInfo), ShotTest(float), TriggerDown(), Hurt(HitInfo), OnDied(HitInfo) overridden |
RunicNpcPlayer |
compile |
IAIAttack, IAISenses |
Re-implemented on RunicNpcPlayer: IsTargetInRange, EngagementRange, GetBestTarget, AttackTick, IsTarget, IsThreat, IsFriendly. Rust's HumanNPC.IsTarget is not virtual, which is why |
RunicNpcPlayer |
compile; a changed meaning only at run time |
BaseAIBrain |
AddStates() and Think(float) overridden; states, CurrentState, Navigator, Senses, Events used; AIState values Roam, Chase, Combat, TakeCover, Cover, MoveTowards, MoveToVector3, FollowPath, NavigateHome, Flee, Blinded replaced or held |
RunicNpcBrain |
compile; a state Rust stops adding only at run time |
BaseAIBrain.BasicAIState |
Subclassed six times (WanderState, RnRoamState, RnGuardState, RouteState, PursueState, HoldState): StateEnter, StateThink, StateLeave |
#region The NPC |
compile |
BaseNavigator |
SetDestination, Stop, Moving, Agent, IsOnNavMeshLink, NavigationSpeed |
the states | compile |
AIMemory / AIBrainSenses |
Memory.Targets, SetKnown, IsLOS, Entity; DelaySenseUpdate |
the states, the fight | compile |
BaseCombatEntity.faction |
Our NPC's target is set to Faction.Horror for the length of our own shot, because BaseProjectile.ServerUse drops NPC-on-NPC hits otherwise (stage 5) |
RunicNpcPlayer.Mark |
run time (the drill's fight checks) |
NPCAutoTurret |
Harmony prefixes on the private Ignore(BasePlayer) and IsEntityHostile(BaseCombatEntity), found by reflection (D267) |
#region Factions, SentryPatch |
run time: rnpc.status says whether the patch applied |
HackableLockedCrate |
The private hackSeconds, set by reflection for a loot table's locked crate (stage 7) |
#region Loot |
run time |
RelationshipManager, ClanManager |
Teams and clans for allies (D257) | #region Factions |
compile |
Rust.Ai.Gen2.RustNavMeshHelpers |
SamplePosition for every placement's navmesh check |
#region Rust's facts |
compile |
The hooks RunicNPC listens to are listed by rnpc.status, which names any that has never fired; one
that stays silent after an update has probably been renamed (neither framework reports a hook that matches
nothing).
Performance: rnt.cost
Stage 9's check (D306): 100 of RunicNPC's NPCs, idle and fighting 20 stand-in players, against the same
session's empty server, on a 6000 map. It passes when they add no more than stage 1 measured, +1.6 ms idle
and +5.2 ms fighting to the median frame. Load tools/RunicNpcTest.cs on a rig (it needs the hidden Kits kit
rnhrevolver), back up data/RunicNPC/ (it replaces the profiles), stop the other rig, and run
rnt.cost [seconds]; the result is in data/RunicNpcTest.json.
Releases
Work lands on edge and is cut over to main; every releasable push to main tags and publishes a
release (.gitea/workflows/release.yml). The version comes from Conventional Commits since the
last tag. v1.0.0 is stage 9's release, the one module-rust then requires.
Contributing
See CONTRIBUTING.md — including the AI-disclosure rule — and report security problems privately as SECURITY.md describes.
License
GPL-3.0-or-later — see LICENSE.md.