docs(runicnpc): stage 5 built, tested and walked; API 4; protocol 13 §19.13

- runicnpc/PLAN.md: stage 5 as built, what the build found that the spike
  did not (Rust's CanSeeTarget is no line of sight for an NPC target; a
  destroyed escort target compares equal to null; a rocket's line of
  sight), the harness results on both rigs, and the site walk;
- runicnpc/API.md: API 4 (the faction table, escort, ally, tether, the
  profile's stage 5 fields, a placement's tether, OnRunicNpcEscortEnded,
  the new rows of RunicNpc_List, rnpc.faction, /rnpc follow, tether=);
- rust-link/PROTOCOL.md §19.13: the faction table on npc.profiles and
  npc.profiles.set, world.place's escort, ally and tether, the three new
  world.error reasons; the bridge needs RunicNPC API 4. Joins protocol 13
  (D253).

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 01:16:34 -05:00
parent eb729f7dd4
commit 8ef7da034f
3 changed files with 209 additions and 15 deletions

View File

@@ -1,9 +1,12 @@
# RunicNPC — the API
**API version 3** (RunicNPC stage 4, 2026-09-30). This is the reference for other plugins. Why it has this
shape is in [PLAN.md](PLAN.md) §4 and the decisions D221–D238 and D249. **API 3 added** `RunicNpc_AddPlacement`,
`RunicNpc_RenamePlacement`, `RunicNpc_RespawnPlacement` and the hook `OnRunicNpcPlacementChanged`, for a
website that lists, edits and creates placements (PLAN.md stage 4); nothing of API 2 changed.
**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
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**
`RunicNpc_AddPlacement`, `RunicNpc_RenamePlacement`, `RunicNpc_RespawnPlacement` and the hook
`OnRunicNpcPlacementChanged` (stage 4).
RunicNPC is called by name through `Call`, like any Oxide or Carbon plugin API. Every call is prefixed
`RunicNpc_`, so no hook of another plugin is ever matched by accident. Every call is a non-public method, which is
@@ -13,7 +16,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 < 3) { /* too old for this caller: say so */ }
if (api < 4) { /* too old for this caller: say so */ }
BasePlayer npc = RunicNPC.Call("RunicNpc_Spawn", position, "warden", "plugin:MyPlugin", null) as BasePlayer;
```
@@ -43,7 +46,7 @@ Every NPC has an owner, and the owner decides its lifetime (PLAN.md §2).
### `RunicNpc_ApiVersion()` → `int`
The API version: `3`.
The API version: `4`.
### `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer`
@@ -85,11 +88,20 @@ The live NPCs of `owner`; null lists them all. Each has:
| `state` | `string` | Rust's AI state: `Idle`, `Roam`, `Chase`, `Combat` and so on. |
| `leashes` | `int` | How many times it has given up a chase at its chase range. |
| `regrounded` | `int` | How many times its ground went from under it and it was put on the nearest navmesh (D239). |
| `faction` | `string` | API 4. Its faction, or `profile:<name>` when its profile has none (D256). |
| `target` | `string` | API 4. The prefab of the NPC, animal or turret it is fighting itself, or null (players are Rust's design's). |
| `engaged` | `bool` | API 4. Whether it is fighting that target now (Rust's design fights players first). |
| `escort` | `string` | API 4. Whom it escorts: a player's Steam id, or an entity's prefab; null for none. |
| `ally` | `string` | API 4. `clan:<id>`, `team:<id>` or `player:<steam id>`, or null. |
| `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). |
### `RunicNpc_Profiles()` → `JObject`
`{"managed": bool, "profiles": {name: profile, …}, "refused": {name: reason, …}}`. A refused profile is kept in
the file but never spawns (a kit the server lacks, for example).
`{"managed": bool, "profiles": {name: profile, …}, "factions": [pair, …], "refused": {name: reason, …}}`. A
refused profile is kept in the file but never spawns (a kit the server lacks, for example). `factions` is the
faction table (API 4).
### `RunicNpc_SetProfiles(JObject all)` → `Dictionary<string, string>`
@@ -165,6 +177,40 @@ Whether `entity` is one of RunicNPC's NPCs.
Its profile's name, or null.
### `RunicNpc_Factions()` → `JArray`
API 4. The faction table (D254): `[{"a": "bandits", "b": "guards", "relation": "hostile"}, …]`, one row per pair,
both ways (D268). A pair not listed is neutral, and a faction is always allied to itself.
### `RunicNpc_SetFactions(JArray pairs)` → `string`
API 4. Replaces the faction table, as a site does with every profile push, and **marks the server managed**
(D221). Null on success; otherwise the first problem, and the old table is kept. Refused: a pair of one faction
with itself, a pair of `scientists` and `animals` (RunicNPC does not change how Rust's NPCs treat each other), a
relation other than `hostile`, `neutral` or `allied`, and a pair given twice in either order.
### `RunicNpc_Escort(ulong netId, BaseEntity target)` → `string`
API 4 (D258, D269). One of ours escorts `target`: a player, or any other entity (a crate it guards, a vehicle).
It keeps within a few metres, never targets it, and fights whoever damages it. **When the target dies, is
destroyed, or (a player) leaves the server, it walks back to its spot**, carries on as its profile does there, and
`OnRunicNpcEscortEnded` is raised. A null `target` stops it. Null on success; refused for a sentry (it never
moves) and for a player who is not on the server.
### `RunicNpc_Ally(ulong netId, string kind, string id)` → `string`
API 4 (D257, D270). One of ours is allied to `clan` (the game's own clan id), `team` (a Rust team id), or
`player` (a Steam id: that player, and their team if they have one), or to nobody with `none`. It **never
targets** the ally's people, and **defends them and what they own** (building blocks, doors, deployables): whoever
damages one of them within its leash is fought. Rust's own clans and teams only. Null on success; refused for a
clan or team the server does not have.
### `RunicNpc_Tether(ulong netId, string zone)` → `string`
API 4 (D272). Holds one of ours inside a ZoneManager zone: it never chases or wanders out, and if it ends up
outside (pushed, or the zone moved) it forgets the fight and walks home. Null frees it. Refused for a sentry, on a
server without ZoneManager, for a zone the server does not have, and for a zone that does not hold the NPC.
### `RunicNpc_CostWarning(int adding)` → `string`
The cost warning (D227) for the NPCs the server plans, plus `adding`. It is built from stage 1's measurements
@@ -184,6 +230,7 @@ All are called on every plugin, and **none of them answers**: a return value is
| `OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)` | When it dies. `contributors` maps each player's Steam id to the health that player took from it, the killing blow included, after Rust's protection. It never adds up to more than its health. |
| `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). |
---
@@ -205,7 +252,14 @@ All are called on every plugin, and **none of them answers**: a return value is
"ranges": { "sense": 30, "loseTarget": 40, "chase": 40, "attack": 30 },
"visionCone": -0.8,
"sleepDistance": 160,
"healthThresholds": [0.5]
"healthThresholds": [0.5],
"faction": "bandits",
"relations": { "scientists": "hostile" },
"alertRadius": 40,
"turrets": "default",
"hurtByPlayers": true,
"hurtsPlayers": true,
"kitUse": { "heal": false, "grenades": false, "melee": false, "rockets": false, "flamethrower": false }
}
```
@@ -214,7 +268,7 @@ 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` or `sentry` | A sentry never moves, and may stand off the navmesh. |
| `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. |
| `movement.radius` | above 0 for `wander` | How far from home it wanders. |
| `health` | above 0 | Its health, and its maximum. |
@@ -228,6 +282,17 @@ All are called on every plugin, and **none of them answers**: a return value is
| `visionCone` | −1 to 1 | Rust's vision cone. |
| `sleepDistance` | 0 or more | D235: with no player this close it walks home, then sleeps. 0: it never sleeps. |
| `healthThresholds` | fractions between 0 and 1 | Where `OnRunicNpcHealth` is raised. |
| `faction` | API 4. null, or 1–40 of a-z, 0-9, _ and -, not `scientists` or `animals` | D254. Profiles of one faction are allies. With none, its allies are its own profile's NPCs (D256). |
| `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. |
| `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). |
**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
fights back, whatever the table says (D268).
### A placement
@@ -247,6 +312,9 @@ All are called on every plugin, and **none of them answers**: a return value is
- `respawn` is in seconds, at least 1. `respawnMode` is `each` (every NPC returns `respawn` after its own death)
or `group` (none returns until all are dead, then all return together) (D236).
- `movement` is optional and overrides the profile's.
- `tether` (API 4) is optional: a ZoneManager zone its NPCs never leave (D272). Setting a placement with a tether
the server does not have, or one that does not hold the spot, is refused; a placement whose zone later goes waits,
as one whose profile has gone does (D237).
### A route
@@ -261,7 +329,7 @@ route is walked back and forth. An NPC joins the route at its nearest point.
## Files and config
- `data/RunicNPC/profiles.json` (`managed` and `profiles`), `placements.json` and `routes.json`, under `oxide/`
- `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.
- `config/RunicNPC.json`:
@@ -269,4 +337,10 @@ route is walked back and forth. An NPC joins the route at its nearest point.
sets it** (D227).
- `spawnBudgetMs`: how many milliseconds of each server frame placements may spend spawning. The default is 8.
- `rnpc.status` at the console reports the swap's field check, the NPCs by owner and rest, profiles refused,
placements waiting, the caps, and the cost warning.
placements waiting, the caps, and the cost warning; from API 4 also the faction pairs, the scientists and animals
it tracks, how many of ours are fighting an NPC, the alarms and defences raised, what its own fight costs a
think, and whether the sentries are patched (`turrets: always`).
- `rnpc.faction list | set <a> <b> hostile|neutral|allied | clear <a> <b>` (console) edits the faction table on a
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.

View File

@@ -8,7 +8,8 @@ decided with it. **Stage 1 measured 2026-09-30** (§9): six answers on both rigs
(§0). **Stage 3 built and tested 2026-09-30** on both rigs (§9); its in-game walk waits for a player. **Stage 4's
design answered 2026-09-30** (D243–D252, §0), **built and walked 2026-09-30** on both rigs (§9). **Stage 5's
design answered 2026-09-30 and 2026-10-01** (D253–D266, §0), **its spike measured 2026-10-01** on both rigs (§9),
and **its build's own questions answered 2026-10-01** (D267–D272).
and **its build's own questions answered 2026-10-01** (D267–D272). **Stage 5 built and tested 2026-10-01, walked on the
site 2026-10-05** on both rigs (§9); its API is [API.md](API.md) version 4.
RunicNPC is Runic Gateway's own NPC plugin for Rust servers, in its own repository,
[`RunicGateway/runicnpc-rust`](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust). It runs on Oxide and
@@ -997,6 +998,82 @@ What this adds to the build (D263–D266):
RunicNPC itself.
6. **Where a tether is set. → D272:** on a placement and on an event step, not on a profile.
**Built and tested (2026-10-01).** Five PRs and this one, on `edge` (D226) except `docs`: runicnpc-rust
(API 4), Rust-Plugins (the bridge), Module-Rust (the site), and `docs`. Rust-Link needs nothing: it forwards
`npc.profiles.set` and `world.place` as they come. The installer needs nothing either: it reads the API floor
from each release's manifest, and the bridge's is now 4.
| Piece | As built |
|---|---|
| Profiles | `role` gains `guard`. New: `faction`, `relations` (its own exceptions), `alertRadius` (40), `turrets` (`default`), `hurtByPlayers` and `hurtsPlayers` (true), `kitUse` (all off). With none set, a profile fights players only (D255). [API.md](API.md) has the rules. |
| The faction table | In `profiles.json` beside the profiles, one row per pair, both ways (D268). `RunicNpc_Factions` and `RunicNpc_SetFactions` read and replace it; `rnpc.faction` edits it on a standalone server. On the site: one table for the whole site, on the NPC profiles page, pushed to every server with its profiles, and a standalone server's own pairs adopted at its first push, the site's winning (D244, D251). |
| Whom it fights | Rust's design still owns every fight with a player. Ours senses only the factions it hunts, from a registry kept as scientists, animals and ours spawn and die, within its sense range of itself **and its chase range of its spot** (D263); the nearest three get a line-of-sight test, once a second. Its target is made `Horror` for the length of its own shot only (the spike's fix and its nesting guard). |
| Shooting back, the alarm | Whoever shoots it is fought back, whatever the table says (D268): an NPC, an animal or a turret by our fight, a player by Rust's own. Allies within the alert radius learn the attacker, seen or not (D256). |
| Allies | `RunicNpc_Ally`: a clan (the game's clan id), a team, or a player and that player's team (D270). Their people are never targets (our NPC re-implements `IAISenses.IsTarget`), and whoever damages them or what they own (building blocks, doors, deployables) within its leash is fought (D257), from `OnEntityTakeDamage`, which does nothing while no NPC has an ally or an escort. |
| Escort | `RunicNpc_Escort` and `/rnpc follow`: an order any roamer or guard takes (D269). Its roam state follows the charge (within 4 m, running past 12 m), its leash and home follow the charge, and when the charge dies, is destroyed or leaves, it walks back to its spot and `OnRunicNpcEscortEnded` is raised. |
| The guard | Holds its spot facing the way it was placed, chases within its chase range, and walks back. |
| The tether | `RunicNpc_Tether`, a placement's `tether` and `/rnpc place tether=` (D272): ZoneManager's `IsPositionInZone` bounds its wander, chase and fight, and outside the zone it forgets the fight and walks home. A placement whose zone has gone waits (D237). |
| Turrets | `ignore`: `CanBeTargeted` answers false. `always`: the Harmony patch on `NPCAutoTurret.Ignore` and `IsEntityHostile` (D267), patched at load and reported by `rnpc.status`. `default` needs nothing. |
| The kit's extras | Each opted in, each used up (D260, D265): a syringe, medkit or bandage below half health after 3 s unhurt; melee within 2.5 m; a flamethrower within 7 m, burning the kit's fuel once its own tank is empty; Rust's own grenade throw at 5–20 m; a rocket at 15–80 m on an arc, launched by RunicNPC (Rust's AI never fires one). The main weapon (the belt's first slot) is always used and never runs out. |
| PVE | `CanEntityTakeDamage` answers true both ways with players unless a profile turns a direction off (D261). "Players can hurt it" off zeroes the damage in its own `Hurt`; "it can hurt players" off cancels the damage in `OnEntityTakeDamage`. RunicNPC enforces both itself, so NextGenPVE's ignoring of "deny" does not matter (D271). |
| The bridge | `npc.profiles` and `npc.profiles.set` carry `factions`; `world.place` with a profile takes `escort`, `ally` and `tether`, all or nothing ([PROTOCOL.md §19.13](../rust-link/PROTOCOL.md)). `runicnpc_api = 4`. |
| The site | The profile form's Sides, Turrets, PVE and kit-extras sections and the guard role; the faction table; the Place NPCs step's `escort`, `allyClan` (from the new `rust.options.clans` source), `allyTeamOf` and `tether`; the placement form's "keep inside zone". |
| Cost | The cost warning gains a line for NPCs that fight NPCs (D227). `rnpc.status` reports what our own fight costs a think: **0.27 ms** for the fight and **0.60 ms** a sense over a full test run on the Oxide rig, the spike's order. |
**Three things the build found that the spike did not:**
1. **Rust's `CanSeeTarget` is no line of sight for an NPC target.** Its rays are unbounded, so on open ground
they run past the target into the terrain behind it, and a probe NPC walked right up to a scientist it had
been shooting at from 30 m. Our fight uses a bounded line to the target's centre instead. The spike's
prototype used Rust's, and its fights were all at 15 m.
2. **A destroyed escort target compares equal to null** (Unity's own `==`), so the first build never noticed a
charge that was killed. It now tests the reference itself.
3. **A rocket aimed at a player needs Rust's own sensed line of sight**, and one aimed at an NPC needs ours:
an NPC is never in Rust's player memory.
**Tested by `tools/RunicNpcTest.cs` 0.5.0** (`rnt.run s5`, 52 checks), and with stages 2–4 again (`rnt.run all`):
| | Oxide | Carbon |
|---|---|---|
| `rnt.run all` (stages 2–5) | 206/207 | 206/207 |
| `rnt.run s5` alone, after the last fix | **52/52** | **52/52** |
What `s5` covers: the faction table's four refusals; red and blue (hostile in the table) both hit; a hunter
of `scientists` kills one; a profile with no faction settings beside a scientist never fights it (D255);
a leash of 8 m never reaches a scientist 25 m off (D263); a profile shot by another fights back (D268); allies
within 40 m learn a shooter and one 60 m off does not (D256); an ally player is never targeted while the same
profile allied to nobody targets them, and a raider of the ally's person or foundation is learned (D257); an
escort follows its charge 35 m and walks home when it dies, raising the hook (D269); a guard returns to its post
and holds it; a 12 m zone holds a 15 m wanderer, a zone that does not exist is refused, and an NPC put outside
walks back in (D272); the PVE answers and both opt-outs (D271); `ignore` through the hook; **the map's own
sentries**: an `always` NPC is targeted and killed, a `default` one beside it is untouched, and **no sentry on
the map ever targeted a stock scientist or bandit guard** (the org lead's condition, D267, sampled four times a
second across all 85–94 sentries); the five kit extras each used and used up, and none used by a profile that
did not opt in; and **no entity is left `Horror`** afterwards.
The one failure in each full run was the escort test's layout, not the escort: it walked the charge towards the
stand-in that keeps the field awake, and Rust's design then fought that player instead of walking home. Walked east
instead, it passes; a probe showed the escort home 0.4 m from its spot 16 s after its charge died. Stages 2–4 were
unchanged on both frameworks.
**Walked on the site (2026-10-05)**, signed in as an admin at the walk core: the NPC profiles page with its new
sections and faction table; **bandits ↔ guards: hostile** saved and pushed, and `rnpc.faction list` on the Oxide rig
showing it; the Bandit profile given a faction, `scientists: hostile`, a 30 m alert radius, `turrets: ignore` and
the heal extra, and `rnpc.profile show bandit` holding exactly that; the Carbon guard made a `guard` of faction
`guards` and pushed; a placement given a zone the server lacks, refused on the form in RunicNPC's words; and an
event (a zone, then two guards with `tether` and `allyTeamOf`) whose NPCs on the Carbon rig carried faction
`guards`, ally `player:76561198000000002` and the run's own zone as their tether, and were given back at
teardown. An escort who was not on the server failed the step with the bridge's sentence and was retried, as
`escort-offline` is. **Not walked:** a clan ally with a live clan (neither rig has one now; the picker correctly
offers none), and anything that needs a player in game (escort and a clan's defence for real, the org lead's
walk, as the plan says).
**Stand-ins are poor players for Rust's own senses**, which the harness works around rather than fixes:
Rust often notices only one of two stand-ins close together, and `CanSeeTarget` never sees one. The checks
about what happens once a player is known hand the stand-in to the NPC's memory with Rust's own `SetKnown`,
which asks our `IsTarget`. An NPC more than 160 m from every stand-in sleeps (D235), which is correct and which
the first runs mistook for "no fight".
### Stage 6 — Roles
Bosses (a health bar drawn with CUI, phases at thresholds that swap profile values, announcements through the bridge