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

Merged
whitlocktech merged 1 commits from docs/runicnpc-stage4-built into main 2026-09-30 19:51:38 +00:00
4 changed files with 208 additions and 10 deletions
Showing only changes of commit 2ad2043060 - Show all commits

View File

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

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
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,
@@ -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
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

View File

@@ -7,12 +7,13 @@ the design of record is [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §3
## What this installs
Two components per Rust server, released together as a **bundle** — an exact pair CI has checked
speaks one protocol, never "the latest of each":
speaks one protocol, never "the latest of each" — and, from RunicNPC's stage 4, a third:
| Component | What it is | Released from |
|---|---|---|
| **The plugin** | `RunicGateway.cs`, one file that runs unchanged on Oxide and Carbon — and beside it, from protocol 13, the optional **ZoneManager helper** `RunicGatewayZones.cs` (PLAN_FIXES D181, D182), installed by default | [Rust-Plugins](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/releases) |
| **The sidecar** | `rust-link-sidecar`, which the plugin dials on loopback and the website reaches over HTTP | [Rust-Link](https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases) |
| **RunicNPC** | `RunicNPC.cs`, Runic Gateway's NPC plugin ([`../runicnpc/PLAN.md`](../runicnpc/PLAN.md)), placed beside the bridge **when the bundle carries it**: from RunicNPC's stage 4, the latest RunicNPC release that answers the API the bridge needs (D224). Optional until RunicNPC's stage 9; it needs **Kits**, like the bridge | [runicnpc-rust](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/releases) |
The game server opens no port for the bridge: the plugin is the client and the sidecar the
listener, on `127.0.0.1`. **One sidecar serves one Rust server.** A community running six servers
@@ -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
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.
**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)
on the installer's `bundles` branch. It names the sidecar binary for your platform and the
plugin tarball, each with a `sha256`.
2. **Download and verify** both against those checksums (`sha256sum -c`, or `Get-FileHash`).
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
`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
@@ -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 |
| `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.

View File

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