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

@@ -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).