docs(runicnpc): stage 4 built and walked; protocol 13 §19.12; API 3

PROTOCOL.md §19.12: RunicNPC over the bridge — integrations.runicNpc, the
npc.profiles, npc.profiles.set, npc.placements and npc.placement commands and
their refusals, the npc.died, npc.health and npc.placement.changed frames,
world.place with a profile, attackerNpc and attackerProfile, the tally's
npcProfileKills, the four sidecar routes, and overlay.toml's runicnpc_api.

API.md: API 3 — RunicNpc_AddPlacement, RunicNpc_RenamePlacement,
RunicNpc_RespawnPlacement and OnRunicNpcPlacementChanged.

PLAN.md stage 4: what was built in five repositories, the walk on both rigs
row by row, what was not walked and why, and what building it found.

rust-link/INSTALL.md: RunicNPC as the bundle's third file, by hand, the egg,
doctor and uninstall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-30 08:45:21 -05:00
parent c6ba46d692
commit 2ad2043060
4 changed files with 208 additions and 10 deletions

View File

@@ -1,7 +1,9 @@
# RunicNPC — the API # RunicNPC — the API
**API version 2** (RunicNPC stage 2, 2026-09-30). This is the reference for other plugins. Why it has this **API version 3** (RunicNPC stage 4, 2026-09-30). This is the reference for other plugins. Why it has this
shape is in [PLAN.md](PLAN.md) §4 and the decisions D221–D238. shape is in [PLAN.md](PLAN.md) §4 and the decisions D221–D238 and D249. **API 3 added** `RunicNpc_AddPlacement`,
`RunicNpc_RenamePlacement`, `RunicNpc_RespawnPlacement` and the hook `OnRunicNpcPlacementChanged`, for a
website that lists, edits and creates placements (PLAN.md stage 4); nothing of API 2 changed.
RunicNPC is called by name through `Call`, like any Oxide or Carbon plugin API. Every call is prefixed RunicNPC is called by name through `Call`, like any Oxide or Carbon plugin API. Every call is prefixed
`RunicNpc_`, so no hook of another plugin is ever matched by accident. Every call is a non-public method, which is `RunicNpc_`, so no hook of another plugin is ever matched by accident. Every call is a non-public method, which is
@@ -11,7 +13,7 @@ what Oxide's `Call` reaches by name (PLAN.md §1.2).
[PluginReference] private Plugin RunicNPC; [PluginReference] private Plugin RunicNPC;
int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0; int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0;
if (api < 2) { /* too old for this caller: say so */ } if (api < 3) { /* too old for this caller: say so */ }
BasePlayer npc = RunicNPC.Call("RunicNpc_Spawn", position, "warden", "plugin:MyPlugin", null) as BasePlayer; BasePlayer npc = RunicNPC.Call("RunicNpc_Spawn", position, "warden", "plugin:MyPlugin", null) as BasePlayer;
``` ```
@@ -41,7 +43,7 @@ Every NPC has an owner, and the owner decides its lifetime (PLAN.md §2).
### `RunicNpc_ApiVersion()` → `int` ### `RunicNpc_ApiVersion()` → `int`
The API version: `2`. The API version: `3`.
### `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer` ### `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer`
@@ -117,6 +119,30 @@ in the air.
Removes a placement and its NPCs. Removes a placement and its NPCs.
### `RunicNpc_AddPlacement(JObject placement)` → `Dictionary<string, object>`
API 3. **Creates a placement and names it as `rnpc place` does**: the profile's name and the first free
number, `warden-3` (D241, D246). It is checked and refused exactly as `rnpc place` is, with the same
sentences, so a placement made from a website and one made in game can never disagree.
- `placement` is a placement (below) without an id. **A `position` without `y` is a point on a map**
(D245): it is put on the ground there by a ray down on terrain and rock, never a building or a tree (a
roof is placed in game), then checked against the navmesh like any other. A point off the map, or under
water, is refused.
- The answer is `{id, position, built, cost}`: the name given, where it landed, whether that spot is on
something players built (D239's warning), and the cost warning (D227). Or `{error}`, the sentence
`rnpc place` would have said.
### `RunicNpc_RenamePlacement(string from, string to)` → `string`
API 3. Renames a placement, as `rnpc rename`: its live NPCs keep living under the new name. Null on
success, or why not (no such placement, the name taken, not a name).
### `RunicNpc_RespawnPlacement(string id)` → `int`
API 3. Removes a placement's NPCs and spawns them again at once, as `rnpc respawn`. How many, or `-1` if
there is no such placement.
### `RunicNpc_Routes()` → `JObject` ### `RunicNpc_Routes()` → `JObject`
`{name: route, …}`. `{name: route, …}`.
@@ -157,6 +183,7 @@ All are called on every plugin, and **none of them answers**: a return value is
| `OnRunicNpcHealth(BasePlayer npc, string profile, float threshold)` | The first time its health falls to one of the profile's `healthThresholds`. | | `OnRunicNpcHealth(BasePlayer npc, string profile, float threshold)` | The first time its health falls to one of the profile's `healthThresholds`. |
| `OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)` | When it dies. `contributors` maps each player's Steam id to the health that player took from it, the killing blow included, after Rust's protection. It never adds up to more than its health. | | `OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)` | When it dies. `contributors` maps each player's Steam id to the health that player took from it, the killing blow included, after Rust's protection. It never adds up to more than its health. |
| `OnRunicNpcDespawned(BasePlayer npc, string owner)` | When it is removed without dying. Not raised as RunicNPC itself unloads. | | `OnRunicNpcDespawned(BasePlayer npc, string owner)` | When it is removed without dying. Not raised as RunicNPC itself unloads. |
| `OnRunicNpcPlacementChanged(string id, string change, string previous)` | API 3. Whenever a placement is set, removed or renamed, through the API or in game. `change` is `set`, `removed` or `renamed`; `previous` is the old name for a rename and null otherwise. |
--- ---

View File

@@ -6,6 +6,7 @@ decided with it. **Stage 1 measured 2026-09-30** (§9): six answers on both rigs
**Stage 2's design answered 2026-09-30:** D232–D238 (§0), which reshape stage 2 (§9). **Stage 2 built and tested **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 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 (§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). design answered 2026-09-30:** D243–D252 (§0).
RunicNPC is Runic Gateway's own NPC plugin for Rust servers, in its own repository, RunicNPC is Runic Gateway's own NPC plugin for Rust servers, in its own repository,
@@ -730,6 +731,56 @@ 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 **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. 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 ### Stage 5 — Behaviour
Guard, patrol and escort roles; factions (between profiles, with Rust's scientists, and with teams or clans); group Guard, patrol and escort roles; factions (between profiles, with Rust's scientists, and with teams or clans); group

View File

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

View File

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