- PLAN.md stage 3: what was built (runicnpc-rust#5), the results on both rigs (134/134, reload and restart 5/5), what building it found (Rust leaves an NPC in the air when its floor goes; a route leg can be on the navmesh and still unwalkable; a shore floor not covered; stage 2's JSON leak), the console-only at= option for the org lead's call, and the in-game walk checklist. - API.md: SetPlacement's navmesh check and D239 fallback, the placements' note, List's regrounded, and SetRoute's unchecked legs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
818 lines
60 KiB
Markdown
818 lines
60 KiB
Markdown
# RunicNPC — the plan
|
||
|
||
**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.
|
||
|
||
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
|
||
Carbon. Other plugins drive it through an API, as NpcSpawn is used, and admins use it directly in game through chat
|
||
commands. **It is a prerequisite of the rest of the Rust plan:** the redesigns resume only after its stage 9
|
||
(§9), and from then on `module-rust` requires it, as it requires Kits.
|
||
|
||
It replaces [`PLAN_REDESIGNS.md`](../modules/rust/PLAN_REDESIGNS.md) §6's "two routes". That section's spike was
|
||
run on 2026-09-30, and the org lead chose neither route (D214). What the spike found is §1 here, because every
|
||
choice below rests on it.
|
||
|
||
Its decisions continue the Rust workstream's numbering at **D214** ([`PLAN_REDESIGNS.md`](../modules/rust/PLAN_REDESIGNS.md) §10).
|
||
The repository carries the same conditions as every Runic Gateway repository: **GPL-3.0-or-later**, Conventional
|
||
Commits, AI-assisted work disclosed, branches cut from an up-to-date base, and the org lead's approval before any
|
||
architectural or design decision is implemented.
|
||
|
||
---
|
||
|
||
## 0. The direction, in the org lead's words (2026-09-30)
|
||
|
||
| # | Decision | Rejected |
|
||
|---|---|---|
|
||
| **D214** | **Runic Gateway writes its own NPC plugin**, an API-style plugin like NpcSpawn, in its own repository. It may borrow *behaviours* the other plugins implement, never their code. | Extending `rust.npc.place` on Rust's scientists alone (route A); HumanNPC (route B); depending on NpcSpawn. |
|
||
| **D215** | **It is not only for events.** Admins place NPCs in game, where they want them, for normal play, so it has chat commands. | NPCs only as event steps. |
|
||
| **D216** | **No visual editor.** Profiles are authored on the website; in game there are commands only. | NpcSpawn's in-game GUI (and the 22 images it needs). |
|
||
| **D217** | **Kits is how an NPC is equipped, and Kits is required.** A profile names kits; it has no item lists of its own. | Wear and belt lists in the profile. |
|
||
| **D218** | **Loot tables are RunicNPC's own for now.** Links to other loot plugins can come later. | AlphaLoot, CustomLoot, Loottable or LootManager as a dependency. |
|
||
| **D219** | **Rust's own navmesh is used. A custom navigation mesh is a later stage, built only if a real need appears** (§8, stage 10). | Shipping NpcSpawn-style point meshes from the start. |
|
||
| **D220** | **The plugin is built and tested in stages before the Rust plan resumes, and it then becomes a plugin `module-rust` requires.** | Building it alongside the remaining redesigns. |
|
||
| **D221** | **RunicNPC works without Runic Gateway too** (Q1). Standalone, its profiles live in its data file and console commands edit them. On a Runic Gateway server, the site's profiles replace that file on every push, and in-game profile edits are refused with "this server's profiles are managed by its website". | Runic Gateway only. |
|
||
| **D222** | **Placements live on the server** (Q2). They keep respawning while the site is offline; the site catches up when it returns, and lists and edits them from then on. | The site as the only record. |
|
||
| **D223** | **Our NPC is its own subclass of Rust's scientist, with its own brain** (Q3). The brain is kept small and tested on Rust's `staging` branch before each forced wipe. | Rust's scientist plus patches. |
|
||
| **D224** | **The installer and the egg ship it from the bundle, pinned and checksummed** (Q6). It is also published as a release on Gitea, and possibly on other sites for anyone to download. | Operators installing it themselves. |
|
||
| **D225** | **Per-profile kills are public and usable in titles** (Q4), like the NPC kills column today. | Admins only. |
|
||
| **D226** | **`edge` → `main`**, like the other Rust repositories (Q5, D18). | PRs straight to `main`. |
|
||
| **D227** | **No default caps.** Instead, a realistic warning of what a number of NPCs costs and its impact on the server, measured in stage 1, shown wherever NPCs are added (Q7). Caps exist only when an admin sets them. | 100 / 50 / 10 per second by default. |
|
||
| **D228** | **`RunicNPC.cs`, console name `RunicNPC`, chat command `/rnpc`, permissions `runicnpc.*`** (Q8). | — |
|
||
| **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`. |
|
||
|
||
**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
|
||
design, so there is nothing of it worth carrying. RunicNPC's code is written from how Rust's own classes behave (the
|
||
HOOKS and OXIDE_API mirrors, and rig probes), and every borrowed idea is credited in §3 by what it is, not by copied
|
||
text.
|
||
|
||
---
|
||
|
||
## 1. What the spike found (2026-09-30, the Oxide rig)
|
||
|
||
The probe was a throwaway plugin, `RgNpcSpike.cs`. It spawned NPCs by each route, watched them for minutes, made
|
||
them fight, killed them, and read what the bridge published through the sidecar's `/events`. The org lead stopped
|
||
the Carbon half: one server was enough to see that HumanNPC does not behave as needed.
|
||
|
||
### 1.1 Rust's own scientist, stripped and given a kit (route A)
|
||
|
||
| Question | Answer |
|
||
|---|---|
|
||
| Does the kit stick? | **Yes.** `Strip()`, Kits' `GiveKit`, then `EquipWeapon()`: after three minutes of combat it still wore the kit. Rust does not re-dress it. |
|
||
| Does it fight with the kit's weapon? | **Yes.** Every hit it landed was `pistol_revolver.entity`, from a revolver kit. Its prefab loadout was an LR-300. |
|
||
| Can it be named? | **No.** `ScientistNPC` overrides `displayName` with a get-only property that answers "Scientist". Setting `_displayName` stores the name and changes nothing. |
|
||
| Is it counted correctly? | Yes. `IsNpc` is true, so every bridge path treats it as an NPC. |
|
||
| Cost | Under Oxide's slow-call warning; no measurable hitch. |
|
||
|
||
### 1.2 HumanNPC 0.6.8 (route B) — ruled out
|
||
|
||
- **Its NPCs are counted as players.** It spawns `player.prefab` with a made-up user id and Harmony-patches
|
||
`BasePlayer.IsBot` for every player on the server. `IsNpc` stays **false**, and that is what the bridge tests. The
|
||
sidecar recorded a `player.death` for "Hobb" (`steamId` 11419803648) and a `player.tally` crediting "Hobb2" with an
|
||
NPC kill: a fake player in the public killfeed, the leaderboard and the titles.
|
||
- **The API the old plan named does not exist.** There is no `SpawnHumanNPC` or `SetHumanNPCInfo`: only
|
||
`CreateNPCHook(position, rotation, name, clone, saved)`. Kit, hostility and invulnerability can be set only by
|
||
reflection into its private types. Its `RefreshNPC` and `RemoveNPC` are public without `[HookMethod]`, so Oxide's
|
||
`Call` cannot reach them either.
|
||
- **Its combat is simulated.** Its hits carried no weapon and a flat ~7 "Bullet" damage, even with a bow.
|
||
- **Its persistence leaks.** `saved: false` holds only until anything else saves: one saved NPC wrote every unsaved
|
||
one to its data file. A dead NPC came back on the next reload. `RemoveNPC` never marks the file dirty, so removed
|
||
NPCs return at the next restart.
|
||
- **It is slow.** ~250 ms to create, ~250 ms to apply settings, ~250–320 ms to remove, each on the main thread.
|
||
- **It breaks on Rust updates.** Nearly every release since 2023 is "fix for Rust update"; 0.6.0 fixed servers that
|
||
failed to load.
|
||
|
||
### 1.3 NpcSpawn 3.4.8 (the org lead's suggestion)
|
||
|
||
It worked, and it is the model RunicNPC borrows from:
|
||
|
||
- It **replaces the scientist's component with its own subclass**, `CustomScientistNpc : ScientistNPC`, copying the
|
||
serialised fields across (`EntityManager.CopySerializableFields`). No Harmony. `IsNpc` is true, so the bridge
|
||
counts it correctly unchanged.
|
||
- **The name sticks**, and it is written into the victim's death screen (`AttackerInfo`).
|
||
- **Kits work**, and it fought with the kit's revolver (with `NpcAttackMode: 1`; its default only attacks a whitelist).
|
||
- **It is never saved** (`enableSaving = false`). After a hard kill and restart, none of three came back. Reloading
|
||
NpcSpawn kills all of its NPCs.
|
||
- Spawning took 4–106 ms.
|
||
|
||
Its costs, which RunicNPC avoids by design: a 2.1–2.8 s main-thread hitch at every boot (it scans ten million
|
||
candidate ids and generates spawn points); a version check over plain HTTP to a bare IP on every boot; it
|
||
**unloads itself** if any of its 22 GUI images is missing; it is not on uMod and states no licence, so it cannot be
|
||
bundled; and it has had 78 releases.
|
||
|
||
### 1.4 Two corrections to the old plan
|
||
|
||
- **"`displayName` already flows into the killfeed" is wrong for every route.** The bridge names an NPC attacker by
|
||
`initiator.ShortPrefabName` (`RunicGateway.cs`, `DescribeAttacker`), so the feed says `scientistnpc_roam`. Stage 4
|
||
fixes it.
|
||
- **HumanNPC's API, as described, does not exist** (§1.2).
|
||
|
||
### 1.5 A rig trap found on the way
|
||
|
||
A data directory created by the panel's file API is **not writable by the game** (`IOException: Permission denied`
|
||
from `Directory.CreateDirectory`). Let a plugin create its own data directory, then write files into it. The
|
||
installer and the egg must do the same with any data RunicNPC ships.
|
||
|
||
---
|
||
|
||
## 2. What RunicNPC is
|
||
|
||
**One plugin file, `RunicNPC.cs`**, compiled by Oxide or Carbon from source, like the bridge. It declares
|
||
`// Requires: Kits`, so neither framework loads it without Kits (D217).
|
||
|
||
**Its NPC is its own type,** a subclass of Rust's `ScientistNPC` with its own brain. This follows NpcSpawn's approach
|
||
(§1.3), because only a subclass can:
|
||
|
||
- override `displayName`;
|
||
- override the aim cone;
|
||
- write its name into the victim's death screen;
|
||
- carry behaviours Rust's brain does not have (guard, escort, boss phases).
|
||
|
||
Stage 1 proves the swap on both frameworks before anything is built on it.
|
||
|
||
**It owns nothing it is not asked for.** Every NPC has an **owner**, and the owner decides its lifetime:
|
||
|
||
| Owner | Who | Survives a restart? |
|
||
|---|---|---|
|
||
| `run:<runId>` | An event run, through the bridge | **No.** Never saved; the bridge's registry reverts it, as `world.place` does today. |
|
||
| `placement:<id>` | An admin's placement in game, or one made from the site | **Yes, as a placement:** RunicNPC stores the spot, profile and respawn delay, and spawns a fresh NPC there at boot. The NPC itself is never saved. |
|
||
| `plugin:<name>` | Any other plugin using the API | No. It is killed when that plugin unloads. |
|
||
|
||
This split is what HumanNPC got wrong: the *placement* is persistent data, the *NPC* never is. There is exactly one
|
||
save path, a flag marks it dirty on every change (including removal), and nothing else is written.
|
||
|
||
**Standalone or managed (D221).** Without a site, RunicNPC reads its profiles from its own data file and console
|
||
commands edit them. With a site, the bridge pushes the site's profiles, which replace the file; the file records that
|
||
it is managed, and in-game profile edits are refused with "this server's profiles are managed by its website".
|
||
Placements stay the server's either way (D222): the site reads them and edits them through the bridge.
|
||
|
||
**A profile is a named description of an NPC:**
|
||
|
||
- **Appearance:** name (or a list to pick from), the kits it wears, body type, and the prefab it starts from.
|
||
- **Combat:** health, damage scales (overall, and by head, body, legs and melee), aim spread, and ranges (sense,
|
||
chase, roam, attack).
|
||
- **Behaviour:** its role and AI states (§6).
|
||
- **Loot:** its loot table (§7).
|
||
|
||
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
|
||
|
||
Borrowed ideas are marked with their source; everything else is new. Stage numbers are §9's.
|
||
|
||
### 3.1 From NpcSpawn (behaviour only)
|
||
|
||
| Feature | Stage |
|
||
|---|---|
|
||
| A scientist subclass with its own brain; never saved | 2 |
|
||
| Names, a random name from a list; body type and skin tone chosen by the NPC's id | 2 |
|
||
| Health, overall and per-body-part damage scales, aim cone, headshot rule | 2 |
|
||
| Sense, listen, chase, roam and attack ranges; a vision cone; hostile-only; ignore safe-zone, sleeping or wounded players | 2, 5 |
|
||
| Sleep when no player is within a distance, and walk home | 2 |
|
||
| States: roam, chase, combat, idle, stationary combat | 2, 5 |
|
||
| NPC-versus-NPC and NPC-versus-animal rules, with allow and deny lists | 5 |
|
||
| Group alert: nearby allies join a fight | 5 |
|
||
| Turrets may target them, and they fight back | 5 |
|
||
| Healing with syringes; grenades, smoke, rockets, flamethrowers, melee (by the weapon the kit gives) | 5 |
|
||
| Parenting to a moving entity | 10 |
|
||
| Loot tables, a crate on death, removing the corpse | 7 |
|
||
| TruePVE / NextGenPVE compatibility (`CanEntityTakeDamage`) | 5 |
|
||
|
||
### 3.2 From HumanNPC (behaviour only)
|
||
|
||
| Feature | Stage |
|
||
|---|---|
|
||
| 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 |
|
||
|
||
### 3.3 Runic Gateway's own
|
||
|
||
| Feature | Stage |
|
||
|---|---|
|
||
| **Profiles authored on the website** and pushed to each server, per server, shared, or fleet-wide (the zone-presets pattern, D210). The site checks every kit exists before it saves. | 4 |
|
||
| **Admin placement in game** by chat command (§5), persistent, with respawn delays; the site lists and edits the placements | 3, 4 |
|
||
| **Correct accounting**: an NPC is never counted as a player; its name reaches the killfeed, event log and death screen; kills are counted per profile | 2, 4 |
|
||
| **Per-profile stats and titles**: "Warden kills", boss kills; the killing blow and every player who did damage | 4 |
|
||
| **Cost warnings, not default caps (D227)**: what N NPCs cost the server, measured in stage 1, shown when an admin places them, on the site's profile and placement pages, and in `doctor`. Caps (per server, owner or profile, and a spawn rate) exist only when an admin sets them | 2, 4 |
|
||
| **Event ownership**: run-owned NPCs with the bridge's restart and teardown guarantees | 4 |
|
||
| **Waves and phases** without a core change: a phase advances on "n NPCs of profile X died", and on "the boss fell below 50%" | 4, 6 |
|
||
| **Zone tethering**: an NPC held inside a ZoneManager zone (from redesign §3) | 5 |
|
||
| **Guard and escort**: hold a point or an entity; follow a convoy or a player | 5 |
|
||
| **Factions**: profiles hostile or allied to each other; allied to a team or clan, for defend-your-base events | 5 |
|
||
| **Bosses**: an on-screen health bar, phases at health thresholds, announcements (popup, chat, Discord and the app through engagement), a reward on the kill | 6 |
|
||
| **Quest-giver or vendor**: passive, press E for a site-written message | 6 |
|
||
| **The live map and the app**: markers for event NPCs and bosses, "guards left: 3/8" | 8 |
|
||
| **Operator-friendly**: shipped in the bundle, no phone-home, no image files, no boot scan; `doctor` reports it; RunicNPC's permissions appear in the site's permission manager like any plugin's | 2, 4 |
|
||
| **An API for other plugins**, versioned, documented, and usable without Runic Gateway at all (D221) | 2, 9 |
|
||
|
||
---
|
||
|
||
## 4. The API
|
||
|
||
Other plugins call RunicNPC by name through `Call`, which on Oxide reaches **non-public** methods and public ones
|
||
marked `[HookMethod]` (§1.2 is what happens otherwise). Every name is prefixed, so no hook of another plugin is ever
|
||
matched by accident. The first draft, fixed in stage 2 and published as `docs/runicnpc/API.md`:
|
||
|
||
| Call | Does |
|
||
|---|---|
|
||
| `RunicNpc_ApiVersion()` → `int` | The API's version. The bridge refuses a RunicNPC older than it needs, and says so in hello. |
|
||
| `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer` | Spawns one NPC, or returns null and logs why. `overrides` may change any profile value for this NPC only. |
|
||
| `RunicNpc_Despawn(ulong netId)` / `RunicNpc_DespawnOwner(string owner)` → `int` | Removes one, or all of an owner's. |
|
||
| `RunicNpc_List(string owner)` → `List<Dictionary<string, object>>` | The owner's live NPCs: id, profile, name, position, health. |
|
||
| `RunicNpc_Profiles()` / `RunicNpc_SetProfiles(JObject all)` | Reads, or replaces, the profile set. Replacing marks the server as managed by a site (D221). |
|
||
| `RunicNpc_Placements()` / `RunicNpc_SetPlacement(...)` / `RunicNpc_RemovePlacement(string id)` | The persistent placements (§2). |
|
||
| `RunicNpc_IsRunicNpc(BaseEntity e)` → `bool`, `RunicNpc_ProfileOf(BaseEntity e)` → `string` | Lets another plugin tell our NPCs apart. |
|
||
|
||
**Hooks it raises** (void; a returning hook can cancel a death, see PROTOCOL.md §8.7):
|
||
`OnRunicNpcSpawned(npc, profile, owner)`, `OnRunicNpcDied(npc, profile, owner, HitInfo, contributors)`,
|
||
`OnRunicNpcHealth(npc, profile, fraction)` (at the thresholds a profile names), `OnRunicNpcDespawned(npc, owner)`,
|
||
`OnRunicNpcUsed(npc, player)`.
|
||
|
||
**The bridge is its only link to the site.** RunicNPC never talks to the sidecar. The bridge calls the API and
|
||
listens to the hooks, which keeps "the sidecar is a dumb forwarder" true.
|
||
|
||
---
|
||
|
||
## 5. In game: the chat commands (D215, D216)
|
||
|
||
`/rnpc` (and the same verbs in the server console as `rnpc.<verb>`), each behind a RunicNPC permission. Those
|
||
permissions reach the site's permission manager through the inventory, like any plugin's.
|
||
|
||
| 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 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` |
|
||
| `/rnpc tp <placement>` | Teleports you to a placement | `runicnpc.admin` |
|
||
| `/rnpc respawn [placement\|all]` | Respawns a placement's NPC now | `runicnpc.admin` |
|
||
| `/rnpc clear <owner>` | Removes every NPC of an owner (e.g. a stuck run) | `runicnpc.admin` |
|
||
|
||
**Every placement is checked against Rust's navmesh (D219).** Every placement also answers with the
|
||
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).
|
||
|
||
---
|
||
|
||
## 6. Behaviour
|
||
|
||
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. |
|
||
| **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 and resumes (stage 5). |
|
||
| **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 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
|
||
is optional, and a profile that asks for a tether on a server without it is refused at save.
|
||
|
||
---
|
||
|
||
## 7. Loot (D218)
|
||
|
||
A profile's loot table: items with chances and amounts, optionally clearing the corpse's default loot, dropping a
|
||
crate, or removing the corpse. Tables are authored on the site with the profile. Adapters to AlphaLoot and the
|
||
others are a later stage and not planned in detail here.
|
||
|
||
---
|
||
|
||
## 8. Navigation (D219)
|
||
|
||
**Rust's navmesh is the only one in stages 1–9.** Every spike NPC walked and fought on it, and NpcSpawn itself
|
||
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);
|
||
- custom map prefabs Rust's mesh does not cover;
|
||
- moving platforms, together with parenting.
|
||
|
||
Stage 10 starts only when a real event or placement needs one, and its own spike decides the file format and cost.
|
||
|
||
---
|
||
|
||
## 9. The stages
|
||
|
||
Each stage is built, then tested on the **Oxide rig**, then on the **Carbon rig**, then PR'd with its docs. A stage
|
||
that touches the site also has a browser walk, signed in as an admin, on every new page. A stage whose acceptance
|
||
needs a player in game writes that walk down, and it is done before the stage closes.
|
||
|
||
### Stage 0 — The repository
|
||
|
||
- `LICENSE.md` (GPL-3.0-or-later), `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md` (with the AI-disclosure rule),
|
||
`SECURITY.md`, `README.md`, PR template.
|
||
- Branches `main` and `edge` (D226).
|
||
- **CI on every PR:** a static reader like Rust-Plugins' `checkPlugin.js`: every hook it declares is known, none
|
||
returns a value where one would cancel a game action, and the API version in the source equals the manifest's.
|
||
- **Release on `main`:** `runicnpc-<ver>.tar.gz` holding `runicnpc/RunicNPC.cs` and a `manifest.json` (version, API
|
||
version, sha256), plus `SHA256SUMS`, using the same release engine as Rust-Plugins.
|
||
- `tools/`, never shipped: the rig harness (stage 1) and the panel scripts (`rig.js`, `con.js`), reading tokens at
|
||
call time.
|
||
- Workspace and org updates: the `CLAUDE.md` repository table and the org landing page (`.profile`), because the
|
||
project's shape changes.
|
||
|
||
**Done when** an empty plugin releases through CI and loads on both rigs.
|
||
|
||
**As built (2026-09-30, runicnpc-rust#1):**
|
||
|
||
- **The plugin** has `// Requires: Kits` and `[Info("RunicNPC", "Runic Gateway", "0.0.0")]`, whose version the
|
||
release replaces. It answers `RunicNpc_ApiVersion()` (API 1) and `rnpc.status` (version, API version, which
|
||
hooks have fired). It spawns nothing.
|
||
- **`plugin.toml`** (D230) declares `api = 1`, `requires_plugins = ["Kits"]` and the floors it was loaded on:
|
||
**Oxide 2.0.7726** and **Carbon 2.0.259**. The Oxide floor is newer than the bridge's 2.0.7585, because the rig
|
||
runs 7726 and RunicNPC has never run on anything older.
|
||
- **`scripts/checkPlugin.js`** runs the bridge's checks (every hook listed in `ExpectedHooks` and void unless
|
||
listed in `ANSWERS_DELIBERATELY`; chat-command signatures). It adds three of its own:
|
||
- no `RunicNpc_*` call may be public without `[HookMethod]`, because `Call` cannot reach it (the trap in §1.2);
|
||
- `// Requires:` must equal `requires_plugins`, and Kits must be in it;
|
||
- there must be exactly one `[Info]` line, because the release stamps that line.
|
||
|
||
It has 23 self-tests, including the real plugin and a CRLF checkout: on Windows, an attribute pattern that
|
||
only knew `\n` stopped seeing `[HookMethod]`.
|
||
- **The release** reuses Rust-Plugins' release engine unchanged. Its packaging step ships
|
||
`runicnpc-<ver>.tar.gz`, holding `runicnpc/RunicNPC.cs` and `manifest.json`, whose fields are component,
|
||
version, commit, repo, **`api`**, the floors, `requires_plugins` and `files` with each file's sha256. It ships
|
||
`SHA256SUMS` beside it. It does not ask the installer to rebuild its bundle until stage 4 makes RunicNPC part of
|
||
that bundle.
|
||
- **`tools/`**: `rig.js` and **`console.js`**, not `con.js`. `CON` is a reserved device name on Windows, so git
|
||
there cannot open a file of that name, even with an extension. The panel address and server ids moved into
|
||
a git-ignored `tools/rigs.json`.
|
||
- **Loaded from the branch on both rigs:** `rnpc.status` answered `api=1 hooks=1 fired=1` on Oxide and on Carbon
|
||
(Carbon compiled it in 217 ms).
|
||
- Also updated: the workspace `CLAUDE.md` repository table, which now lists every org repository, and the org
|
||
landing page (`.profile#7`).
|
||
|
||
### Stage 1 — The spike
|
||
|
||
A harness plugin (`tools/RunicNpcHarness.cs`) answers, on **both** rigs, what stage 2 depends on:
|
||
|
||
1. The subclass swap (`CopySerializableFields` into our `ScientistNPC` subclass and brain) spawns, walks, fights
|
||
and dies cleanly on Oxide **and Carbon**.
|
||
2. `enableSaving = false` holds across a hard kill and restart (§1.3 did this for NpcSpawn only).
|
||
3. Which ranges and scales hold on the brain without our own states, and which need them.
|
||
4. Navmesh coverage: a placement check at every monument of the 6000 map, and on a roof.
|
||
5. The cost of spawning 1, 10 and 100, and of 100 idle and 100 fighting, as the server's frame time. These numbers
|
||
are the cost warning's (D227), so they are measured on both rigs and on the 6000 map.
|
||
6. The name in the death screen, and what the bridge publishes for a kill by and of our NPC.
|
||
|
||
**Done when** each has a measured answer written into this plan, and the org lead has picked anything the answers
|
||
leave open.
|
||
|
||
**Measured (2026-09-30, `tools/RunicNpcHarness.cs`, runicnpc-rust#3).** Both rigs ran the same world for this:
|
||
the Oxide rig was regenerated to the Carbon rig's **6000 map, seed 981448696, 12 GB**. Costs were measured on one rig
|
||
at a time, with the other stopped, because they share the node's CPU. Nobody was connected. Each answer below held on
|
||
**both** frameworks unless it says otherwise.
|
||
|
||
*How the harness makes a player.* Q6 and the fighting load need a player, and the rigs have none. At the org lead's
|
||
suggestion, the harness spawns **stand-ins**: `player.prefab` with a made-up user id above Rust's bot range
|
||
(11400000001 and up). `IsNpc` is false, so Rust's AI and the bridge treat one exactly as a player. This is §1.2's
|
||
HumanNPC lesson, used on purpose. A stand-in never connects, and only the harness makes one.
|
||
|
||
**1. The swap works, with one step the plan did not know about.**
|
||
|
||
- **`CreateEntity(prefab, pos, rot, startActive: false)` must be followed by `gameObject.AwakeFromInstantiate()`
|
||
before `Spawn()`.** That is the call `GameManager.CreatePrefab` makes itself when `active` is true. Without it the
|
||
entity still spawns, is networked and even thinks. But Unity never starts the brain, so it has no AI design, no
|
||
navigator and no state. It stood still for 90 s.
|
||
- With that call, the swap takes **9–16 ms**. The NPC is our type (`RnhNpc`, `RnhBrain`), `IsNpc` is true, it is
|
||
not saved, it keeps its name, and it wears and holds its kit (`pistol_revolver.entity`).
|
||
- It walks, chases and **fights a player with its kit's weapon**: it killed a stand-in from 12–13 m in 14–20 s, every
|
||
hit a `pistol_revolver.entity`. It dies cleanly. Its corpse, an `NPCPlayerCorpse`, carries its name.
|
||
- **The field copy differs between frameworks.** The rule "public and not `[NonSerialized]`, or `[SerializeField]`"
|
||
copied 64 NPC and 32 brain fields on Carbon, but **124 and 36 on Oxide**. Oxide's patcher makes private fields
|
||
public, so on Oxide the rule also carries runtime state across. It worked on both, but stage 2 needs a rule that
|
||
selects the same fields on both, for example a list read from Carbon's result and checked on Oxide.
|
||
|
||
**2. `enableSaving = false` holds.** Five of ours were never saved and one was, as a control. After `server.save`, a
|
||
hard kill and a restart, **none of the five came back, on either rig**, matched by net id. The control came back,
|
||
but as a plain `ScientistNPC` named "Scientist": a saved subclass loses its type and its name when the server loads
|
||
it. That is the other reason our NPC must never be saved (§2).
|
||
|
||
**3. What holds on Rust's brain, and what needs our own states.**
|
||
|
||
| Setting | Result |
|
||
|---|---|
|
||
| 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.** |
|
||
| Sleep | An NPC outside an AI zone is never put to sleep, and `ai_dormant` does not apply to this AI. So an idle NPC keeps thinking with nobody near, which is what the costs below measure. |
|
||
| **Fighting NPCs** | **Rust's scientist AI never attacks an NPC.** `HumanNPC.IsTarget` is true only for non-NPC players, pets and scarecrows. `IsFriendly` means "same prefab id", which the swap copies, so every stock scientist counts ours as a friend. Re-implementing Rust's internal `IAISenses` puts a scientist in our NPC's target list. But Rust's AI design never runs its attack event for an NPC target. Calling `AttackTick` directly passed line of sight every time and still fired no shot, because the design's cover and facing states win. **NPC-versus-NPC combat needs our own combat state, and sensing NPCs needs our own sensing** (see the cost below). D231 makes it a profile setting, so stage 5 opens with its own spike. |
|
||
|
||
**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.
|
||
SamplePosition`**, the same helper Rust's navigator uses. It answers false until `RustNavigation.Instance.
|
||
IsDefaultNavmeshBuilt()`. On a map's **first** boot that took 8–10 minutes (477 s on Carbon, 604 s on Oxide); later
|
||
boots load the saved `proceduralmap.<size>.<seed>.<n>.navmesh`. **Placements that respawn at boot must wait for it.**
|
||
|
||
| | Carbon | Oxide |
|
||
|---|---|---|
|
||
| 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 |
|
||
| 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.
|
||
|
||
**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
|
||
fight NPCs beside 100 stock scientists, then empty again. Every phase also had one 1–3 s spike from the autosave, so
|
||
the table quotes the median frame rather than the worst.
|
||
|
||
| Phase | Oxide: median frame / fps | Oxide: time per think / each NPC thinks every | Carbon: median frame / fps | Carbon: time per think / each thinks every |
|
||
|---|---|---|---|---|
|
||
| Baseline, empty | 16.4 ms / 52.7 | — | 17.3 ms / 46.6 | — |
|
||
| 1 | 16.7 / 50.7 | 0.09 ms / 0.28 s | 17.3 / 48.1 | 0.05 ms / 0.29 s |
|
||
| 10 | 16.6 / 51.8 | 0.04 ms / 0.28 s | 17.7 / 47.4 | 0.03 ms / 0.29 s |
|
||
| 100 idle | 17.7 / 48.3 | 0.04 ms / 0.30 s | 18.9 / 41.8 | 0.03 ms / 0.33 s |
|
||
| 100 shooting 20 players | 21.6 / 44.5 | 2.5 ms / **2.5 s** | 22.1 / 43.1 | 2.4 ms / **2.6 s** |
|
||
| 100 sensing NPCs, beside 100 scientists | 26.4 / 33.9 | **17.7 ms** / **7.5 s** | 26.1 / 33.9 | **20.4 ms** / **8.2 s** |
|
||
| Spawning 100 at once | a **552 ms** hitch (5.5 ms each) | | a **483 ms** hitch (4.8 ms each) | |
|
||
|
||
What the numbers say:
|
||
|
||
- **The two frameworks cost the same.** Every row agrees within about 1 ms, so one warning serves both.
|
||
- **This rig is slow.** An empty server runs at about 50 fps, not its 240 target. The warning therefore reports
|
||
what NPCs **add**, not an absolute fps.
|
||
- **Idle NPCs are cheap:** 100 add about 1 ms to the median frame.
|
||
- **Fighting NPCs hit Rust's AI budget before they hit the frame rate.** Every human NPC's thinking shares
|
||
`aithinkmanager.framebudgetms`, 2 ms per frame. At 100 fighting, each NPC gets to think only every 2.5 s, so they
|
||
react sluggishly while the median frame rises by only about 5 ms. The warning has to say both things.
|
||
- **Sensing NPCs the way Rust does, with a line-of-sight test to every candidate, costs about 7× more per think than
|
||
fighting players.** 100 NPCs sensing each other make roughly 100² tests. D231's NPC targeting must sense
|
||
differently, for example by testing only the profiles it is hostile to, nearest first.
|
||
- **Spawn in batches.** 100 in one frame is a half-second hitch; stage 2 spreads a large placement over frames.
|
||
|
||
**6. The name on the death screen, and what the bridge publishes.**
|
||
|
||
- **`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.
|
||
- **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;
|
||
- a player kills our NPC → **only** `player.tally` with `npcKills: 1` and the weapon. Nothing names the NPC or its
|
||
profile.
|
||
|
||
Both are stage 4's to fix: the NPC's name in the feed, and a death frame with its profile.
|
||
- **Left on the rigs:** each rig's sidecar store now has the stand-in "RnhDummy" (11400000001) as a player, with
|
||
one death and one tally row. It is test data on test rigs; a wipe clears it.
|
||
|
||
### 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`.
|
||
|
||
**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.
|
||
|
||
### 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).
|
||
|
||
**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.
|
||
|
||
### Stage 4 — Runic Gateway integration
|
||
|
||
Across four repositories, split into 4a–4c if it grows:
|
||
|
||
- **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 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;
|
||
- 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.
|
||
- **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 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. 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.
|
||
|
||
**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.
|
||
|
||
### Stage 6 — Roles
|
||
|
||
Bosses (a health bar drawn with CUI, phases at thresholds that swap profile values, announcements through the bridge
|
||
to engagement, a reward on the kill through the existing reward verbs) and passive NPCs (press E, site-written lines).
|
||
|
||
**Tested by** an in-game walk (the bar, a phase change, the announcement arriving on Discord and in the app), and
|
||
the site's trigger firing at the threshold.
|
||
|
||
### Stage 7 — Loot
|
||
|
||
Loot tables authored on the site, the corpse's default loot cleared or kept, a crate on death, corpse removal.
|
||
|
||
**Tested by** harness kills whose corpses are read back, 100 times each, against the table's chances.
|
||
|
||
### Stage 8 — On the map and in the app
|
||
|
||
Event NPCs and bosses on the live map (protocol 11's `map.live`), a live "left: 3/8" on the event page, and the same
|
||
in the Android app.
|
||
|
||
**Tested by** a browser walk and an emulator walk.
|
||
|
||
### Stage 9 — Hardening and release
|
||
|
||
- **Performance to the budget stage 1 measured:** 100 NPCs on a 6000 map without the frame time crossing it.
|
||
- **An update drill:** a rig on Rust's `staging` branch loads the current RunicNPC before each forced wipe. The
|
||
subclass swap and the brain are what break when Rust changes, so they are kept small and listed in one place.
|
||
- **The whole acceptance walk on both rigs, with a player.**
|
||
- **Operator documentation:** `docs/runicnpc/` gets INSTALL and a command reference; `module-rust`'s OPERATING notes
|
||
list it as required.
|
||
- **Publishing (D224):** the Gitea release is the source of record. Other download sites (uMod, Codefling) are
|
||
decided then, each against its own rules, for example uMod's review guidelines.
|
||
- **v1.0.0 released, and `module-rust` then requires it:**
|
||
- the bundle lists it;
|
||
- the module refuses NPC steps on a server without it, saying why;
|
||
- the bridge reports it in hello.
|
||
|
||
**Then the Rust plan resumes** at PLAN_REDESIGNS §9 item 6 (the step editor and the kit weekend) and §11.
|
||
|
||
### Stage 10 — Custom navigation (only if needed)
|
||
|
||
§8. Not scheduled.
|
||
|
||
---
|
||
|
||
## 10. Maintenance, honestly
|
||
|
||
| What breaks | When | How we know | Cost |
|
||
|---|---|---|---|
|
||
| The subclass swap and the brain states | A Rust update that changes `ScientistNPC`, its brain, or `BaseNavigator` | The staging-branch rig, before the wipe | Our release timing, not an upstream author's. This is NpcSpawn's churn, but for the states we use, not all of its twenty. |
|
||
| Kits' API | A Kits release that renames `GiveKit` | Stage 2's harness | Low: one call. |
|
||
| ZoneManager's API | A ZoneManager release | Stage 5's harness | Low, and the tether is optional. |
|
||
| Carbon's compatibility layer | A Carbon release | The Carbon rig | Seen before; recorded in CARBON.md. |
|
||
|
||
A RunicNPC fix ships as its own release. It moves the bundle's pin only when the bridge needs it to, so an NPC fix
|
||
does not wait for a bridge release, or the reverse.
|
||
|
||
---
|
||
|
||
## 11. Questions for the org lead
|
||
|
||
**All eight were answered on 2026-09-30, as recommended except Q6 (D224 adds Gitea and other download sites)
|
||
and Q7 (D227: no default caps, a cost warning instead). They are kept as asked.**
|
||
|
||
**Q1. Can RunicNPC be used without Runic Gateway?** Picture a server owner who installs only RunicNPC, from uMod.
|
||
|
||
- **(a, recommended) Yes.** Without a site, profiles live in RunicNPC's data file and console commands edit them.
|
||
With a site, the site's profiles replace that file on every push, and in-game profile edits are refused with "this
|
||
server's profiles are managed by its website".
|
||
- **(b) No.** Profiles come only from a site, and the plugin is only ever installed by our installer.
|
||
|
||
**Q2. An admin places three guards with `/rnpc place`, then the site is offline for a day. What happens?**
|
||
|
||
- **(a, recommended)** The placements live on the server, so the guards keep respawning. The site catches up when it
|
||
returns, and shows and edits them from then on.
|
||
- **(b)** The site is the only record, so a placement made while it is offline is refused.
|
||
|
||
**Q3. What should our NPC be?**
|
||
|
||
- **(a, recommended)** Its own subclass of Rust's scientist with its own brain, as NpcSpawn does. It can be named,
|
||
and it can guard, escort and run boss phases. The brain is the part a Rust update can break.
|
||
- **(b)** Rust's scientist left as it is, plus small patches for the name and a few values. There is less to break,
|
||
but there is no guard, escort or boss behaviour.
|
||
|
||
**Q4. A player kills your "Warden" boss. Where does "Warden kills: 3" show?**
|
||
|
||
- **(a, recommended)** On their public profile and as a title condition, like the NPC kills column today.
|
||
- **(b)** Only to admins.
|
||
|
||
**Q5. Branches.** Like the other Rust repositories (D18), build on `edge` and cut over to `main` for releases?
|
||
**Recommended: yes.**
|
||
|
||
**Q6. How does RunicNPC reach a server?**
|
||
|
||
- **(a, recommended)** The installer and the egg install it from our bundle, pinned and checksummed like the bridge.
|
||
Kits and ZoneManager stay "report, don't install" (D153).
|
||
- **(b)** Operators install it themselves, like Kits, and `doctor` only reports it.
|
||
|
||
**Q7. How many NPCs by default?** Recommended defaults, each editable per server:
|
||
|
||
- 100 NPCs on a server in all;
|
||
- 50 per owner (one event run, or all of one admin's placements);
|
||
- 10 spawns per second.
|
||
|
||
**Q8. The name.** The plugin file is `RunicNPC.cs`, so its console name is `RunicNPC`, with the command `/rnpc` and
|
||
permissions `runicnpc.*`. **Recommended: yes.**
|