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
**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