docs(runicnpc): stage 6 built and walked; API 5; protocol §19.14

API.md version 5: the profile's boss and use blocks, the passive role
and stand, the boss hooks and OnRunicNpcUsed, role and boss in
RunicNpc_List, RunicNpc_Despawn taking a boss's adds, the example kits
written once, and a Kits reload taking RunicNPC with it (D283).

PROTOCOL.md §19.14: npc.boss.spawned, npc.boss.phase and npc.boss.died,
their classes, and runicnpc_api 5.

PLAN.md stage 6: what was built, the builds it was tested on, the
harness and the site walk on both rigs, and what building it found.
The example kits' permission is kits.runicnpc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-05 13:12:16 -05:00
parent 023ba77ceb
commit 6d319c843b
3 changed files with 174 additions and 12 deletions

View File

@@ -1,7 +1,10 @@
# RunicNPC — the API
**API version 4** (RunicNPC stage 5, 2026-10-01). This is the reference for other plugins. Why it has this
shape is in [PLAN.md](PLAN.md) §4 and the decisions D221–D238, D249 and D253–D272. **API 4 added** the faction
**API version 5** (RunicNPC stage 6, 2026-10-05). This is the reference for other plugins. Why it has this
shape is in [PLAN.md](PLAN.md) §4 and the decisions D221–D238, D249, D253–D272 and D273–D289. **API 5 added**
the profile's `boss` and `use` blocks and the `passive` role with its `stand` movement, the hooks
`OnRunicNpcBossSpawned`, `OnRunicNpcBossPhase`, `OnRunicNpcBossDied` and `OnRunicNpcUsed`, and `role` and `boss`
in `RunicNpc_List` (PLAN.md stage 6). Nothing of API 4 changed shape. **API 4 added** the faction
table (`RunicNpc_Factions`, `RunicNpc_SetFactions`), orders for one NPC (`RunicNpc_Escort`, `RunicNpc_Ally`,
`RunicNpc_Tether`), the hook `OnRunicNpcEscortEnded`, the profile's stage 5 fields, a placement's `tether`, and
more in `RunicNpc_List` (PLAN.md stage 5). Nothing of API 3 changed shape. **API 3 added**
@@ -16,7 +19,7 @@ what Oxide's `Call` reaches by name (PLAN.md §1.2).
[PluginReference] private Plugin RunicNPC;
int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0;
if (api < 4) { /* too old for this caller: say so */ }
if (api < 5) { /* too old for this caller: say so */ }
BasePlayer npc = RunicNPC.Call("RunicNpc_Spawn", position, "warden", "plugin:MyPlugin", null) as BasePlayer;
```
@@ -46,7 +49,7 @@ Every NPC has an owner, and the owner decides its lifetime (PLAN.md §2).
### `RunicNpc_ApiVersion()` → `int`
The API version: `4`.
The API version: `5`.
### `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer`
@@ -66,7 +69,9 @@ set, or a navmesh that is not built yet.
### `RunicNpc_Despawn(ulong netId)` → `int`
Removes one NPC by its net id. Returns 1, or 0 if it is not one of RunicNPC's. A placement's NPC comes back after
its respawn delay.
its respawn delay. **For a boss (API 5), it also removes the adds that boss summoned, even once the boss is dead**,
and counts them in what it returns: an event's teardown removes what its step placed by net id, and a boss's adds
are in no step's ledger (D287).
### `RunicNpc_DespawnOwner(string owner)` → `int`
@@ -96,6 +101,8 @@ The live NPCs of `owner`; null lists them all. Each has:
| `tether` | `string` | API 4. The ZoneManager zone it is held in, or null. |
| `shotsMarked` | `int` | API 4. Shots it made at an NPC target (each marks the target `Horror` for that shot only). |
| `kitUsed` | `object` | API 4. `heals`, `throws`, `rockets` and `meleeHits`: the kit's extras it has used (D260). |
| `role` | `string` | API 5. Its profile's role: `roamer`, `sentry`, `guard` or `passive`. |
| `boss` | `object` | API 5. For a boss, `phase` (0 before the first, then 1, 2 …) and `damagers` (how many players have hurt it); null for any other NPC. |
### `RunicNpc_Profiles()` → `JObject`
@@ -231,6 +238,10 @@ All are called on every plugin, and **none of them answers**: a return value is
| `OnRunicNpcDespawned(BasePlayer npc, string owner)` | When it is removed without dying. Not raised as RunicNPC itself unloads. |
| `OnRunicNpcPlacementChanged(string id, string change, string previous)` | API 3. Whenever a placement is set, removed or renamed, through the API or in game. `change` is `set`, `removed` or `renamed`; `previous` is the old name for a rename and null otherwise. |
| `OnRunicNpcEscortEnded(BasePlayer npc, string profile, string owner)` | API 4. Whom it escorted died, was destroyed or left the server, and it is walking back to its spot (D269). |
| `OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner)` | API 5. After `OnRunicNpcSpawned`, for a profile with a `boss` block. Its spawn line has been said. |
| `OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at)` | API 5. It entered a phase: `phase` counts from 1, `at` is the phase's health fraction. Raised after the phase's changes are applied and its line is said. A hit that crosses two thresholds raises both, highest first. |
| `OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)` | API 5. After `OnRunicNpcDied`, for a boss, with the same contributors. Its death line has been said. RunicNPC gives no reward itself (D276, D285): an event's own reward steps do. |
| `OnRunicNpcUsed(BasePlayer npc, string profile, BasePlayer player)` | API 5. A player pressed E on a passive NPC and was answered (D278). At most once a second per player. |
---
@@ -259,7 +270,29 @@ All are called on every plugin, and **none of them answers**: a return value is
"turrets": "default",
"hurtByPlayers": true,
"hurtsPlayers": true,
"kitUse": { "heal": false, "grenades": false, "melee": false, "rockets": false, "flamethrower": false }
"kitUse": { "heal": false, "grenades": false, "melee": false, "rockets": false, "flamethrower": false },
"boss": {
"barDistance": 100,
"announce": "chat",
"spawnLine": "{name} has risen!",
"deathLine": "{name} has fallen to {killer}.",
"phases": [
{ "at": 0.5, "adds": { "profile": "raider", "count": 3 }, "line": "{name} calls for help!" },
{ "at": 0.25, "damageDealt": 1.5, "speed": 1.3, "kit": "warden_heavy", "line": "{name} is enraged!" }
]
},
"use": null
}
```
A passive profile has no `boss` and a `use` block:
```json
{
"names": ["Quartermaster"], "kits": ["outpost_clothes"], "role": "passive",
"movement": { "mode": "stand", "radius": 0 },
"hurtByPlayers": false,
"use": { "mode": "chat", "lines": ["Back again, {player}?", "Supplies are short."], "title": "", "text": "" }
}
```
@@ -268,8 +301,8 @@ All are called on every plugin, and **none of them answers**: a return value is
| `names` | at least one | One is picked for each NPC. |
| `kits` | at least one; each must exist in Kits | One is picked for each NPC, and it is equipped with it (D217). |
| `prefab` | a Rust `scientistnpc_*` prefab that is a plain scientist | What it is built from. |
| `role` | `roamer`, `sentry` or `guard` | A sentry never moves, and may stand off the navmesh. A guard (API 4) holds its spot facing the way it was placed, chases within its chase range, and walks back. |
| `movement.mode` | `wander`, `monument` or `route:<name>` | D233. A placement may override it. |
| `role` | `roamer`, `sentry`, `guard` or `passive` | A sentry never moves, and may stand off the navmesh. A guard (API 4) holds its spot facing the way it was placed, chases within its chase range, and walks back. A passive NPC (API 5) never fights: it takes no target, does not shoot back and raises no alarm, and pressing E on it does what its `use` says (D278, D279). |
| `movement.mode` | `wander`, `monument`, `route:<name>`, or `stand` for a passive profile only | D233. A placement may override it. A passive NPC that stands (D288) holds its spot as a sentry does, may stand off the navmesh, and turns to whoever presses E on it. |
| `movement.radius` | above 0 for `wander` | How far from home it wanders. |
| `health` | above 0 | Its health, and its maximum. |
| `damageDealt` | 0 or more | Multiplies its weapon's damage; 1 is the weapon's full damage. |
@@ -286,9 +319,25 @@ All are called on every plugin, and **none of them answers**: a return value is
| `relations` | API 4. keys: a faction, `scientists`, `animals` or `profile:<name>`; values `hostile`, `neutral` or `allied` | Its own exceptions, which win over the faction table (D254). **A profile with no faction settings fights players only** (D255). |
| `alertRadius` | API 4. 0 or more; default 40 | D256. Allies this close learn whoever attacked one of them, seen or not. 0 is off. |
| `turrets` | API 4. `default`, `ignore` or `always` | D259, D264, D267. `default`: as Rust's scientists (player turrets shoot it, safe-zone sentries do not). `ignore`: no turret targets it. `always`: Outpost's and Bandit Camp's sentries too, through a Harmony patch that answers for these NPCs only. It shoots back at a turret that shoots it. |
| `hurtByPlayers`, `hurtsPlayers` | API 4. true or false; default true | D261, D271. Off makes it unkillable by players, or harmless to them. RunicNPC enforces both itself, and answers TruePVE's and NextGenPVE's `CanEntityTakeDamage` the same way. Turrets, fire and other NPCs are not affected. |
| `hurtByPlayers`, `hurtsPlayers` | API 4. true or false; default true, **except `hurtByPlayers` for a passive profile, which is false unless it is given** (API 5, D279) | D261, D271. Off makes it unkillable by players, or harmless to them. RunicNPC enforces both itself, and answers TruePVE's and NextGenPVE's `CanEntityTakeDamage` the same way. Turrets, fire and other NPCs are not affected. |
| `kitUse` | API 4. `heal`, `grenades`, `melee`, `rockets`, `flamethrower`, each true or false | D260. The kit's extras it uses, each only if true and each used up from its inventory. The main weapon (the belt's first slot) is always used and never runs out (D265). |
| `boss` | API 5. null, or the block below; not on a passive profile | D273. Makes any roamer, sentry or guard a boss, which keeps moving and fighting as its role does. |
| `boss.barDistance` | above 0, at most 1000; default 100 | D274. Players this close see its health bar at the top of the screen, and so does every player who has hurt it, wherever they are, until it dies or despawns. A player near two bosses sees the nearer. |
| `boss.announce` | `chat` or `popup`; default `chat` | D277. Where its lines are said. `popup` uses PopupNotifications where it is loaded and chat where it is not. |
| `boss.spawnLine`, `boss.deathLine` | at most 256 characters; blank says nothing | D286. Said to everyone on the server. `{name}` is its name; in the death line `{killer}` is the killing blow's player (empty when no player). |
| `boss.phases` | a list; each `at` a fraction between 0 and 1, no two alike | D275. When its health falls to `at`, the phase applies from then on. Every other part is optional. |
| `phases[].damageDealt`, `aimCone` | 0 or more | Replace the profile's. |
| `phases[].speed` | above 0, at most 5 | A multiple of Rust's own speed for it: 1.3 is a third faster. |
| `phases[].ranges` | as the profile's `ranges` | Replace the profile's. |
| `phases[].kit` | a Kits kit the server has | A kit swap: its inventory is stripped, the kit given and its weapon equipped. |
| `phases[].adds` | `profile`: a profile that is not a boss; `count`: 1 to 20 | NPCs of that profile placed on a 6 m ring around it, one per server frame. They belong to its owner (an event's teardown removes them) and fight on after it dies (D287). |
| `phases[].line` | at most 256 characters | D286. Said only to the players near it (within `barDistance`) and to those who have hurt it. |
| `use` | API 5. required on a passive profile, refused on any other | D278. What pressing E on it does, from up to 3 m: `mode` `chat` says one of `lines` (picked at random, each at most 256 characters) in that player's chat; `mode` `window` opens a window with `title` (blank: its name), `text` (at most 2,000 characters) and a Close button. `{player}` in either is the player's name. |
A phase changes **that boss's own copy** of its profile; another NPC of the same profile is not touched. A boss gives
no reward itself (D276, D285).
**Whom it fights besides players** is its own sensing (stage 5): only the factions it is hostile to, within its
sense range of itself **and its chase range of its spot** (D263), so it never walks into a monument to look for
scientists. Rust's scientists never fight back (Rust's own rule); they take cover. Whoever shoots one of ours, it
@@ -332,6 +381,14 @@ route is walked back and forth. An NPC joins the route at its nearest point.
- `data/RunicNPC/profiles.json` (`managed`, `profiles` and, from API 4, `factions`), `placements.json` and `routes.json`, under `oxide/`
or `carbon/`. On a standalone server profiles are edited in the file, then `rnpc.reload` reads them again. On a
managed one the site pushes them.
- `data/RunicNPC/state.json` (API 5): `examplesWritten`, set on the first load. That load writes the four example
kits (`rnpc_raider`, `rnpc_campguard`, `rnpc_sniper`, `rnpc_juggernaut`) into Kits' own data file, each where
its name is free and each needing the permission `kits.runicnpc` (Kits' prefix, since Kits registers it), which nobody is granted, so no player can
claim one. On a server no site manages it also adds the four example profiles (`raider`, `campguard`, `sniper`,
`juggernaut`). Then it reloads Kits, which reloads RunicNPC with it. **Nothing is written again, even if you
delete them** (D280, D284, D289).
- **A Kits reload reloads RunicNPC** on both frameworks, because RunicNPC requires Kits, and the NPCs it had spawned
are lost: placements come back, an event's NPCs do not. **Do not reload Kits while an event runs** (D283).
- `config/RunicNPC.json`:
- `caps`: `total`, `perOwner`, `perProfile` and `spawnsPerSecond`. **Each is 0, meaning no cap, until an admin
sets it** (D227).
@@ -344,3 +401,7 @@ route is walked back and forth. An NPC joins the route at its nearest point.
standalone server; refused while a site manages it. `/rnpc follow <placement> <player|me|off>` makes a
placement's live NPCs escort a player, for trying it out (in memory only: a respawn or reload ends it).
`/rnpc place … tether=<zone>` holds a placement inside a zone.
- `rnpc.profile set <name> boss on|none` and `rnpc.profile set <name> use on|none` (API 5) add a profile's boss
box or press-E block with its defaults, or remove it. Their fields are then set one at a time
(`boss.spawnLine The Juggernaut has risen!`), and a list of objects as JSON
(`boss.phases [{"at":0.5,"line":"…"}]`). `role passive` also turns `hurtByPlayers` off (D279).