PROTOCOL.md §19.12: RunicNPC over the bridge — integrations.runicNpc, the npc.profiles, npc.profiles.set, npc.placements and npc.placement commands and their refusals, the npc.died, npc.health and npc.placement.changed frames, world.place with a profile, attackerNpc and attackerProfile, the tally's npcProfileKills, the four sidecar routes, and overlay.toml's runicnpc_api. API.md: API 3 — RunicNpc_AddPlacement, RunicNpc_RenamePlacement, RunicNpc_RespawnPlacement and OnRunicNpcPlacementChanged. PLAN.md stage 4: what was built in five repositories, the walk on both rigs row by row, what was not walked and why, and what building it found. rust-link/INSTALL.md: RunicNPC as the bundle's third file, by hand, the egg, doctor and uninstall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
273 lines
13 KiB
Markdown
273 lines
13 KiB
Markdown
# 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.
|
||
|
||
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 < 3) { /* 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: `3`.
|
||
|
||
### `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. |
|
||
| `regrounded` | `int` | How many times its ground went from under it and it was put on the nearest navmesh (D239). |
|
||
|
||
### `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, note}`. `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. `note` is
|
||
set while its NPCs stand somewhere other than its spot because the ground there went (D239, below), 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).
|
||
|
||
A roamer's spot must be on Rust's navmesh, within 2 m up or down, when it is set; otherwise the answer says how far
|
||
off it is, as `rnpc place` does (D219). The check is skipped while the map's navmesh is still being built, and
|
||
for a profile that does not exist yet. **If the ground under a placement goes later** (a player-built floor is
|
||
destroyed, D239), a live NPC is put on the nearest navmesh within 2 s, and the placement respawns its NPCs on the
|
||
nearest navmesh until its spot is walkable again; `note` says so meanwhile. Rust itself leaves such an NPC standing
|
||
in the air.
|
||
|
||
### `RunicNpc_RemovePlacement(string id)` → `bool`
|
||
|
||
Removes a placement and its NPCs.
|
||
|
||
### `RunicNpc_AddPlacement(JObject placement)` → `Dictionary<string, object>`
|
||
|
||
API 3. **Creates a placement and names it as `rnpc place` does**: the profile's name and the first free
|
||
number, `warden-3` (D241, D246). It is checked and refused exactly as `rnpc place` is, with the same
|
||
sentences, so a placement made from a website and one made in game can never disagree.
|
||
|
||
- `placement` is a placement (below) without an id. **A `position` without `y` is a point on a map**
|
||
(D245): it is put on the ground there by a ray down on terrain and rock, never a building or a tree (a
|
||
roof is placed in game), then checked against the navmesh like any other. A point off the map, or under
|
||
water, is refused.
|
||
- The answer is `{id, position, built, cost}`: the name given, where it landed, whether that spot is on
|
||
something players built (D239's warning), and the cost warning (D227). Or `{error}`, the sentence
|
||
`rnpc place` would have said.
|
||
|
||
### `RunicNpc_RenamePlacement(string from, string to)` → `string`
|
||
|
||
API 3. Renames a placement, as `rnpc rename`: its live NPCs keep living under the new name. Null on
|
||
success, or why not (no such placement, the name taken, not a name).
|
||
|
||
### `RunicNpc_RespawnPlacement(string id)` → `int`
|
||
|
||
API 3. Removes a placement's NPCs and spawns them again at once, as `rnpc respawn`. How many, or `-1` if
|
||
there is no such placement.
|
||
|
||
### `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. Unlike
|
||
`rnpc path` (D240), it does not check that an NPC can walk from each point to the next: a leg Rust cannot path
|
||
leaves the NPC standing at the last point it reached, so a caller that builds routes should check its own legs.
|
||
|
||
### `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. |
|
||
| `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. |
|
||
|
||
---
|
||
|
||
## 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.
|