diff --git a/runicnpc/API.md b/runicnpc/API.md index c4609a5..48861f0 100644 --- a/runicnpc/API.md +++ b/runicnpc/API.md @@ -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("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` + +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 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. | --- diff --git a/runicnpc/PLAN.md b/runicnpc/PLAN.md index 6a5474d..f361f94 100644 --- a/runicnpc/PLAN.md +++ b/runicnpc/PLAN.md @@ -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 diff --git a/rust-link/INSTALL.md b/rust-link/INSTALL.md index 68d4c34..9299121 100644 --- a/rust-link/INSTALL.md +++ b/rust-link/INSTALL.md @@ -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 ]` | 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 ]` | 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 ] [--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 ] [--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. diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md index 4a9e181..fd8ec91 100644 --- a/rust-link/PROTOCOL.md +++ b/rust-link/PROTOCOL.md @@ -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, `-`, 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:`, `placement:` or `plugin:`. 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:`), and recorded in the registry with `prefab` `runicnpc:`. 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).