docs(runicnpc): stage 2 as built, and API.md

runicnpc/API.md is the reference for other plugins: owners, every
RunicNpc_* call, the four hooks, and the profile, placement and route
shapes. PLAN.md records stage 2's results on both rigs and what building it
found: Rust's chase needs an AI zone like its roam, and a player-built floor
joins the navmesh 0.1-0.3 s after it spawns, which corrects stage 1's Q4
(left open for the org lead). README indexes API.md.

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:44 -05:00
parent 5c44ce437a
commit 1e00c5da6e
3 changed files with 280 additions and 4 deletions

View File

@@ -98,7 +98,8 @@ requires it once it releases (D220).
| Doc | What it covers |
|---|---|
| [PLAN.md](runicnpc/PLAN.md) | **The plan** — what the 2026-09-30 spike found (route A, HumanNPC, NpcSpawn), the org lead's decisions D214–D220, the features, the API, the chat commands, and stages 0–10 with how each is tested |
| [PLAN.md](runicnpc/PLAN.md) | **The plan** — what the 2026-09-30 spike found (route A, HumanNPC, NpcSpawn), the org lead's decisions D214–D238, the features, the API, the chat commands, and stages 0–10 with how each is tested and what each found |
| [API.md](runicnpc/API.md) | **The API** other plugins call (version 2): owners, every `RunicNpc_*` call, the hooks it raises, and the profile, placement and route shapes |
### `android/`
| Doc | What it covers |

234
runicnpc/API.md Normal file
View File

@@ -0,0 +1,234 @@
# RunicNPC — the API
**API version 2** (RunicNPC stage 2, 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.
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
what Oxide's `Call` reaches by name (PLAN.md §1.2).
```csharp
[PluginReference] private Plugin RunicNPC;
int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0;
if (api < 2) { /* too old for this caller: say so */ }
BasePlayer npc = RunicNPC.Call("RunicNpc_Spawn", position, "warden", "plugin:MyPlugin", null) as BasePlayer;
```
**The version moves when a call or a raised hook changes shape**, not on every release. `plugin.toml` in the
repository declares the same number, and the release's `manifest.json` carries it, so an installer or the
bridge can refuse a RunicNPC that is too old before it is loaded.
A call that cannot do what it was asked **returns null (or 0, or false) and says why in the server log**. Calls
that change data return the reason as a string instead, and null means success.
---
## Owners
Every NPC has an owner, and the owner decides its lifetime (PLAN.md §2).
| Owner | Who uses it | Lifetime |
|---|---|---|
| `run:<id>` | An event run, through the bridge | Never saved. Removed by the caller, or by `RunicNpc_DespawnOwner`. |
| `plugin:<name>` | Any other plugin | Never saved. **Removed when that plugin unloads.** Use your plugin's own name. |
| `placement:<id>` | RunicNPC itself | The NPC is never saved; the placement is, and spawns a fresh one at boot. Callers cannot spawn with this owner. |
---
## Calls
### `RunicNpc_ApiVersion()` → `int`
The API version: `2`.
### `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer`
Spawns one NPC now, or returns null.
- `owner` must be `run:<id>` or `plugin:<name>`.
- `overrides` may change any profile value for this NPC only, in the profile's own shape (below). It may be null.
For example: `{"health": 500, "names": ["Captain"], "movement": {"mode": "route:gate", "radius": 0}}`. Arrays
are replaced, not merged. The merged profile is validated like a saved one.
- A roamer must stand on Rust's navmesh: within 3 m across and 2 m up or down of `at`. A sentry may stand
anywhere.
- It is refused until the map's navmesh is built, which takes 8–10 minutes on a map's first boot.
Refusals: an unknown or refused profile, a bad owner, a failed override, a spot off the navmesh, a cap an admin has
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.
### `RunicNpc_DespawnOwner(string owner)` → `int`
Removes every NPC of `owner`, and returns how many it removed.
### `RunicNpc_List(string owner)` → `List<Dictionary<string, object>>`
The live NPCs of `owner`; null lists them all. Each has:
| Key | Type | |
|---|---|---|
| `netId` | `ulong` | |
| `profile`, `name`, `owner`, `kit` | `string` | The kit is the one picked for this NPC. |
| `placement` | `string` | Its placement's id, or null. |
| `position`, `home` | `{x, y, z}` | `home` is where it spawned: where it wanders around, and walks back to before it sleeps. |
| `health`, `maxHealth` | `float` | |
| `movement` | `string` | `wander`, `monument` or `route:<name>`. |
| `rest` | `string` | `Awake`, `GoingHome` or `Asleep` (D235). |
| `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. |
### `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).
### `RunicNpc_SetProfiles(JObject all)` → `Dictionary<string, string>`
Replaces the whole profile set with `all` (`{name: profile, …}`), as a site does on every push, and **marks the
server managed** (D221). It returns the profiles refused and why; the rest are in use at once. A placement whose
profile has gone waits, and spawns again when the profile returns (D237).
### `RunicNpc_Placements()` → `List<Dictionary<string, object>>`
Each placement as `{id, placement, alive, waiting, lastError}`. `placement` is the placement's JSON (below),
`alive` is how many of its NPCs are alive, and `waiting` says why it cannot spawn right now, or is null.
### `RunicNpc_SetPlacement(string id, JObject placement)` → `string`
Adds or replaces a persistent placement (D222). Null on success, or the reason. Replacing one despawns its NPCs and
spawns them again from the new values. Every set logs the cost warning (D227).
### `RunicNpc_RemovePlacement(string id)` → `bool`
Removes a placement and its NPCs.
### `RunicNpc_Routes()` → `JObject`
`{name: route, …}`.
### `RunicNpc_SetRoute(string name, JObject route)` → `string`
Adds or replaces a route (D234). Every point must be on Rust's navmesh. Null on success, or the reason.
### `RunicNpc_RemoveRoute(string name)` → `bool`
Removes a route. Placements that walk it wait until it exists again.
### `RunicNpc_IsRunicNpc(BaseEntity entity)` → `bool`
Whether `entity` is one of RunicNPC's NPCs.
### `RunicNpc_ProfileOf(BaseEntity entity)` → `string`
Its profile's name, or null.
### `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
(PLAN.md §9), and says what the NPCs add to a server frame, idle and fighting, and how slowly each then reacts. A
caller that adds NPCs shows it to whoever added them.
---
## Hooks it raises
All are called on every plugin, and **none of them answers**: a return value is ignored.
| Hook | When |
|---|---|
| `OnRunicNpcSpawned(BasePlayer npc, string profile, string owner)` | After an NPC spawns and is equipped. |
| `OnRunicNpcHealth(BasePlayer npc, string profile, float threshold)` | The first time its health falls to one of the profile's `healthThresholds`. |
| `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. |
---
## Data shapes
### A profile (D238)
```json
{
"names": ["Warden", "Old Warden"],
"kits": ["warden_rifle", "warden_smg"],
"prefab": "scientistnpc_roam",
"role": "roamer",
"movement": { "mode": "wander", "radius": 20 },
"health": 250,
"damageDealt": 1.0,
"damageTaken": { "head": 1.0, "body": 1.0, "legs": 1.0 },
"aimCone": 2.0,
"ranges": { "sense": 30, "loseTarget": 40, "chase": 40, "attack": 30 },
"visionCone": -0.8,
"sleepDistance": 160,
"healthThresholds": [0.5]
}
```
| Field | Rule | Meaning |
|---|---|---|
| `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. |
| `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. |
| `damageDealt` | 0 or more | Multiplies its weapon's damage; 1 is the weapon's full damage. |
| `damageTaken` | each 0 or more | Multiplies the damage it takes to the head, the legs (legs and feet), and the body (everything else, including hits with no body part, like fire and explosions). |
| `aimCone` | 0 or more | How much its aim spreads; Rust's scientists use 2. |
| `ranges.sense` | above 0 | How far it notices players. |
| `ranges.loseTarget` | at least `sense` | How far a target must get before it is forgotten. |
| `ranges.chase` | 0 or more | How far from home it will chase (0: no limit). At that distance it waits 5 s for the target to come within range, then gives up and walks home. |
| `ranges.attack` | above 0 | How far it shoots from. This replaces the weapon's own range. |
| `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. |
### A placement
```json
{
"profile": "warden",
"position": { "x": 100.0, "y": 12.5, "z": -340.0 },
"yaw": 90,
"count": 3,
"respawn": 300,
"respawnMode": "each",
"movement": { "mode": "route:gate", "radius": 0 }
}
```
- `count` NPCs spawn at the spot, and all but the first are scattered within 3 m of it.
- `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.
### A route
```json
{ "points": [ { "x": 1, "y": 2, "z": 3 }, { "x": 4, "y": 2, "z": 6 } ], "loop": true }
```
At least two points, each on Rust's navmesh. With `loop`, the last point leads back to the first; without it, the
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/`
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`:
- `caps`: `total`, `perOwner`, `perProfile` and `spawnsPerSecond`. **Each is 0, meaning no cap, until an admin
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.

View File

@@ -3,7 +3,8 @@
**Status:** plan, written 2026-09-30. Its eight questions (§11) were answered the same day: D221–D228 (§0).
**Stage 0 closed 2026-09-30** (runicnpc-rust#1/#2, §9): v0.1.0 released and loaded on both rigs. D229–D230 were
decided with it. **Stage 1 measured 2026-09-30** (§9): six answers on both rigs; D231 was decided with it.
**Stage 2's design answered 2026-09-30:** D232–D238 (§0), which reshape stage 2 (§9).
**Stage 2's design answered 2026-09-30:** D232–D238 (§0), which reshape stage 2 (§9). **Stage 2 built and tested
2026-09-30** on both rigs (§9); its API is [API.md](API.md).
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
@@ -460,7 +461,7 @@ it. That is the other reason our NPC must never be saved (§2).
| Health, name, aim cone | Hold: set on the NPC and read back (400/400, the name, 0.5). |
| Sense range, target-lost range, vision cone, listen range, hostile-only, sense types | **Only if set before the brain starts.** `Senses.Init` copies them once. At 35 m, a range of 50 set before the start saw the target, and the same range set afterwards did not. |
| The prefab's own values | Sense 30 m, lose target 40 m, vision cone −0.8, listen 10 m, memory 10 s, line-of-sight checks on, hostile-only off, senses players only, health 150, aim cone 2. |
| Roaming | **Rust's roam only follows an AI zone's move points.** In a monument's zone (Desert Military Base) ours roamed 36–47 m in 60 s. In an open field it stood still (0 m). **A roamer anywhere but a monument needs our own roam state, so stage 2's roamer role includes one.** |
| Roaming | **Rust's roam only follows an AI zone's move points** (and so does its chase: stage 2). In a monument's zone (Desert Military Base) ours roamed 36–47 m in 60 s. In an open field it stood still (0 m). **A roamer anywhere but a monument needs our own roam state, so stage 2's roamer role includes one.** |
| Sleep | An NPC outside an AI zone is never put to sleep, and `ai_dormant` does not apply to this AI. So an idle NPC keeps thinking with nobody near, which is what the costs below measure. |
| **Fighting NPCs** | **Rust's scientist AI never attacks an NPC.** `HumanNPC.IsTarget` is true only for non-NPC players, pets and scarecrows. `IsFriendly` means "same prefab id", which the swap copies, so every stock scientist counts ours as a friend. Re-implementing Rust's internal `IAISenses` puts a scientist in our NPC's target list. But Rust's AI design never runs its attack event for an NPC target. Calling `AttackTick` directly passed line of sight every time and still fired no shot, because the design's cover and facing states win. **NPC-versus-NPC combat needs our own combat state, and sensing NPCs needs our own sensing** (see the cost below). D231 makes it a profile setting, so stage 5 opens with its own spike. |
@@ -475,7 +476,7 @@ boots load the saved `proceduralmap.<size>.<seed>.<n>.navmesh`. **Placements tha
| Monuments probed (9 points each) | 144 | 144 |
| Points on the navmesh | 1,152 / 1,296 (89%) | 1,151 / 1,296 (89%) |
| Of those on a structure (a roof or raised floor) | 132 / 209 (63%) | 130 / 211 (62%) |
| A player-built floor 4 m up | **not on the mesh** | **not on the mesh** |
| A player-built floor 4 m up | **not on the mesh** in the frame it spawned; **on it 0.1–0.3 s later** (stage 2) | the same |
| One check | 13.6 µs | 8.3 µs |
Monument roofs (Launch Site, Airfield, Trainyard, the warehouses) are mostly walkable. Player-built floors and
@@ -553,6 +554,46 @@ and reload checks on both rigs: no NPC saved, every placement back, nothing left
also checks each movement mode, sleep and the walk home, both respawn modes, and a placement whose profile is
deleted and restored.
**Built (2026-09-30, runicnpc-rust#4, API version 2).** The API as built is [API.md](API.md). What else landed:
- `tools/fieldlist` generates the swap's field list from the Carbon rig's assemblies (`tools/managed.js`
downloads them). It picks **64 NPC and 32 brain fields**, exactly stage 1's Carbon count. On both rigs every
name resolves, and Carbon's boot check finds no field Rust has added.
- `tools/RunicNpcTest.cs`, the harness: `rnt.run api|hooks|move|sentry|sleep|place|all`, then `rnt.after` after a
reload or restart.
| Group | What it proves | Oxide | Carbon |
|---|---|---|---|
| api | every call's answer; seven refusals (bad owner, `placement:` owner, unknown or refused profile, bad override, off the navmesh) | 24/24 | 24/24 |
| hooks | spawned, a health threshold once, body damage ×0.5, died with contributors (250 of 250), despawned | 8/8 | 8/8 |
| move | wander within its radius; a route in order (3>0>1>2>3>0); a monument roamer 36 m on Rust's roam; our chase to 9.6–9.8 m of a 10 m chase range, and giving up twice | 8/8 | 8/8 |
| sentry | holds 0.00 m for 40 s and hits a target 12 m off 29–34 times; stands 40 m up, off the navmesh, for 20 s | 7/7 | 7/7 |
| sleep | wanders 8.6 m off, walks home at ≤2.8 m/s when no player is within 160 m, sleeps 1.7 m from its spot, stays still, wakes | 7/7 | 7/7 |
| place | `each` returns only the dead one; `group` waits for all, then all return; a deleted profile waits and returns; a missing route waits | 14/14 | 14/14 |
| a plugin unloads | its NPCs are removed with it | pass | pass |
| RunicNPC reloads | every placement back, the world equals the registry, no plain scientists at the spots | 5/5 | 5/5 |
| the server restarts | the same, after `server.save`, a restart, and the navmesh load | 5/5 | 5/5 |
What building it found:
- **Outside a monument, Rust's scientists never chase.** Rust's chase state looks for an AI zone's move points and
returns an error without them, as its roam does (stage 1, Q3). A `wander` or `route` roamer therefore has our
own chase. It closes to three quarters of the attack range, never past the profile's chase range from home, and
if the target stays out of reach at that edge for 5 s it gives up and walks home. A `monument` roamer keeps
Rust's chase.
- **Rust's navmesh covers a new player-built floor in 0.1–0.3 s** (2–3 frames), on both rigs. Stage 1's "a
player-built floor is never on the mesh" (Q4) sampled in the same frame the floor spawned, so it is wrong: a
roamer can stand on a player-built floor a moment after it exists. §5's sentry rule, §8 and stage 10 were
written on stage 1's reading. **Open for the org lead.**
- **Rust's navmesh sampler reaches further down than across.** A roamer asked to stand on a roof found the
ground 4.7 m below. A roamer's spot must now be within 2 m, up or down, of the navmesh it is put on.
- **A brain can think before Unity has started it.** For that moment it has no navigator, so RunicNPC leaves that
think to Rust.
- **A hit with no body part reports every part at once** (`(HitArea)(-1)`: fire, explosions, falls). Only an
exact head or leg hit uses those scales; everything else is body.
- **The game manifest's entity list leaves out the NPC prefabs.** A profile's `prefab` is looked up in its prefab
list instead.
### Stage 3 — In game
The chat and console commands (§5), their permissions, the navmesh placement check, respawn delays, and recording