The org lead's answers of 2026-09-30 for stage 5 (behaviour): one stage walked once, opening with the D231 spike; named factions with per-profile exceptions; players-only by default; group alert at 40 m; clan and team allies defended; escort targets from events, the API and /rnpc follow; turrets as for Rust's scientists; kit extras opt-in; PVE hooks allowed both ways; a patrol returns to the point it was heading to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
935 lines
77 KiB
Markdown
935 lines
77 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. **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
|
||
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`. |
|
||
| **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. |
|
||
|
||
**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, 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). |
|
||
| **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).
|
||
|
||
**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, 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).
|
||
|
||
- **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).
|
||
- **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);
|
||
- 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).
|
||
- **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).
|
||
|
||
**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.
|
||
|
||
### 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.
|
||
|
||
### 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.**
|