Compare commits

..

1 Commits

Author SHA1 Message Date
0324e3e626 docs(runicnpc): the death-screen name check, deferred to the org lead
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 23:27:09 -05:00
6 changed files with 49 additions and 869 deletions

View File

@@ -98,8 +98,7 @@ 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–D242, 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 |
| [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 |
### `android/`
| Doc | What it covers |

View File

@@ -8,7 +8,7 @@ they can be read together. **Built so far:** §1, walked 2026-09-28 (§1.9); §5
rig walk still to come (§5.8, D209); §3, built and walked on the rigs and the site 2026-09-29, its in-game
rows still to come (§3.4); §4, built 2026-09-29 (§4.1). **§6 was spiked on 2026-09-30, and the org lead
chose neither route: Runic Gateway writes its own NPC plugin, RunicNPC, planned in
[`../../runicnpc/PLAN.md`](../../runicnpc/PLAN.md) (D214–D242), and the rest of this plan waits for it.**
[`../../runicnpc/PLAN.md`](../../runicnpc/PLAN.md) (D214–D231), and the rest of this plan waits for it.**
This is a companion to [`PLAN_FIXES.md`](PLAN_FIXES.md) and [`PLAN.md`](PLAN.md). Where they disagree, this
document is later and wins. Its decisions continue PLAN_FIXES' numbering at **D188**.

View File

@@ -1,272 +0,0 @@
# 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.

View File

@@ -3,11 +3,6 @@
**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 built and tested
2026-09-30** on both rigs (§9); its API is [API.md](API.md). **Stage 3's design answered 2026-09-30:** D239–D242
(§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 4's
design answered 2026-09-30:** D243–D252 (§0).
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
@@ -48,41 +43,6 @@ architectural or design decision is implemented.
| **D229** | **The empty repository was seeded by one `chore:` commit straight to `main`** (the licence and a stub README), `edge` was branched from it, and everything after goes by PR (stage 0). It is the only direct push. | You seeding it in the Gitea UI; the whole scaffold straight to `main`, unreviewed. |
| **D230** | **The layout is `plugin/RunicNPC.cs` and a root `plugin.toml`**: `api`, the framework floors, `requires_plugins` (stage 0). RunicNPC is one file, not an overlay of a server tree. | Mirroring Rust-Plugins' `overlay/oxide/plugins/` and `overlay.toml`. |
| **D231** | **Each profile (or group) decides whether its NPCs target other NPCs, and which kinds**: Rust's scientists, animals, other profiles, and so on (stage 1; the org lead's words). Rust's AI cannot do this (§9, stage 1, Q3), so it is our own combat state and sensing, and stage 5 opens with a spike for it. | Our NPCs fighting players only, as Rust's scientists do. |
| **D232** | **The swap copies a fixed list of fields**, generated from Rust's unmodified assembly by a `tools/` script and carried in the plugin, so Oxide and Carbon copy the same fields. A boot check on Carbon, where Rust's visibility is unmodified, names any field Rust has added since; the staging drill (stage 9) regenerates the list. | Stage 1's rule as it is (Oxide copies runtime state too); the rule with Oxide-only filters. |
| **D233** | **A roamer moves in one of three ways:** `wander` (our own: a walkable point within a radius of its spot, walk, pause, repeat), `monument` (Rust's own AI-zone paths), or `route:<name>` (points an admin records in game, walked in order). **The profile sets the default, `wander`, and each placement may override it** (the org lead: "should be both, and the ability for admins in game to make routes"). | Our wander only; Rust's paths in monuments and ours elsewhere, fixed; the mode fixed per profile. |
| **D234** | **Routes are walked from stage 2 and recorded from stage 3.** Stage 2 builds the follower and the routes file (the harness writes its points); stage 3 adds `/rnpc path record` and setting points in game; stage 5 adds fighting and resuming the route. | Recording in stage 3 and walking in stage 5, as first planned. |
| **D235** | **An NPC sleeps when no player is within 160 m** (Rust's own dormant distance; a profile may change it, 0 = never). **It walks back to its spot before it sleeps** (the org lead: "it should walk back home before going to sleep"), and wakes when a player comes within range. | Being put back at its spot; never sleeping unless a profile opts in. |
| **D236** | **Respawn is chosen per placement:** `each` (the default: every NPC returns its delay after its own death) or `group` (none return until all are dead, then all return together). | One fixed rule. |
| **D237** | **A deleted profile leaves its placements waiting.** Their NPCs despawn; the placements are kept and shown as "profile missing" in game and on the site; they spawn again if the profile returns. | Refusing the delete; deleting the placements with it. |
| **D238** | **A profile's shape** is §2's, as the org lead approved it: `names`, `kits`, `prefab`, `role`, `movement`, `health`, `damageDealt`, `damageTaken`, `aimCone`, `ranges`, `visionCone`, `sleepDistance`, `healthThresholds`, in `data/RunicNPC/profiles.json` with the `managed` flag. Placements and routes have their own files beside it; the optional caps (D227) are in the plugin's config. Stages 5–7 add their own sections. | A smaller stage-2 profile, combat values deferred to stage 5. |
| **D239** | **A roamer may be placed wherever Rust's navmesh reaches, a player-built floor included, and the answer warns when it is one** ("this spot is on a player-built structure; if it is destroyed, the NPC falls back to the nearest navmesh"). Sentry-only stays the rule for a spot that is truly off the mesh. Stage 10 shrinks to pasted or custom prefabs and moving platforms (stage 2's finding). | Allowing it silently; keeping anything player-built sentry-only even where the mesh covers it. |
| **D240** | **A route is recorded point by point:** `/rnpc path record <name>` starts it, `/rnpc path point` adds where the admin stands (checked against the navmesh; a bad point is refused with the reason), `/rnpc path undo` drops the last, `/rnpc path save [loop\|back]` writes it, `/rnpc path cancel` discards it. Nothing is drawn on screen (D216). | The same flow with the points drawn on screen; a point dropped every few metres as the admin walks. |
| **D241** | **A placement made in game is named after its profile and a number** (`/rnpc place bandit` answers "Placed bandit-3"), and `/rnpc rename <id> <new>` renames it. `/rnpc near` lists the names. | Bare numbers; the admin naming every placement. |
| **D242** | **`/rnpc place` and `/rnpc here` take `key=value` options in any order after the profile:** `count=`, `respawn=`, `mode=each\|group`, `move=wander\|monument\|route:<name>`, `radius=`. Anything left out takes the profile's default. | `/rnpc place bandit 3 300 group route:gate`; `/rnpc place` then `/rnpc set`. |
| **D243** | **An event's "Place NPCs" picker shows both, grouped:** the site's profiles first, then Rust's own scientists as today. Existing events keep working. A server without RunicNPC offers only Rust's own until stage 9 makes RunicNPC required (stage 4). | Profiles only, flagging old steps to be re-picked; profiles only, with old steps still running silently. |
| **D244** | **A server's own profiles are adopted by the site on its first push.** Before it pushes to a server for the first time, the site reads that server's standalone profiles (`rnpc.profile`) and imports each one as a profile for that server alone, so nothing on the server changes and its placements keep spawning. This refines D221's "replace" for the first push, as the permission manager's adopt does (stage 4). | Replacing them, leaving their placements "profile missing" (D237); listing them for the admin to adopt or discard one by one, holding that server's push until each is decided. |
| **D245** | **On the site, an admin lists, edits and creates placements, a new one by clicking the live map.** The server puts the clicked point on the ground at that spot and checks it against the navmesh, refusing with the reason as in game. A roof or a building top cannot be chosen from the map; that stays an in-game placement (stage 4). | List and edit only; creating at a monument; a read-only list. |
| **D246** | **The map's placement form has `/rnpc place`'s options** (D242): profile, count, respawn, each or group, movement and radius. The server names the placement, as in game (D241) (stage 4). | The profile only, everything else edited afterwards. |
| **D247** | **Kills are counted by profile name, per server, by default. Each site profile can choose otherwise** with a "kills count" setting: *this server* (the default), *every server with a profile of this name*, or *this profile only* (the site profile, on whichever servers it is pushed to). The org lead's words: "by name per server (default) with options for the server admin to choose different ways" (stage 4, refines D225). | One fixed rule; one site-wide setting; one setting per server. |
| **D248** | **Stage 4 is one stage, with a PR in each of its four repositories plus docs, walked once end to end** (stage 4). Its wire changes join protocol 13, which is not yet released. | Split into 4a (wire), 4b (site) and 4c (shipping), each walked before the next. |
| **D249** | **RunicNPC gets API 3 in stage 4, a fifth PR, in `runicnpc-rust`**: a call that creates a placement and names it as `/rnpc place` does (D241, D246), puts a map point on the ground and checks it against the navmesh (D245), and a hook raised whenever a placement changes, so an edit made in game reaches the site at once. The bridge only calls it (stage 4). | The bridge copying the naming rule and checking the placements every minute. |
| **D250** | **Per-profile kills are shown on the player's public stats ("Warden kills: 3"), in a "kills of a profile" title category, and as a leaderboard the visitor picks a profile for** (stage 4, D225, D247). | The player page and titles only; titles only. |
| **D251** | **Where an adopted server profile and a site profile share a name on that server, the site's wins** (stage 4, refines D244). The server's own is still imported, marked "replaced" and kept for an admin to restore, and that server's placements take the site profile's values. | The adopted one winning on its server, with the site's profile skipping it. |
| **D252** | **A player's per-profile kills are shown by opening their row in a server's leaderboard** ("Warden 3 · Bandit 12", for the wipe the page shows), and to the player on their own Player → Rust page. The module has no public player page, and stage 4 adds none (stage 4, the "player's public stats" of D250). | A new public player page; the profile leaderboard only. |
| **D253** | **Stage 5 is one stage, walked once end to end**, as stage 4 was (D248). It opens with the D231 spike, and its wire changes join protocol 13 while that protocol is unreleased (stage 5). | 5a/5b/5c, each walked and merged before the next; the spike alone first, the split decided afterwards. |
| **D254** | **Factions are named, with per-profile overrides.** Each profile names its faction (`bandits`), and a faction table states once how factions treat each other: *hostile*, *neutral* or *allied*. Rust's scientists (`scientists`) and animals (`animals`) are two built-in factions. A profile may also list its own exceptions, which win over its faction's relations. The table is authored on the site and pushed with the profiles; a standalone server edits it from the console (D221) (stage 5). | Named factions only; per-profile lists only, where a sixth bandit means editing the other five. |
| **D255** | **A profile with no faction settings fights players only**, as it does today. Fighting Rust's scientists, animals or other profiles is opted into per faction or per profile. Stage 1 measured sensing NPCs at about seven times the cost of fighting players (stage 5). | Hostile to Rust's scientists by default; hostile to everything outside its faction. |
| **D256** | **Group alert:** each profile has an alert radius, **40 m by default** and 0 to turn it off. When one of its NPCs is attacked, every ally within that radius learns the attacker and joins the fight, whether or not it can see him. An ally is the same faction, or the same profile when there is no faction (stage 5). | Only the NPCs of the placement that was hit; off unless a profile turns it on. |
| **D257** | **An NPC allied to a clan or a Rust team never targets its members, and defends them.** It attacks whoever damages one of those players, or anything the clan or team owns (building blocks, doors, deployables), within its leash. Rust's own clans and teams only, with no Clans-plugin dependency (stage 5). | Never targets them, nothing more; defends the players but not their buildings. |
| **D258** | **An escort's target comes from an event step, from the API, or from an admin's `/rnpc follow <placement> <player>`**, the command for trying it out in game. A persistent placement never escorts anyone of its own accord (stage 5). | Persistent placements that escort a named player too; the API alone. |
| **D259** | **Turrets treat our NPC as they treat Rust's scientists by default**, and it shoots back at a turret that shoots it. The spike measures exactly what Rust does. Each profile can choose `turrets: default`, `ignore` or `always` (stage 5). | Always targeted by player turrets; never targeted. |
| **D260** | **A kit's extra items are used only when the profile opts in, behaviour by behaviour:** healing with syringes, grenades, switching to melee, rockets, flamethrowers. All are off by default; the main weapon is always used. An item used comes out of the NPC's inventory, so the loot matches what is left (stage 5). | Every behaviour on whenever the kit carries the item; the main weapon only, with extras as loot. |
| **D261** | **On a PVE server, RunicNPC answers TruePVE's and NextGenPVE's damage hook with "allowed" for its own NPCs, both ways**, so players can fight them and they fight back, as with Rust's scientists. A profile may opt out, for example to make an NPC unkillable (stage 5). | Leaving it to the server's TruePVE rules; a config switch, off by default. |
| **D262** | **A patrol that leaves its route to fight returns to the point it was heading to** and carries on from there (stage 5, refines D234). | The nearest point, in the same direction; starting the route again. |
| **D263** | **A fight with Rust's scientists stays one-sided, and our NPCs never go hunting.** A scientist one of ours shoots takes cover, as Rust's AI does, and is not made to fire back. A profile hostile to `scientists` fights only scientists that come within its own leash of its spot. It never walks into a monument to look for them, so our NPCs never clear a monument on their own (stage 5 spike; the org lead's words: "our NPCs will not clear monuments on their own"). | RunicNPC driving a hit scientist to fire back; balancing fights so a few cannot clear a monument; refusing such profiles near monuments at placement. |
| **D264** | **`turrets: always` means the safe-zone sentries too**: Outpost's and Bandit Camp's turrets shoot that profile's NPCs as they shoot a hostile player. `default` is what Rust does (player turrets shoot it, safe-zone sentries do not), and `ignore` means no turret shoots it (stage 5 spike, refines D259). | Dropping `always`, since `default` already means player turrets. |
| **D265** | **The main weapon's ammunition is endless, as in Rust.** An NPC never runs dry, and its loot keeps the kit's ammunition. Only D260's extras (syringes, grenades, rockets and the like) are used up (stage 5 spike, refines D260). | Ammunition used up shot by shot; a per-profile setting. |
| **D266** | **Fire burns as it does in Rust.** A flamethrower NPC's flames burn every NPC in them, its own allies included, because Rust filters only bullets between NPCs. Admins place flamethrower NPCs with that in mind (stage 5 spike). | Our NPCs taking no fire damage from their allies. |
**Borrowing, not copying.** NpcSpawn states no licence at all, so its source grants us nothing and is read only as a
description of *what* can be done in Rust. HumanNPC is MIT on uMod, which is GPL-compatible, but §1.2 rules out its
@@ -201,39 +161,6 @@ Placements stay the server's either way (D222): the site reads them and edits th
The kit list is required and non-empty; one kit is picked at random per spawn, for variety within a profile. A kit
the server does not have refuses the profile when it is saved, not when an NPC spawns.
**In the data file (D238)**, `data/RunicNPC/profiles.json`:
```json
{
"managed": false,
"profiles": {
"warden": {
"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]
}
}
}
```
- `role` is `roamer` or `sentry` in stage 2; the other roles of §6 arrive with their stages.
- `movement.mode` is `wander`, `monument` or `route:<name>` (D233). A placement may override it.
- `damageDealt` scales the kit weapon's damage. `damageTaken` scales what the NPC takes, by body part.
- `sleepDistance` 0 means it never sleeps (D235).
- `healthThresholds` are the fractions at which `OnRunicNpcHealth` fires (§4).
**A deleted profile leaves its placements waiting (D237):** their NPCs despawn, and they spawn again if it returns.
---
## 3. Features
@@ -264,7 +191,7 @@ Borrowed ideas are marked with their source; everything else is new. Stage numbe
|---|---|
| Press E to talk: an NPC says a line, or opens a message, when used | 6 |
| Lines on greet, hurt and kill | 6 |
| Walk a recorded path (D234), and follow a player | 2, 3, 5 |
| Follow a player, and walk a recorded path | 5 |
### 3.3 Runic Gateway's own
@@ -321,13 +248,10 @@ permissions reach the site's permission manager through the inventory, like any
| Command | Does | Permission |
|---|---|---|
| `/rnpc place <profile> [key=value ...]` | Places at the spot you are looking at, persistent, and answers with its name (D241). Options in any order (D242): `count=`, `respawn=` (seconds), `mode=each\|group`, `move=wander\|monument\|route:<name>`, `radius=` | `runicnpc.place` |
| `/rnpc here <profile> [key=value ...]` | The same, where you stand | `runicnpc.place` |
| `/rnpc remove [placement]` | Removes a placement and its NPCs: the one named, or that of the NPC you are looking at | `runicnpc.place` |
| `/rnpc rename <placement> <new>` | Renames a placement (D241) | `runicnpc.place` |
| `/rnpc near [radius]` | Lists placements (by name) and live NPCs near you | `runicnpc.place` |
| `/rnpc path record <name>` · `point` · `undo` · `save [loop\|back]` · `cancel` | Records a route where you walk, point by point (D240) | `runicnpc.place` |
| `/rnpc path list` · `delete <name>` | Lists or deletes routes; a placement on a deleted route waits, as for a deleted profile (D237) | `runicnpc.place` |
| `/rnpc place <profile> [count] [respawn]` | Places at the spot you are looking at, persistent | `runicnpc.place` |
| `/rnpc here <profile> [count]` | The same, where you stand | `runicnpc.place` |
| `/rnpc remove` | Removes the placement of the NPC you are looking at | `runicnpc.place` |
| `/rnpc near [radius]` | Lists placements and live NPCs near you | `runicnpc.place` |
| `/rnpc info` | The NPC you are looking at: profile, owner, health, target, state | `runicnpc.place` |
| `/rnpc profiles` | The profiles this server has | `runicnpc.place` |
| `rnpc.profile <create\|set\|delete> ...` (console) | Edits a profile on a standalone server; refused when the site manages them (D221) | `runicnpc.admin` |
@@ -339,11 +263,8 @@ permissions reach the site's permission manager through the inventory, like any
cost warning (D227): how many RunicNPC NPCs the server now has and what stage 1 measured that number to cost.
On the navmesh, the NPC roams, chases and fights
normally, and that includes a player-built floor, which Rust's mesh covers a moment after it is built (stage 2).
**On a player-built structure the placement is made with a warning** (D239): if the structure is destroyed, the
NPC falls back to the nearest navmesh. Off the mesh (a pasted structure, a custom prefab), a **stationary** profile
is placed and a roaming one is refused with the reason. An event step gets the same answer, as a blocked
RaidableBases spot does (D208).
normally. Off it (a roof, inside a base, a pasted structure), a **stationary** profile is placed and a roaming one
is refused with the reason. An event step gets the same answer, as a blocked RaidableBases spot does (D208).
---
@@ -353,19 +274,16 @@ Each profile has a **role**, which sets its AI states and defaults:
| Role | Behaviour |
|---|---|
| **Roamer** | Moves in one of three ways (D233): `wander` within a radius of its spot, `monument` (Rust's AI-zone paths), or `route:<name>`; chases and fights. The profile sets the default, a placement may override it. |
| **Roamer** | Wanders within its roam range of home, chases and fights. Rust's default. |
| **Sentry** | Stationary: turns and shoots, never walks. The only role allowed off the navmesh. |
| **Guard** | Holds a point or an entity; chases only to its leash, then returns. |
| **Patrol** | A roamer on `route:<name>` (D233): walks a route recorded in game (`/rnpc path record`); fights, then returns to the point it was heading to (D262, stage 5). |
| **Escort** | Follows an entity or player; defends it. Its target comes from an event step, the API, or `/rnpc follow` (D258). |
| **Patrol** | Walks a path recorded in game (`/rnpc path record`); fights and resumes. |
| **Escort** | Follows an entity or player; defends it. |
| **Boss** | Any of the above, plus a health bar, phases and announcements (§3.3). |
| **Passive** | Never fights; press E to talk. Quest-givers and vendors. |
**Factions** are named (D254): each profile names one, and a table says how factions treat each other, *hostile*,
*neutral* or *allied*. Rust's scientists and animals are two built-in factions. A profile may list exceptions,
which win over its faction's relations. With no faction settings a profile fights players only (D255). An
NPC can also be allied to a clan or a Rust team: it never targets the members, and it defends them and what they
own (D257). Allies within a profile's alert radius join a fight one of them is in (D256).
**Factions** are a relation table between profiles, plus optional ties to a team or clan: *hostile*, *neutral*
or *allied*. Rust's scientists are one more faction, so a profile can be told to leave them alone.
**Zone tethering** keeps an NPC inside a ZoneManager zone. At the edge it turns back, and if it ends up outside (it
was pushed, or the zone moved) it is returned home. ZoneManager is already required by `module-rust`; to RunicNPC it
@@ -389,8 +307,7 @@ shipped no custom mesh. Where it does not reach, a profile is a sentry (§5).
**Stage 10, only if needed:** a point graph recorded in game. An admin walks the route, `/rnpc mesh record <name>`
samples it, and NPCs move between the points directly, as NpcSpawn's custom meshes do, for:
- pasted structures, and roofs the mesh does not reach (a player-built floor needs none: Rust's mesh covers it
a moment after it is built, stage 2 and D239);
- pasted or player-built bases, and roofs;
- custom map prefabs Rust's mesh does not cover;
- moving platforms, together with parenting.
@@ -502,9 +419,9 @@ 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** (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.** |
| 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.** |
| 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 landed no hit. (Stage 5 found why: Rust's bullets skip any NPC an NPC shoots at, unless its faction is `Horror`. It was not the design's cover and facing states, as this row first said.) **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. |
| **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. |
**4. Navmesh.** Rust's scientists walk **Rust's own navmesh** (its Gen2 `RustNavMeshAgent`), not Unity's: a Unity
`NavMesh` query found nothing in the open world. **The placement check is `Rust.Ai.Gen2.RustNavMeshHelpers.
@@ -517,11 +434,11 @@ 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** in the frame it spawned; **on it 0.1–0.3 s later** (stage 2) | the same |
| A player-built floor 4 m up | **not on the mesh** | **not on the mesh** |
| One check | 13.6 µs | 8.3 µs |
Monument roofs (Launch Site, Airfield, Trainyard, the warehouses) are mostly walkable. This stage read
player-built floors and roofs as never walkable; stage 2 corrected that (the row above), and D239 follows from it.
Monument roofs (Launch Site, Airfield, Trainyard, the warehouses) are mostly walkable. Player-built floors and
roofs never are: that is §5's sentry rule, and stage 10.
**5. The cost (D227's numbers).** Seven 60 s phases on a field 400 m from any monument: a baseline, 1, 10 and 100
of ours idle, the same 100 shooting 20 stand-ins (whose damage the harness zeroes, so none die), a fresh 100 set to
@@ -557,8 +474,11 @@ What the numbers say:
- **`HumanNPC.AttackerInfo` writes the prefab's short name as the killer**, after `BasePlayer` has written the
display name. The death screen said "scientistnpc_roam" until our NPC overrode it. With the override it says
**"RnhWarden"**, and the weapon stays `pistol_revolver.entity`.
- **Open for the in-game walk:** the client may use that string to pick the killer's portrait. Only a real client
shows whether the override costs the portrait.
- **Deferred: the org lead checks it in game later (2026-09-30).** The client may use that string to pick the
killer's portrait, and only a real client shows it. To check: on a rig with the override loaded, be killed by a
named RunicNPC NPC and look at the death screen. Does it show the NPC's name, and does it still show the killer's
portrait? The result is written here. If the portrait is lost, stage 2 decides between the name and the portrait.
This is the only stage 1 item still open.
- **What the bridge publishes (protocol 13):**
- our NPC kills a player → `player.death` with `attackerType: "npc"`, **`attackerName: "scientistnpc_roam"`**
(the prefab name, as §1.4 said), the weapon and the distance;
@@ -571,408 +491,61 @@ What the numbers say:
### Stage 2 — The NPC and its API
- **The NPC:** the subclass with a fixed field list (D232) and `AwakeFromInstantiate` before `Spawn` (stage 1, Q1);
the `AttackerInfo` override, so its name reaches the death screen (stage 1, Q6); profiles read from RunicNPC's own
data file in §2's shape (D238); a random kit and name per spawn; appearance and combat values, with the sense
values set before the brain starts (stage 1, Q3).
- **Roles and movement:** roamer and sentry. A roamer's three modes (D233): our own `wander`, Rust's `monument`
paths, and `route:<name>` from the routes file (D234; the harness writes its points, stage 3 records them in
game).
- **Sleep (D235):** past the profile's distance from every player, the NPC walks back to its spot, then stops
thinking; a player in range wakes it.
- **Navmesh at boot:** nothing spawns until `RustNavigation.Instance.IsDefaultNavmeshBuilt()` (stage 1, Q4); the
queue waits for it.
- **Owners and lifetimes (§2):** placements persisted with one dirty flag, each with its count, delay and respawn
mode (`each` or `group`, D236) and an optional movement override; a placement whose profile is gone waits (D237);
standalone profiles and the managed flag (D221).
- **Spawning in batches:** large placements and the boot respawn are spread over frames, within a per-frame time
budget (stage 1, Q5: 100 at once is a half-second hitch).
- **Optional caps, off by default,** in the plugin's config, and **the cost warning** from stage 1's table (D227).
- **The API (§4) and its hooks;** `docs/runicnpc/API.md`.
- The subclass, profiles (read from RunicNPC's own data file for now), kits (random pick), appearance, combat values,
roamer and sentry roles, sleep.
- Owners and lifetimes (§2); placements persisted with one dirty flag; standalone profiles and the managed flag
(D221); optional caps, off by default, and the cost warning from stage 1's measurements (D227).
- The API (§4) and its hooks; `docs/runicnpc/API.md`.
**Tested by** the harness asserting each call's result (PASS/FAIL lines read back over the panel), plus restart
and reload checks on both rigs: no NPC saved, every placement back, nothing left behind by an unloaded owner. It
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; the org lead answered with D239, and those sections now follow it.
- **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.
and reload checks on both rigs: no NPC saved, every placement back, nothing left behind by an unloaded owner.
### Stage 3 — In game
The chat and console commands (§5), their permissions, the navmesh placement check with D239's warning on a
player-built structure, respawn delays, and recording routes in game point by point (D234, D240). Placements made
in game are named after their profile and a number, and renamable (D241). `/rnpc place` and `/rnpc here` take
`key=value` options, a movement override and a respawn mode among them (D233, D236, D242).
The chat and console commands (§5), their permissions, the navmesh placement check, respawn delays, and `/rnpc
path record` for stage 5.
**Tested by** the harness for everything a console can reach (the commands' parsing, refusals, names, and the
route file a recording writes), then a written in-game walk: place, remove, rename, look-at info, respawn, record a
route and place a patrol on it, place on a player-built floor and destroy it, restart. **This is the first stage
that needs a player on a rig.**
**Built (2026-09-30, runicnpc-rust#5, API still 2).** Every verb of §5 is `/rnpc <verb>` in chat and `rnpc.<verb>`
in a console, one dispatcher behind both. `runicnpc.admin` includes `runicnpc.place`, and the server console has
both. `/rnpc` alone lists the verbs the caller may use. `rnpc.profile` answers only in a console (F1 included): in
chat it points there. **One addition for the org lead's call:** the server console has no position, so there
`place`, `here` and `path point` take `at=x,y,z` (and `yaw=`), which is also how the harness drives them. In game
these options are refused.
| Group | What it proves | Oxide | Carbon |
|---|---|---|---|
| cmd | D242's options and defaults; D241's names (the next number, the freed number taken again, rename keeps the live NPC and moves its owner); 11 refusals, none leaving a placement behind; a sentry 40 m up; D240's recording (undo, too few points, a point in the air, `loop\|back`); a patrol walking the recorded route 18 m in 12 s; a deleted route leaves its placement waiting; respawn, clear, remove; the chat path as a stand-in player (nothing without the permission, `here`, `remove`); standalone `rnpc.profile` create, set, show and delete, refused while managed | 57/57 | 57/57 |
| floor | D239's warning; a roamer standing on a player-built floor; the floor destroyed under it; the respawn falling back to the ground and back onto the rebuilt floor; a route leg to a floor with no stairs refused | 7/7 | 7/7 |
| all | stage 2's groups and the two above in one `rnt.run all` | 134/134 | 134/134 |
| RunicNPC reloads / the server restarts | stage 2's checks | 5/5 · 5/5 | 5/5 · 5/5 |
What building it found:
- **Rust leaves an NPC standing in the air when the floor under it is destroyed.** Measured for 15 s; it kept its
state and never fell. RunicNPC's brain now checks every 2 s, and a roamer more than 2 m above the nearest
navmesh is put on it (as Rust's own navigator warps) and wanders from there until it respawns. It was on the
ground 2 s after its floor went, on both rigs. A placement whose spot is off the mesh when its NPCs respawn
spawns them on the nearest navmesh, and returns to its spot once the floor is rebuilt. This is how D239's "falls
back to the nearest navmesh" is implemented.
- **A route can be on the navmesh and still not walkable.** The first recorded test route climbed a hill a player
can walk. Its first point was on an island of navmesh on a rock top, and the patrol never left it. `path point`
now asks Rust for a path from the previous point and refuses the point if there is none; `path save loop`
refuses a route whose last point cannot reach its first. `RunicNpc_SetRoute` does not check legs (API.md).
- **A floor over the shore was not covered by the navmesh in 10 s,** where a floor over dry ground a few metres
away was covered in 0.1 s (stage 2's figure). The command answers with the reason in both cases, so nothing
depends on it. It is noted here in case stage 10 meets it again.
- **Stage 2 wrote two computed properties into its JSON** (`V` in every position, `IsSentry` in every profile),
in the files and in the API's answers. Both are ignored now.
- **Rust's console argument type has changed** from `string[]` to `StringView[]` in the current build. The
console verbs read each argument through `GetString`, which both have.
**The in-game walk is still to do** (it needs a player). Its checklist, on either rig, with `runicnpc.admin`
granted and one standalone profile made from the console (`rnpc.profile create bandit`, then `set bandit kits
<kit>`):
1. `/rnpc` lists the verbs; without the permission it says which is missing.
2. `/rnpc place bandit` on the ground you look at → "Placed bandit-1"; it spawns facing you, and the cost
warning is shown. `/rnpc here bandit count=3 mode=group` → bandit-2, three NPCs around you.
3. Look at one: `/rnpc info` shows its name, profile, placement, health, state and target. Shoot it: the target
line names you.
4. `/rnpc near` lists bandit-1 and bandit-2 with distances. `/rnpc rename bandit-2 camp`; `/rnpc tp camp`.
5. Kill one of `camp`: nothing returns until all three are dead (`group`); then `/rnpc respawn camp` brings all
back at once.
6. `/rnpc path record gate`, walk and `/rnpc path point` four times (try one on a rock or a roof: refused),
`/rnpc path undo`, one more point, `/rnpc path save loop`. `/rnpc place bandit move=route:gate` → the NPC walks
the points in order.
7. Build a foundation and a floor 2 high with stairs, stand on the floor: `/rnpc here bandit` → the warning.
Destroy the floor: the NPC is on the ground within 2 s. `/rnpc near` shows the note.
8. Look at an NPC: `/rnpc remove` removes its placement. `server.save`, restart: every other placement is back,
and no plain "Scientist" stands at a spot.
**Tested by** a written in-game walk: place, remove, look-at info, respawn, restart. **This is the first stage that
needs a player on a rig.**
### Stage 4 — Runic Gateway integration
Across four repositories, one PR in each plus docs, walked once end to end (D248). The org lead's answers on
2026-09-30 are D243–D248: the event picker shows profiles and Rust's own scientists (D243); a server's own profiles
are adopted, not replaced, on the first push (D244); placements are listed, edited and created from the live map
with `/rnpc place`'s options (D245, D246); and kills are counted by profile name per server unless a profile says
otherwise (D247). Three more answers followed the same day: RunicNPC itself gets API 3, a fifth PR (D249); per-profile
kills are on the player's public stats, in titles and on a leaderboard (D250), a player's own shown by opening their leaderboard row (D252); and where an adopted profile and a site
profile share a name, the site's wins (D251).
Across four repositories, split into 4a–4c if it grows:
- **RunicNPC (API 3, D249):** a call that creates a placement and names it as in game, from a map point it puts on
the ground and checks against the navmesh; rename and respawn as calls; a hook raised whenever a placement changes.
- **Rust-Plugins (the bridge):**
- hello reports RunicNPC's presence and API version;
- `world.place` for an NPC takes a profile, and the placing is RunicNPC's;
- the killfeed names NPCs by their name (§1.4);
- new frames: an NPC died, with profile, name, killer and contributors; a health threshold; placements changed;
- `player.tally` counts NPC kills per profile;
- profiles pushed from the site, as the permission sync is, after the server's own are read for adoption (D244);
- placements read, edited, removed and created from the site; a created one is put on the ground at the clicked
point and checked against the navmesh (D245).
- profiles and placements pushed from the site, as the permission sync is.
- **Rust-Link:** routes for profiles and placements, and the frames forwarded.
- **Module-Rust:**
- an **NPC profiles** admin page (per server, shared or fleet-wide) with the kit check, the adoption of a server's
own profiles (D244), and each profile's "kills count" setting (D247);
- a **placements** page per server: the list with each placement's values, state and note, edits, rename, remove,
respawn, and a new placement made by clicking the live map, with `/rnpc place`'s options (D245, D246);
- `rust.npc.place` takes a profile, through a new option source `rust.options.npc_profiles`, grouped with Rust's
own scientists in the picker (D243);
- an **NPC profiles** admin page (per server, shared or fleet-wide) with the kit check;
- a **placements** view per server;
- `rust.npc.place` takes a profile, through a new option source `rust.options.npc_profiles`;
- triggers `rust.npc.died` and `rust.npc.health`, so a phase can wait for "8 guards died" (waves) or "the boss
below 50%";
- per-profile stats and titles, counted as each profile says (D247): on the player's public stats, as a title
category, and as a leaderboard ranked by the profile a visitor picks (D250); one player's by opening their
leaderboard row, and a player's own on Player → Rust (D252);
- an adopted profile whose name a site profile already has on that server is kept as "replaced", restorable
(D251).
- per-profile stats and titles.
- **Installer and egg:**
- RunicNPC becomes a third artefact in the Rust bundle, pinned and checksummed like the plugin and the sidecar
(D224);
- `doctor` reports it missing or edited;
- the egg installs it.
The wire changes join protocol 13, which is still unreleased (D248).
The wire changes join protocol 13 if it is still unreleased, or open 14.
**Tested by** the usual trio: each repository's suites, both rigs through the site, and a browser walk of both new
pages, placing from the map included. An event run places profile NPCs, waves advance on their deaths, and teardown removes them.
**Built (2026-09-30), five PRs and this one.** RunicNPC API 3 (`runicnpc-rust` `feat/stage-4-api3`), the bridge
(`Rust-Plugins` `feat/runicnpc-stage4`), the sidecar and the egg (`Rust-Link` `feat/runicnpc-stage4`), the site
(`Module-Rust` `feat/runicnpc-stage4`) and the installer (`installer` `feat/runicnpc-stage4`). The wire is
[`PROTOCOL.md`](../rust-link/PROTOCOL.md) §19.12, and API 3 is in [API.md](API.md).
| Piece | What it does |
|---|---|
| RunicNPC API 3 (D249) | `RunicNpc_AddPlacement` names a placement as `rnpc place` does and grounds a map point (terrain and rock only); `RunicNpc_RenamePlacement`, `RunicNpc_RespawnPlacement`; the hook `OnRunicNpcPlacementChanged` on every set, remove and rename. `rnpc place` and `here` now share one function with the API, so both refuse with the same sentences. The harness gained an `api3` group; `rnt.run all` passes 157/157 on both rigs |
| The bridge | `integrations.runicNpc`; `npc.profiles`, `npc.profiles.set`, `npc.placements`, `npc.placement` (add, set, remove, rename, respawn); the frames `npc.died`, `npc.health`, `npc.placement.changed`; `world.place` with a `profile`, reverted through RunicNPC; the killfeed's `attackerNpc` and `attackerProfile`; the tally's `npcProfileKills`; `rg.npc`. `overlay.toml` declares `runicnpc_api = 3` |
| The sidecar | Four forwards: `GET`/`POST /npc/profiles`, `GET /npc/placements`, `POST /npc/placement` |
| The site | **Admin → Rust NPC profiles** (the profile form, per server, shared or fleet, each server's push state and refusals, replaced profiles with Restore); **Admin → Rust NPC placements** (the live map: click to place, a pin per placement; edit, rename, respawn, remove); the push loop with adoption; the event picker (D243); the triggers `rust.npc.died` and `rust.npc.health`; per-profile kills, the profile leaderboard, the opened row (D252) and Player → Rust; the title category "Kills of an NPC profile". 481 server and 66 client tests |
| Shipping (D224) | The installer's compose job carries the latest RunicNPC release whose API is at least the bridge's `runicnpc_api` (none when the bridge needs none, RunicNPC has not released, or its API is too old). The installer places `RunicNPC.cs` before the bridge, records it, restores it on `update`, reports it in `doctor` and removes it on `uninstall`, never its data directory. The egg installs it. RunicNPC's release asks the installer to recompose |
**Walked, 2026-09-30, on both rigs through a walk site, in the browser as a signed-in admin.** There was no player
on the rigs, so a throwaway probe plugin killed NPCs as a stand-in player, as stages 1 to 3's harness did.
| Row | Oxide | Carbon |
|---|---|---|
| A standalone server's own profiles adopted on the first push (`bandit`); one whose name a site profile had (`warden`) kept as replaced, and Restore refused with the reason while the site's covers it (D244, D251). Its placement kept both NPCs through it | pass | (managed already; nothing to adopt, pushed 0 then 1) |
| A kit the server lacks refused on save, naming the server | — | pass |
| A profile edited in the browser, pushed within the tick | pass | "push now" |
| Placed from the live map: grounded (8 m up), named `warden-1` by the server, cost warning shown (D245, D246). A click on the sea refused with RunicNPC's sentence | pass | pass (API) |
| Rename, edit, respawn and remove (with its confirm) from the placements page | pass | — |
| A rename made at the server console reaching the site as `npc.placement.changed` | pass | pass |
| Kills: `npc.died` (name, placement, killer, contributors), `npc.health` at 0.5, the tally's `npcProfileKills`, credited to the site profile pushed under that name (D247) | pass | pass |
| "Rank by" a profile's kills; opening a row shows the player's kills by profile (D250, D252) | pass | — |
| A title rule on a profile's kills ("Warden Slayer"), and one on a profile not on the server refused | pass | — |
| The step editor's picker: the site's profiles, then Rust's own, grouped (D243) | pass | — |
| An event placing two profile NPCs; its phase waited on `rust.npc.died` where `profile` is `warden` and `byEvent` is true, ignored a placement's warden dying, and advanced on the event's two; a second run cancelled removed its two live NPCs | pass | pass (place, teardown) |
**Not walked:** the killfeed's `attackerNpc`, which needs an NPC to kill a player, and a player's own kills on
Player → Rust, which needs a linked account. Both wait for the in-game walk with a player. The installer's and the
egg's RunicNPC path runs only against real releases, so it is walked at the cutover. The compose job and the egg
were run against a mock Gitea for the three cases (a RunicNPC answering API 3, one answering API 2, none released).
What building it found:
- **A condition on a phase gate cannot name the run.** Core counts a gate's firings from phase entry and does not
know which run an NPC belongs to, so `rust.npc.died` carries `byEvent` and `runId` for the gate's `where`. A
gate that must count only its own run's NPCs uses a profile only events place, or `byEvent`. Noted for stage 6's
bosses.
- **The web feed lowercased an NPC's name** (it read a prefab name, `attacker()` in `format.js`), so "Old Warden 2"
would have read "Old warden". The bridge sends the name in a field of its own, `attackerNpc`, and the feed shows it
as typed.
- **A site profile is labelled by its first NPC name** ("Warden"), where the leaderboard and titles need a word a
player reads; `warden` stays the name events and `/rnpc` use. No new field.
- **Rig notes, not code:** the walk site's `rust-oxide` row held the token of the server before it was recreated,
and its ingest cursor pointed past the new sidecar's ids (both reset). After swapping a running sidecar binary
on Carbon, a panel **restart** did not relaunch it; a stop and a start did.
pages. An event run places profile NPCs, waves advance on their deaths, and teardown removes them.
### Stage 5 — Behaviour
Guard, patrol and escort roles; factions (between profiles, with Rust's scientists, and with teams or clans); group
alert; zone tethering; turrets; the weapon behaviours a kit's items imply; TruePVE compatibility.
The org lead's answers on 2026-09-30 are D253–D262. **It is one stage, walked once end to end (D253)**, with the
pieces below. Its wire changes join protocol 13 while it is unreleased.
- **The spike first (D231, D253).** A harness answers what the rest depends on, on both rigs:
- our own sensing and combat state against an NPC target: does it shoot, with the kit weapon, and hit;
- the cost of sensing only the factions a profile is hostile to, nearest first, against stage 1's 100² line of
sight tests (17.7–20.4 ms per think);
- what Rust's auto turrets, flame turrets and shotgun traps do to a stock scientist and to ours (D259);
- what TruePVE's and NextGenPVE's hook sees for our NPC (D261);
- which kit items Rust's AI already uses on its own (D260).
- **RunicNPC (API 4):**
- the faction table and each profile's `faction`, exceptions, alert radius, turret mode, PVE opt-out and kit
behaviours, in the profile file and through `RunicNpc_SetProfiles` (D254–D261);
- the guard role (holds a point or an entity, chases to its leash, returns), escort (D258) and patrol resume
(D262);
- sensing and combat against NPC and animal targets, for the factions that ask for it (D255);
- group alert (D256) and clan or team allies (D257);
- zone tethering through ZoneManager, optional;
- turret behaviour (D259), the opted-in kit behaviours (D260), and the PVE hooks (D261);
- `/rnpc follow` and `rnpc.faction` for standalone servers.
- **Rust-Plugins (the bridge):** the faction table pushed with the profiles; `world.place` with an escort target
and a clan or team ally for an event's NPCs.
- **Module-Rust:** the profile form's new fields, a faction table on the NPC profiles page, and the Place NPCs
step's escort target and ally.
**Tested by** harness fights between profiles on the rig (who shoots whom, with what, and within which leash), and
an in-game walk for escort and faction ties to a clan.
**Measured (2026-10-01, `tools/RunicNpcHarness.cs` 0.2.0: `rnh.npcfight`, `rnh.sense`, `rnh.turrets`, `rnh.items`,
`rnh.pve`).** Both rigs, the same 6000 map as stage 1. Costs were measured on one rig at a time, with the other
stopped. Nobody was connected; where a player was needed, the harness used stage 1's stand-ins. The prototype combat
state lives in the harness, not in RunicNPC. **Every answer held on both frameworks** unless it says otherwise.
*The prototype.* Our sensing reads only the factions an NPC is hostile to, from a registry the plugin keeps as
scientists, animals and our NPCs spawn and die. It costs no physics query, only distances. It sorts the nearest three
and tests line of sight on those alone, once a second. Rust's own design still owns every fight with a player. Only when
it has none does our state take over: it faces the target, closes in until the target is in range and in sight, and hands
Rust's own `TickAttack` the target, so the shot is still Rust's.
**1. Why stage 1's NPC never hit an NPC, and what fixes it.**
- **Rust drops the hit, not the shot.** `BaseProjectile.ServerUse` skips every hit where **the shooter is an NPC and
the victim is an NPC**, unless the victim's faction is `Horror` (pets excepted). Animals count as NPCs too. Stage 1
read the missing hits as "the design's cover and facing states win". That was wrong: our NPC fired 79–96 times in
30 s at a stock scientist, and hit it 0 times.
- **`faction` is a plain field that nothing else in Rust reads.** `GetFaction()` is not virtual and is used only by that
check. So our NPC marks **its own target, and only for the length of its own shot**: it sets the target's faction to
`Horror` inside its `ShotTest` and `TriggerDown`, then puts it back. Nothing changes for anyone else's bullets.
- **One trap, found and fixed.** `ShotTest` calls `TriggerDown` inside itself, so the mark nests. The first run let the
inner call save `Horror` as the value to put back. That left targets `Horror` for good, and allies then hit each other
about 475 times in a 50-against-50 fight. A mark is now made only when none is in place. After the fix, no entity was
ever left `Horror` (counted after every phase).
| Fight (15 m, the kit's revolver, 60 s) | Oxide | Carbon |
|---|---|---|
| Ours → stock scientist, no mark | 0 hits in 30 s | 0 hits in 30 s |
| Ours → stock scientist, marked per shot | killed in 44 s, 29 hits | killed in 48 s, 29 hits |
| Ours ↔ ours (two profiles) | both hit; one died at 41 s | both hit; one died at 35 s |
| Ours → boar | it fought back and killed ours (27 s) | the same (25 s) |
| Ours → wolf | 25 hits in 90 s, wolf at 12/150; it never attacked | killed in 43 s, 28 hits; it never attacked |
| Ally of ours standing on the line of fire | **0 hits on the ally**; the scientist died at 47 s | **0 hits**; died at 39 s |
- **A stock scientist never shoots back at ours.** Rust's `HumanNPC.IsTarget` is true only for players, pets and
scarecrows, and it is not virtual. Shot by ours, the scientist takes cover and does nothing else. **One-sided, as
D263 decided.**
- **Fire is not filtered.** Rust's NPC-to-NPC check exists only for bullets. An NPC with a flamethrower burned a
polar bear that walked in, and would burn allied NPCs the same way. **It stays that way (D266).**
**2. What our sensing costs.** Thirty seconds a phase, after five to settle. The frame times include the world's own AI.
About 570 brains (Rust's scientists and animals) already queue for Rust's 2 ms AI budget on this map, which is why the
empty baseline runs well under stage 1's 50 fps.
| Phase | Oxide: median frame | Oxide: ms per think / thinks every | Carbon: median frame | Carbon: ms per think / thinks every |
|---|---|---|---|---|
| Baseline, empty | 22.8 ms | — | 21.2 ms | — |
| 100 of ours, hostile to scientists, none near | 23.2 | 0.18 / 0.8 s | 22.7 | 0.12 / 0.6 s |
| 100 of ours fighting 100 stock scientists | 27.9 | **2.5** / 7.3 s | 24.6 | **2.4** / 6.3 s |
| 50 of ours fighting 50 of ours | 25.9 | **1.9** / 4.8 s | 23.2 | **1.5** / 3.6 s |
| Empty again | 24.2 | — | 21.0 | — |
- **About 7–10× cheaper than Rust's own sensing.** Stage 1 measured 17.7–20.4 ms per think for 100 NPCs sensing NPCs
Rust's way. Ours costs 1.5–2.5 ms per think, gunfire included: about what fighting players cost in stage 1 (2.4–2.5 ms).
- **One sense costs 0.1 ms with nothing near, and 0.5–1.1 ms in a fight.** Nearly all of that is the two line-of-sight
tests; the distance scan over 50–270 candidates is small.
- **Rust's AI budget again sets how quickly they react, not the frame rate.** With 100 fighting on top of the world's
AI, each NPC thinks every 3.5–7.5 s. The cost warning (D227) gets a line for NPCs that fight NPCs, as stage 1's has one
for fighting players.
**3. Turrets and traps (D259).** Each turret was armed with Rust's own inventory (an AK and ammunition, fuel, shells).
It faced a stand-in player as the control, then a stock scientist, then ours. Every target was spawned outside the
turret's trigger and moved in, because a flame turret only notices what *enters* its trigger.
| | Stand-in player | Stock scientist | Ours |
|---|---|---|---|
| Auto turret | 4 hits, 70 damage in 30 s | the same | the same; ours shot back: 20 hits, **11 damage** to the turret's 1,000 |
| Flame turret | burned (196–225 hits) | dead in 7–8 s | dead in 8–10 s; ours shot back, 5–8 hits |
| Shotgun trap | 16 pellets a shot | dead in 2–3 s | dead in 2.5 s; it answered with one shot on Oxide, none on Carbon |
- **Player turrets already treat ours exactly as they treat Rust's scientists**, so D259's `default` needs no code.
Outpost and bandit-camp sentries (`NPCAutoTurret`) ignore every `ScientistNPC`, and ours is one, so they ignore ours
as they do Rust's.
- **Shooting back works, and a revolver barely scratches a turret.** A flame turret's damage arrives through its
fireballs, so "who hurt me" follows the fireball's creator back to the turret.
- `ignore` has Rust's own hook to use: `CanBeTargeted` for the auto turret, the flame turret and the trap.
NextGenPVE uses the same hook. **`always` adds the safe-zone sentries (D264).**
**4. The kit items Rust's AI uses by itself (D260).** Ours carried the kit's revolver, 3 syringes, 2 grenades and a
machete, with Rust's `CanUseHealingItems` switched on. A stand-in "shot" it 20 damage every 4 s, down to 37 health.
Then the main weapon was swapped for a rocket launcher, and then for a flamethrower.
- **Healing: never, in 60 s on either rig.** Rust heals only when it *enters* its cover state at an AI zone's cover
point, below a health fraction, on a random chance, and out of its target's sight. In open ground that never
happens.
- **Grenades and melee: never.** In Rust, only scarecrows throw. No scientist code ever switches to a melee weapon.
- **A rocket launcher held as the main weapon: never fired.** Rust's design stood in combat with it and did not call an
attack once.
- **A flamethrower as the main weapon works** (515–539 hits on the stand-in in 40 s).
- **Nothing is consumed.** 151 attempts to fire the revolver (31 hits) and 40 s of flame left the inventory's ammunition and fuel untouched,
because an NPC reloads for free.
So **every behaviour D260 lists is ours to write**: healing, grenades, melee, rockets, and the flamethrower's range.
**The main weapon's ammunition stays endless (D265).**
**5. TruePVE and NextGenPVE (D261).** Each damage direction was probed with a 10-damage hit, each plugin on its default
rules. The harness answered `CanEntityTakeDamage` three ways: not at all, "allow", and "deny". The PVE stand-ins
carry Steam-range ids, because both plugins treat only Steam ids as players.
| | Player → player (the control) | Player ↔ ours | Player ↔ stock scientist | Ours ↔ stock scientist |
|---|---|---|---|---|
| No PVE plugin | hurts | hurts | hurts | hurts |
| TruePVE 2.4.4 (uMod) | **blocked** | hurts; "deny" blocks it | hurts | hurts; "deny" blocks it |
| NextGenPVE 1.8.2 (Oxide) | **blocked** | hurts; **"deny" ignored** | hurts | hurts; "deny" ignored |
| NextGenPVE 1.8.2 (Carbon) | hurts: it failed to start (`SQLite.Interop` missing on this host) | hurts | hurts | hurts |
- **Both plugins' defaults already treat ours as Rust's scientists, both ways**, so D261's "allowed" changes nothing on
default rules. It protects ours from stricter rule sets.
- **Both plugins ask `CanEntityTakeDamage` once per hit. TruePVE honours `true` and `false`; NextGenPVE honours only
`true`.** D261's per-profile opt-out therefore cannot rely on that hook. RunicNPC does it itself, in places no PVE
plugin touches: an unkillable profile zeroes its damage in the NPC's own `Hurt`, and a harmless profile sets the NPC's
`damageScale` (Rust's own multiplier on an NPC's shots) to 0.
- NextGenPVE is not on uMod. Its source is on GitHub (`Remod-org/NextGenPVE`, GPL-2.0). Both plugins were removed from
both rigs afterwards, with their config, data and language files.
**6. Two notes on the harness itself.** A turret or trap spawned by the harness sometimes vanished the moment its
victim's body landed beside it. That looks like Rust's ground check on a deployable that was never placed by a player,
not anything an NPC did. The world's own wolves and a polar bear wandered into two scenarios; the tables count only the
entities the harness labelled.
**What the answers left open, and the org lead's answers (2026-10-01):**
1. **Rust's scientists cannot fight back against ours. → D263:** the fight stays one-sided; the scientist takes cover.
A profile hostile to `scientists` fights only the scientists that come within its own leash, and never goes into a
monument to look for them.
2. **What `turrets: always` means. → D264:** the safe-zone sentries too. Outpost's and Bandit Camp's turrets shoot that
profile's NPCs as they shoot a hostile player.
3. **Ammunition. → D265:** endless, as in Rust. Only D260's extras are used up.
4. **Fire and allies. → D266:** fire burns as it does in Rust, allies included.
What this adds to the build (D263–D266):
- **Sensing (D263):** a profile's hunt for `scientists`, `animals` or another profile is limited by its own leash
around its spot (its chase range, D233). Our sensing never drives an NPC towards a target beyond it, so it never
walks into a monument to look for scientists.
- **Turrets (D264):** `default` needs no code. `ignore` answers `CanBeTargeted` with "no" for the auto turret, flame
turret and trap. `always` makes Outpost's and Bandit Camp's sentries (`NPCAutoTurret`) target it, which Rust's own
`Ignore` and `IsEntityHostile` refuse for every `ScientistNPC`. Stage 5's build finds the hook that reaches those
two, or answers for the sentry from RunicNPC's side, and says which in this plan.
- **Ammunition (D265) and fire (D266):** nothing to build. The NPC's reload stays Rust's own, and fire damage is not
touched.
### Stage 6 — Roles
Bosses (a health bar drawn with CUI, phases at thresholds that swap profile values, announcements through the bridge

View File

@@ -7,13 +7,12 @@ the design of record is [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §3
## What this installs
Two components per Rust server, released together as a **bundle** — an exact pair CI has checked
speaks one protocol, never "the latest of each" — and, from RunicNPC's stage 4, a third:
speaks one protocol, never "the latest of each":
| Component | What it is | Released from |
|---|---|---|
| **The plugin** | `RunicGateway.cs`, one file that runs unchanged on Oxide and Carbon — and beside it, from protocol 13, the optional **ZoneManager helper** `RunicGatewayZones.cs` (PLAN_FIXES D181, D182), installed by default | [Rust-Plugins](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/releases) |
| **The sidecar** | `rust-link-sidecar`, which the plugin dials on loopback and the website reaches over HTTP | [Rust-Link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases) |
| **RunicNPC** | `RunicNPC.cs`, Runic Gateway's NPC plugin ([`../runicnpc/PLAN.md`](../runicnpc/PLAN.md)), placed beside the bridge **when the bundle carries it**: from RunicNPC's stage 4, the latest RunicNPC release that answers the API the bridge needs (D224). Optional until RunicNPC's stage 9; it needs **Kits**, like the bridge | [runicnpc-rust](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/releases) |
The game server opens no port for the bridge: the plugin is the client and the sidecar the
listener, on `127.0.0.1`. **One sidecar serves one Rust server.** A community running six servers
@@ -122,8 +121,7 @@ When you create a server from it:
**The install** downloads the sidecar, its launcher and the plugin from the bundle, checks each
against the bundle's checksum, and only then places them: the sidecar in `rust-link/`, the plugin in
`oxide/plugins/` or `carbon/plugins/`. When the bundle carries RunicNPC, `RunicNPC.cs` goes beside the plugin
(its data directory is left for RunicNPC to make). Any mismatch fails the install with the reason, before
`oxide/plugins/` or `carbon/plugins/`. Any mismatch fails the install with the reason, before
anything is placed.
**The first boot** prints, in the console, the token the sidecar generated — **once** — and a line
@@ -156,10 +154,7 @@ history, and the token is unchanged.
[`v2/rust/current.json`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/v2/rust/current.json)
on the installer's `bundles` branch. It names the sidecar binary for your platform and the
plugin tarball, each with a `sha256`.
2. **Download and verify** both against those checksums (`sha256sum -c`, or `Get-FileHash`) — and, when
the bundle has an `npc` entry, RunicNPC's tarball too. Copy `runicnpc/RunicNPC.cs` from it into the same
plugins directory as the bridge. **Do not create `data/RunicNPC/` yourself**: RunicNPC makes it on first
load, and one made from outside the game (a panel's file manager) is not writable by it.
2. **Download and verify** both against those checksums (`sha256sum -c`, or `Get-FileHash`).
3. **The plugin:** copy `runicgateway-rust-plugin/RunicGateway.cs` from the tarball into
`oxide/plugins/` or `carbon/plugins/`, and every other `.cs` the tarball's `manifest.json` lists
in `files` beside it — from protocol 13 that is `RunicGatewayZones.cs`, which lets ZoneManager count a
@@ -225,9 +220,9 @@ With the installer:
| | |
|---|---|
| `doctor --game rust [--server-id <id>]` | Per server: the framework; whether the plugin file is still the one deployed; each helper deployed beside it, as a **warning** when missing or edited (the bridge runs without one, and the row says what that costs); RunicNPC, when the bundle carried it, the same way (without it the site's NPC profiles and placements and events' profile NPCs are off); that the plugin's config names this server; the required uMod plugins; the service; and `/health` through to **plugin connected**. A stopped server is a warning; a running one whose plugin never connected is a failure, printed with the framework versions the plugin is known good on |
| `doctor --game rust [--server-id <id>]` | Per server: the framework; whether the plugin file is still the one deployed; each helper deployed beside it, as a **warning** when missing or edited (the bridge runs without one, and the row says what that costs); that the plugin's config names this server; the required uMod plugins; the service; and `/health` through to **plugin connected**. A stopped server is a warning; a running one whose plugin never connected is a failure, printed with the framework versions the plugin is known good on |
| `update --game rust` | Moves the sidecar and every server's plugin to the current bundle, and restarts the sidecars. Always all servers together — they share one binary |
| `uninstall --game rust [--server-id <id>] [--purge]` | Removes the service, the plugin file, its helpers and RunicNPC. **Keeps the plugin's config** and RunicNPC's `data/RunicNPC/` (an admin's placements and routes) — it is the website's, and it names the server. `--purge` also removes the sidecar config (the token) and the database. Removing the last server removes the shared binary too |
| `uninstall --game rust [--server-id <id>] [--purge]` | Removes the service and the plugin file. **Keeps the plugin's config** — it is the website's, and it names the server. `--purge` also removes the sidecar config (the token) and the database. Removing the last server removes the shared binary too |
With the egg: reinstall to update (above); the console is the diagnosis.

View File

@@ -1437,7 +1437,7 @@ five routes.
|---|---|---|
| `world.monuments` | `world.monuments` | This map's monuments in one stable order (grouped by prefab short name, then by position). Each has `value` (`kind`, or `kind#n` when the kind repeats), `kind`, `instance`, `of`, `label` (the game's display phrase), `x`, `z` and `grid`. Also `worldSize`, the placeable `prefabs` (`key`, `kind`, `label`), `eventsEnabled`, `maxCrates`, `maxNpcs` and `zoneManager` |
| `world.zone` | `world.ok` or `world.error` | Opens a ZoneManager temporary zone owned by the bridge. Needs `runId`, `key`, a location, `radius` (5–150 m) and `holdMs` (1 minute to 7 days); `name` is optional. Protocol 13 adds `flags`, `settings`, `enterMessage`, `leaveMessage`, `delivery`, `format` and `dome` (§19.11) |
| `world.place` | `world.ok` or `world.error` | Places `count` of one allowlisted `prefab` at a location, scattered within `spread` m (0–50, 10 by default for a group). All or nothing: if the game refuses one, the ones already made are killed. Protocol 13 adds `profile`, a RunicNPC profile in place of a prefab (§19.12) |
| `world.place` | `world.ok` or `world.error` | Places `count` of one allowlisted `prefab` at a location, scattered within `spread` m (0–50, 10 by default for a group). All or nothing: if the game refuses one, the ones already made are killed |
| `world.revert` | `world.ok` | Gives back what a run owns: the named `ids`, or else everything under `key`, or else everything the run owns. The answer lists `removed`, `gone` and `refused` |
| `world.owned` | `world.owned` | What the world still holds of what events made, **looked for** by net id or zone id, narrowed by `runId`. Anything gone is pruned from the registry as the walk passes it |
@@ -2383,118 +2383,3 @@ last hello when it is saved and in a dry run; `bad-option` and `dome-unavailable
ZoneManager and ZoneDomes reloads, a server restart and the forced opposite boot order (exactly one set of
spheres each time), revert and expiry removing the dome, and a dome refused without the helper. The
in-game rows (flags on a player, the messages) wait for the later in-game walk.
### 19.12 RunicNPC: the site's profiles and placements (runicnpc PLAN.md stage 4)
RunicNPC is Runic Gateway's own NPC plugin ([`../runicnpc/PLAN.md`](../runicnpc/PLAN.md), [`API.md`](../runicnpc/API.md)).
**The bridge is its only link to the site**: it calls RunicNPC's API for the site's commands and turns
RunicNPC's hooks into frames, and RunicNPC never talks to the sidecar, so "the sidecar is a dumb forwarder"
stays true. RunicNPC is optional until its stage 9. Without it every `npc.*` command answers `npc.error`
**`runicnpc-missing`**, and an event places Rust's own scientists only (D243).
The bridge needs **RunicNPC API 3** (D249), `RunicNpcApiNeeded` in the code and **`runicnpc_api = 3` in
`overlay.toml`**, which the release copies into its manifest and `checkPlugin.js` holds equal to the code.
An older RunicNPC answers `npc.error` **`runicnpc-old`**.
**Hello and status.** `integrations` gains `runicNpc`, like the other optional mods:
```json
"runicNpc": { "loaded": true, "version": "0.2.0", "api": 3 }
```
`api` is present only while RunicNPC is loaded. **The bridge sends hello again when RunicNPC loads or
unloads.**
**Four commands.** Each answers a reply of its own kind, or `npc.error` with a `reason` and RunicNPC's own
sentence in `message`.
| Command | Answers | |
|---|---|---|
| `npc.profiles` | `npc.profiles` | `managed`, `profiles` (name → profile, D238) and `refused` (name → why). The site reads it before its **first** push to a server, to adopt the server's own profiles (D244) |
| `npc.profiles.set` | `npc.ok` | `{"profiles": {name: profile, …}}` replaces the whole set, as a push does, and marks the server managed (D221). The answer's `refused` names each profile RunicNPC would not use, and why (a kit the server lacks) |
| `npc.placements` | `npc.placements` | Every placement: `id`, `placement` (its values), `alive`, `waiting` (D237), `note` (D239), `lastError`. Also `routes`, the names a placement may walk, and `cost`, the cost warning for what the server plans now (D227) |
| `npc.placement` | `npc.ok` | One change to one placement, by **`op`**, below |
`npc.placement`'s ops:
| `op` | Carries | Answers |
|---|---|---|
| `add` | `placement`: `profile`, `position` **x and z only**, and `/rnpc place`'s options (D246): `count`, `respawn`, `respawnMode`, `movement` | `id` (named by RunicNPC as in game, `<profile>-<n>`, D241), `position` (where it landed), `built`, `cost` |
| `set` | `id`, `placement` (a whole x, y, z moves it) | `id`, `cost` |
| `remove` | `id` | `id` |
| `rename` | `id`, `to` | `id` (the new), `previous` |
| `respawn` | `id` | `id`, `respawned` |
**A map point is put on the ground by RunicNPC** (`RunicNpc_AddPlacement`, D245): a ray down on terrain
and rock, never a building or a tree, so a roof is placed in game. Then it is checked against the navmesh
exactly as `/rnpc place` is, and a refusal is RunicNPC's own sentence: off the map, under water, off the
navmesh (`A roamer cannot stand there: …`), no such profile or route.
| `reason` | Means |
|---|---|
| `runicnpc-missing` | RunicNPC is not loaded |
| `runicnpc-old` | RunicNPC answers an API older than 3 |
| `malformed` | a field is missing or `op` is unknown |
| `not-found` | no placement of that id |
| `refused` | RunicNPC would not; `message` says why |
**Three events,** from RunicNPC's hooks, all **staff** class on the site:
```json
{"kind":"npc.died","type":"event","netId":"973440","profile":"bandit","name":"Road Bandit",
"owner":"placement:bandit-1","placement":"bandit-1","x":-2123.7,"z":2282.7,
"killerId":"76561198000000002","killerName":"Marisol","weapon":"rifle.ak.entity",
"contributors":[{"steamId":"76561198000000002","name":"Marisol","damage":180}]}
{"kind":"npc.health","type":"event","netId":"973440","profile":"bandit","name":"Road Bandit",
"threshold":0.5,"health":78.3,"maxHealth":180,"x":-2123.7,"z":2282.7}
{"kind":"npc.placement.changed","type":"event","id":"gate","change":"renamed","previous":"warden-1"}
```
- `owner` is RunicNPC's: `run:<id>`, `placement:<id>` or `plugin:<name>`. An event's NPC also carries
**`runId`**, a placement's **`placement`**. `killerId` and `killerName` are absent when no player landed
the killing blow. `contributors` is every player who took health from it, most first.
- `npc.health` is sent the first time an NPC falls to one of its profile's `healthThresholds`.
- `npc.placement.changed` is sent on every set, remove and rename, from the site or in game (`change` is
`set`, `removed` or `renamed`), so an edit made in game reaches the site at once. The site reads the list
again rather than trusting a diff.
**`world.place` takes a `profile`** instead of a `prefab` (D243): the NPCs are RunicNPC's, owned by the run
(`run:<runId>`), and recorded in the registry with `prefab` `runicnpc:<profile>`. Everything else is as
§15.1 says: the same bounds (`EventsMaxNpcs`), all or nothing, a repeated key answered with the first
call's ids. A scattered point RunicNPC refuses (off the navmesh) is tried again three times, then at the
centre. `world.revert` removes such an NPC **through RunicNPC** (`RunicNpc_Despawn`), which raises its own
despawn hook. Two new `world.error` reasons, both permanent: **`runicnpc-missing`** and
**`unknown-profile`** (the server has no such profile, or RunicNPC refuses it). A placement that names both a
prefab and a profile is `malformed`.
**The killfeed names RunicNPC's NPCs** (runicnpc PLAN.md §1.4). A `player.death` whose attacker is one of
them gains **`attackerNpc`** (its own name, as the victim's death screen shows it) and
**`attackerProfile`**, **beside** `attackerName`, which stays the prefab so a reader that knows only prefab
names still reads a scientist.
**`player.tally` gains `npcProfileKills`**, the player's kills of RunicNPC's NPCs by profile name
(`{"warden": 2}`). They are counted in `npcKills` as well.
**Hooks.** `OnRunicNpcDied`, `OnRunicNpcHealth` and `OnRunicNpcPlacementChanged` join `rg.hooks`' list.
`rg.npc` prints RunicNPC's version and API and the last `npc.*` command.
**The sidecar** forwards four routes, each a correlated round trip that fails while the game is down:
| Route | Command |
|---|---|
| `GET /npc/profiles` | `npc.profiles` |
| `POST /npc/profiles` | `npc.profiles.set` (the body's `cmd` and `reqId` written over, as every forward) |
| `GET /npc/placements` | `npc.placements` |
| `POST /npc/placement` | `npc.placement` |
The three events are filed and served like every other (§8.1).
**The website** (Module-Rust) authors the profiles (Admin → Rust NPC profiles) and pushes each server its
set on a loop of its own. Before the first push to a server it reads `npc.profiles`, and a standalone
server's profiles are adopted as profiles for that server alone; one whose name a site profile already has
there is kept aside as "replaced" (D244, D251). It lists and edits placements live (Admin → Rust NPC
placements), a new one by clicking the live map (D245, D246). It adds the triggers `rust.npc.died` and
`rust.npc.health`, which a phase can wait on, and stores `npcProfileKills` per profile for the leaderboard
and titles (D247, D250, D252).
**Walked on both rigs, 2026-09-30** (runicnpc PLAN.md stage 4).