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:
@@ -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
234
runicnpc/API.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user