docs(runicnpc): stage 7 decisions D290–D299 (loot) #318

Merged
whitlocktech merged 5 commits from docs/runicnpc-stage7-decisions into main 2026-10-06 00:44:53 +00:00
3 changed files with 223 additions and 17 deletions

View File

@@ -1,7 +1,10 @@
# RunicNPC — the API
**API version 4** (RunicNPC stage 5, 2026-10-01). This is the reference for other plugins. Why it has this
shape is in [PLAN.md](PLAN.md) §4 and the decisions D221–D238, D249 and D253–D272. **API 4 added** the faction
**API version 5** (RunicNPC stage 6, 2026-10-05). This is the reference for other plugins. Why it has this
shape is in [PLAN.md](PLAN.md) §4 and the decisions D221–D238, D249, D253–D272 and D273–D289. **API 5 added**
the profile's `boss` and `use` blocks and the `passive` role with its `stand` movement, the hooks
`OnRunicNpcBossSpawned`, `OnRunicNpcBossPhase`, `OnRunicNpcBossDied` and `OnRunicNpcUsed`, and `role` and `boss`
in `RunicNpc_List` (PLAN.md stage 6). Nothing of API 4 changed shape. **API 4 added** the faction
table (`RunicNpc_Factions`, `RunicNpc_SetFactions`), orders for one NPC (`RunicNpc_Escort`, `RunicNpc_Ally`,
`RunicNpc_Tether`), the hook `OnRunicNpcEscortEnded`, the profile's stage 5 fields, a placement's `tether`, and
more in `RunicNpc_List` (PLAN.md stage 5). Nothing of API 3 changed shape. **API 3 added**
@@ -16,7 +19,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 < 4) { /* too old for this caller: say so */ }
if (api < 5) { /* too old for this caller: say so */ }
BasePlayer npc = RunicNPC.Call("RunicNpc_Spawn", position, "warden", "plugin:MyPlugin", null) as BasePlayer;
```
@@ -46,7 +49,7 @@ Every NPC has an owner, and the owner decides its lifetime (PLAN.md §2).
### `RunicNpc_ApiVersion()` → `int`
The API version: `4`.
The API version: `5`.
### `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer`
@@ -66,7 +69,9 @@ set, or a navmesh that is not built yet.
### `RunicNpc_Despawn(ulong netId)` → `int`
Removes one NPC by its net id. Returns 1, or 0 if it is not one of RunicNPC's. A placement's NPC comes back after
its respawn delay.
its respawn delay. **For a boss (API 5), it also removes the adds that boss summoned, even once the boss is dead**,
and counts them in what it returns: an event's teardown removes what its step placed by net id, and a boss's adds
are in no step's ledger (D287).
### `RunicNpc_DespawnOwner(string owner)` → `int`
@@ -96,6 +101,8 @@ The live NPCs of `owner`; null lists them all. Each has:
| `tether` | `string` | API 4. The ZoneManager zone it is held in, or null. |
| `shotsMarked` | `int` | API 4. Shots it made at an NPC target (each marks the target `Horror` for that shot only). |
| `kitUsed` | `object` | API 4. `heals`, `throws`, `rockets` and `meleeHits`: the kit's extras it has used (D260). |
| `role` | `string` | API 5. Its profile's role: `roamer`, `sentry`, `guard` or `passive`. |
| `boss` | `object` | API 5. For a boss, `phase` (0 before the first, then 1, 2 …) and `damagers` (how many players have hurt it); null for any other NPC. |
### `RunicNpc_Profiles()` → `JObject`
@@ -231,6 +238,10 @@ All are called on every plugin, and **none of them answers**: a return value is
| `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. |
| `OnRunicNpcEscortEnded(BasePlayer npc, string profile, string owner)` | API 4. Whom it escorted died, was destroyed or left the server, and it is walking back to its spot (D269). |
| `OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner)` | API 5. After `OnRunicNpcSpawned`, for a profile with a `boss` block. Its spawn line has been said. |
| `OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at)` | API 5. It entered a phase: `phase` counts from 1, `at` is the phase's health fraction. Raised after the phase's changes are applied and its line is said. A hit that crosses two thresholds raises both, highest first. |
| `OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)` | API 5. After `OnRunicNpcDied`, for a boss, with the same contributors. Its death line has been said. RunicNPC gives no reward itself (D276, D285): an event's own reward steps do. |
| `OnRunicNpcUsed(BasePlayer npc, string profile, BasePlayer player)` | API 5. A player pressed E on a passive NPC and was answered (D278). At most once a second per player. |
---
@@ -259,7 +270,29 @@ All are called on every plugin, and **none of them answers**: a return value is
"turrets": "default",
"hurtByPlayers": true,
"hurtsPlayers": true,
"kitUse": { "heal": false, "grenades": false, "melee": false, "rockets": false, "flamethrower": false }
"kitUse": { "heal": false, "grenades": false, "melee": false, "rockets": false, "flamethrower": false },
"boss": {
"barDistance": 100,
"announce": "chat",
"spawnLine": "{name} has risen!",
"deathLine": "{name} has fallen to {killer}.",
"phases": [
{ "at": 0.5, "adds": { "profile": "raider", "count": 3 }, "line": "{name} calls for help!" },
{ "at": 0.25, "damageDealt": 1.5, "speed": 1.3, "kit": "warden_heavy", "line": "{name} is enraged!" }
]
},
"use": null
}
```
A passive profile has no `boss` and a `use` block:
```json
{
"names": ["Quartermaster"], "kits": ["outpost_clothes"], "role": "passive",
"movement": { "mode": "stand", "radius": 0 },
"hurtByPlayers": false,
"use": { "mode": "chat", "lines": ["Back again, {player}?", "Supplies are short."], "title": "", "text": "" }
}
```
@@ -268,8 +301,8 @@ All are called on every plugin, and **none of them answers**: a return value is
| `names` | at least one | One is picked for each NPC. |
| `kits` | at least one; each must exist in Kits | One is picked for each NPC, and it is equipped with it (D217). |
| `prefab` | a Rust `scientistnpc_*` prefab that is a plain scientist | What it is built from. |
| `role` | `roamer`, `sentry` or `guard` | A sentry never moves, and may stand off the navmesh. A guard (API 4) holds its spot facing the way it was placed, chases within its chase range, and walks back. |
| `movement.mode` | `wander`, `monument` or `route:<name>` | D233. A placement may override it. |
| `role` | `roamer`, `sentry`, `guard` or `passive` | A sentry never moves, and may stand off the navmesh. A guard (API 4) holds its spot facing the way it was placed, chases within its chase range, and walks back. A passive NPC (API 5) never fights: it takes no target, does not shoot back and raises no alarm, and pressing E on it does what its `use` says (D278, D279). |
| `movement.mode` | `wander`, `monument`, `route:<name>`, or `stand` for a passive profile only | D233. A placement may override it. A passive NPC that stands (D288) holds its spot as a sentry does, may stand off the navmesh, and turns to whoever presses E on it. |
| `movement.radius` | above 0 for `wander` | How far from home it wanders. |
| `health` | above 0 | Its health, and its maximum. |
| `damageDealt` | 0 or more | Multiplies its weapon's damage; 1 is the weapon's full damage. |
@@ -286,9 +319,25 @@ All are called on every plugin, and **none of them answers**: a return value is
| `relations` | API 4. keys: a faction, `scientists`, `animals` or `profile:<name>`; values `hostile`, `neutral` or `allied` | Its own exceptions, which win over the faction table (D254). **A profile with no faction settings fights players only** (D255). |
| `alertRadius` | API 4. 0 or more; default 40 | D256. Allies this close learn whoever attacked one of them, seen or not. 0 is off. |
| `turrets` | API 4. `default`, `ignore` or `always` | D259, D264, D267. `default`: as Rust's scientists (player turrets shoot it, safe-zone sentries do not). `ignore`: no turret targets it. `always`: Outpost's and Bandit Camp's sentries too, through a Harmony patch that answers for these NPCs only. It shoots back at a turret that shoots it. |
| `hurtByPlayers`, `hurtsPlayers` | API 4. true or false; default true | D261, D271. Off makes it unkillable by players, or harmless to them. RunicNPC enforces both itself, and answers TruePVE's and NextGenPVE's `CanEntityTakeDamage` the same way. Turrets, fire and other NPCs are not affected. |
| `hurtByPlayers`, `hurtsPlayers` | API 4. true or false; default true, **except `hurtByPlayers` for a passive profile, which is false unless it is given** (API 5, D279) | D261, D271. Off makes it unkillable by players, or harmless to them. RunicNPC enforces both itself, and answers TruePVE's and NextGenPVE's `CanEntityTakeDamage` the same way. Turrets, fire and other NPCs are not affected. |
| `kitUse` | API 4. `heal`, `grenades`, `melee`, `rockets`, `flamethrower`, each true or false | D260. The kit's extras it uses, each only if true and each used up from its inventory. The main weapon (the belt's first slot) is always used and never runs out (D265). |
| `boss` | API 5. null, or the block below; not on a passive profile | D273. Makes any roamer, sentry or guard a boss, which keeps moving and fighting as its role does. |
| `boss.barDistance` | above 0, at most 1000; default 100 | D274. Players this close see its health bar at the top of the screen, and so does every player who has hurt it, wherever they are, until it dies or despawns. A player near two bosses sees the nearer. |
| `boss.announce` | `chat` or `popup`; default `chat` | D277. Where its lines are said. `popup` uses PopupNotifications where it is loaded and chat where it is not. |
| `boss.spawnLine`, `boss.deathLine` | at most 256 characters; blank says nothing | D286. Said to everyone on the server. `{name}` is its name; in the death line `{killer}` is the killing blow's player (empty when no player). |
| `boss.phases` | a list; each `at` a fraction between 0 and 1, no two alike | D275. When its health falls to `at`, the phase applies from then on. Every other part is optional. |
| `phases[].damageDealt`, `aimCone` | 0 or more | Replace the profile's. |
| `phases[].speed` | above 0, at most 5 | A multiple of Rust's own speed for it: 1.3 is a third faster. |
| `phases[].ranges` | as the profile's `ranges` | Replace the profile's. |
| `phases[].kit` | a Kits kit the server has | A kit swap: its inventory is stripped, the kit given and its weapon equipped. |
| `phases[].adds` | `profile`: a profile that is not a boss; `count`: 1 to 20 | NPCs of that profile placed on a 6 m ring around it, one per server frame. They belong to its owner (an event's teardown removes them) and fight on after it dies (D287). |
| `phases[].line` | at most 256 characters | D286. Said only to the players near it (within `barDistance`) and to those who have hurt it. |
| `use` | API 5. required on a passive profile, refused on any other | D278. What pressing E on it does, from up to 3 m: `mode` `chat` says one of `lines` (picked at random, each at most 256 characters) in that player's chat; `mode` `window` opens a window with `title` (blank: its name), `text` (at most 2,000 characters) and a Close button. `{player}` in either is the player's name. |
A phase changes **that boss's own copy** of its profile; another NPC of the same profile is not touched. A boss gives
no reward itself (D276, D285).
**Whom it fights besides players** is its own sensing (stage 5): only the factions it is hostile to, within its
sense range of itself **and its chase range of its spot** (D263), so it never walks into a monument to look for
scientists. Rust's scientists never fight back (Rust's own rule); they take cover. Whoever shoots one of ours, it
@@ -332,6 +381,14 @@ route is walked back and forth. An NPC joins the route at its nearest point.
- `data/RunicNPC/profiles.json` (`managed`, `profiles` and, from API 4, `factions`), `placements.json` and `routes.json`, under `oxide/`
or `carbon/`. On a standalone server profiles are edited in the file, then `rnpc.reload` reads them again. On a
managed one the site pushes them.
- `data/RunicNPC/state.json` (API 5): `examplesWritten`, set on the first load. That load writes the four example
kits (`rnpc_raider`, `rnpc_campguard`, `rnpc_sniper`, `rnpc_juggernaut`) into Kits' own data file, each where
its name is free and each needing the permission `kits.runicnpc` (Kits' prefix, since Kits registers it), which nobody is granted, so no player can
claim one. On a server no site manages it also adds the four example profiles (`raider`, `campguard`, `sniper`,
`juggernaut`). Then it reloads Kits, which reloads RunicNPC with it. **Nothing is written again, even if you
delete them** (D280, D284, D289).
- **A Kits reload reloads RunicNPC** on both frameworks, because RunicNPC requires Kits, and the NPCs it had spawned
are lost: placements come back, an event's NPCs do not. **Do not reload Kits while an event runs** (D283).
- `config/RunicNPC.json`:
- `caps`: `total`, `perOwner`, `perProfile` and `spawnsPerSecond`. **Each is 0, meaning no cap, until an admin
sets it** (D227).
@@ -344,3 +401,7 @@ route is walked back and forth. An NPC joins the route at its nearest point.
standalone server; refused while a site manages it. `/rnpc follow <placement> <player|me|off>` makes a
placement's live NPCs escort a player, for trying it out (in memory only: a respawn or reload ends it).
`/rnpc place … tether=<zone>` holds a placement inside a zone.
- `rnpc.profile set <name> boss on|none` and `rnpc.profile set <name> use on|none` (API 5) add a profile's boss
box or press-E block with its defaults, or remove it. Their fields are then set one at a time
(`boss.spawnLine The Juggernaut has risen!`), and a list of objects as JSON
(`boss.phases [{"at":0.5,"line":"…"}]`). `role passive` also turns `hurtByPlayers` off (D279).

View File

@@ -11,7 +11,7 @@ design answered 2026-09-30 and 2026-10-01** (D253–D266, §0), **its spike meas
and **its build's own questions answered 2026-10-01** (D267–D272). **Stage 5 built and tested 2026-10-01, walked on the
site 2026-10-05** on both rigs (§9); its API is [API.md](API.md) version 4. **Stage 6's design answered
2026-10-05** (D273–D282, §0), **its spike measured 2026-10-05** on both rigs, and **its two questions answered the
same day** (D283, D284, §9).
same day** (D283, D284, §9). **Its build's own questions were answered the same day too** (D285–D289), and **stage 6 was built and walked on the site 2026-10-05** on both rigs (§9); its API is [API.md](API.md) version 5. **Stage 7's design answered 2026-10-05** (D290–D299, §0).
RunicNPC is Runic Gateway's own NPC plugin for Rust servers, in its own repository,
[`RunicGateway/runicnpc-rust`](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust). It runs on Oxide and
@@ -105,6 +105,21 @@ architectural or design decision is implemented.
| **D282** | **Stage 6 is one stage, opening with a spike on both rigs, walked once end to end**, as stages 4 and 5 were (D248, D253) (stage 6). | 6a (bosses) and 6b (passive NPCs), each walked and merged before the next. |
| **D283** | **RunicNPC keeps `// Requires: Kits`.** A Kits reload reloads RunicNPC too, on both frameworks, and the NPCs it had spawned are lost (placements respawn; an event's do not). The operator documentation says not to reload Kits while an event runs (stage 6 spike, finding 6). | Kits still required, but checked in RunicNPC's own code, so its NPCs survive a Kits reload. |
| **D284** | **RunicNPC writes its four example kits only on its first install**, recorded by a flag in its data. A kit the admin deletes or edits is never brought back or overwritten. The kits are `rnpc_raider`, `rnpc_campguard`, `rnpc_sniper` and `rnpc_juggernaut`, each needing a permission nobody is granted (stage 6). | On every load, for any example kit that is missing; only on an admin's command. |
| **D285** | **Rewarding a boss fight in an event is the Rust module's business, not RunicNPC's**, and uses the module's existing steps: count participants (time in the boss's zone, or NPC kills), then "Reward a kit". Stage 6 builds no reward path. RunicNPC's death hook still names the killer and every player who damaged the boss (stage 6, the org lead: "that belongs in the rust module plan"). | A "Reward a kit" choice that reaches every player who damaged the run's boss, ranked by damage; damage to the run's NPCs counted in the tally. |
| **D286** | **A boss's spawn and death lines go to everyone on the server; its phase lines only to the players near it**: within its bar distance, and anyone who has damaged it (stage 6). | Every line to everyone; every line only to players near it. |
| **D287** | **A boss's adds fight on after it dies**, until they are killed. They belong to whoever owns the boss, so an event's teardown removes them with it (stage 6). | Adds die with the boss; adds stop fighting and despawn after 60 s. |
| **D288** | **A passive NPC moves as its profile says**: it can stand, wander or walk a route like a roamer, and it never fights (stage 6). | Always standing at its spot, like a sentry. |
| **D289** | **The four example profiles (D280, D284):** a raider (roamer, wanders 40 m, 150 hp, semi-auto rifle); a camp guard (guard, chases 30 m, 200 hp, Thompson, metal facemask and chest plate); a sniper (sentry, sees 120 m, 120 hp, bolt-action rifle); and the Juggernaut (roamer and boss, wanders 30 m, 2,500 hp, M249, heavy plate). The Juggernaut has a phase at 50% (summons 3 raiders: "The Juggernaut calls for help!") and one at 25% (damage ×1.5, speed ×1.3: "The Juggernaut is enraged!"). Its spawn line is "The Juggernaut has risen!", its death line "The Juggernaut has fallen.", and its bar shows within 100 m. Item names are checked on the rig; one Rust does not know is replaced by its nearest match (stage 6). | Weaker starters (revolver, Thompson, hunting bow; a 1,500 hp boss). |
| **D290** | **What a corpse starts with is the profile's choice:** Rust's own scientist loot (the default, as today), the kit the NPC wore, or nothing. **Its loot table is added on top** (stage 7). Today a RunicNPC corpse holds only Rust's scientist loot, from the prefab's loot slots; a scientist never copies its inventory, so the kit never drops. | The table only; always the kit plus the table. |
| **D291** | **A loot table has two parts:** rows that always roll, each with its own chance and an amount range, and **a pool that picks at most N rows by weight**, with an optional "nothing" weight. The pool is the cap: however lucky the roll, it never pays out more than N, so a kill cannot drop every rare item at once (stage 7; the org lead asked for loot "not too generous but still fair"). | Independent chances only; a weighted pool only. |
| **D292** | **A profile may put its loot table into a crate instead of the corpse:** Rust's wooden box, military crate, elite crate, or the hackable locked crate (stage 7). | Those crates without the locked one; no crates this stage. |
| **D293** | **Anyone may loot**, as in Rust: whoever reaches the corpse or crate first (stage 7). | The players who damaged it first, for a time; the killer first, for a time. |
| **D294** | **How long the corpse stays is the profile's:** Rust's default, a number of seconds, or none at all (sensible when the loot goes into a crate) (stage 7). | Always Rust's default. |
| **D295** | **Each profile carries its own loot table.** The site edits it with the profile, but **RunicNPC is a first-class plugin and does not depend on the website** (stage 7, the org lead). | Named tables shared between profiles. |
| **D296** | **An event's "Place NPCs" step decides whether its NPCs drop their loot table**, with a switch that is on by default (stage 7). An event's own reward steps are separate (D285). | Event NPCs drop loot as anywhere; event NPCs never drop table loot. |
| **D297** | **The locked crate's hack timer is the profile's**, Rust's own 15 minutes by default (stage 7). | Always Rust's 15 minutes. |
| **D298** | **On a server with no website, a loot table is edited in the data file** (`data/RunicNPC/profiles.json`) and read again with `rnpc.reload`. No new in-game commands (stage 7). | `rnpc.loot` verbs; JSON through `rnpc.profile set`. |
| **D299** | **Stage 7 is one stage, opening with a spike on both rigs, walked once end to end**, as stages 5 and 6 were (D253, D282) (stage 7). | 7a (the table and corpse) and 7b (the crates), each walked and merged before the next. |
**Borrowing, not copying.** NpcSpawn states no licence at all, so its source grants us nothing and is read only as a
description of *what* can be done in Rust. HumanNPC is MIT on uMod, which is GPL-compatible, but §1.2 rules out its
@@ -399,9 +414,11 @@ have, is refused when it is set.
## 7. Loot (D218)
A profile's loot table: items with chances and amounts, optionally clearing the corpse's default loot, dropping a
crate, or removing the corpse. Tables are authored on the site with the profile. Adapters to AlphaLoot and the
others are a later stage and not planned in detail here.
A profile's loot table (D290–D298): rows that always roll, each with a chance and an amount, and a pool that picks
at most N rows by weight. The table is added to what the corpse starts with (Rust's scientist loot, the kit, or
nothing), or goes into a crate instead, the hackable locked crate included. The profile also says how long the
corpse stays. Each profile carries its own table; the site edits it with the profile, and a server with no site
edits the data file. Adapters to AlphaLoot and the others are a later stage and not planned in detail here.
---
@@ -1114,7 +1131,7 @@ The org lead's answers on 2026-10-05 are D273–D282. **It is one stage, opening
- **Rust-Plugins (the bridge):** the boss block pushed with the profiles; frames for a boss's spawn, phase and
death for the site.
- **Module-Rust:** the profile form's boss box and passive settings; public boss triggers (spawned, phase, killed)
for engagement rules (D277); an event's reward steps reaching the players who killed its boss (D276).
for engagement rules (D277). Nothing for an event's rewards: they stay the module's own steps (D285).
**Not in this stage:** a Discord post for a boss outside an event (D277), RunicNPC's own kill rewards (D276), and
greet, hurt or kill lines (D281). A boss's loot table is stage 7's.
@@ -1231,8 +1248,10 @@ around it, each point snapped to the navmesh.
What this adds to the build (D283, D284):
- **The kits are named `rnpc_raider`, `rnpc_campguard`, `rnpc_sniper` and `rnpc_juggernaut`.**
- **Each carries `RequiredPermission: runicnpc.examplekits`**, which nobody is granted, so no player can claim one
by typing its name (`IsHidden` alone does not stop that). RunicNPC's `GiveKit` call ignores the permission.
- **Each carries `RequiredPermission: kits.runicnpc`**, which nobody is granted, so no player can claim one
by typing its name (`IsHidden` alone does not stop that). RunicNPC's `GiveKit` call ignores the permission. It
was `runicnpc.examplekits` until the build found Oxide warning on every Kits load that a permission Kits
registers must start with `kits.`; the org lead chose Kits' prefix (2026-10-05).
- **The first install runs in this order:** write the kits, set the flag, reload Kits.
- Because of D283, the reload takes RunicNPC down and back once.
- On that second load the flag is already set, so nothing is written twice and nothing loops.
@@ -1246,11 +1265,96 @@ What this adds to the build (D283, D284):
- A phase's kit swap is strip, `GiveKit`, `EquipWeapon`.
- Adds are placed one per frame.
**The build's own questions, answered by the org lead on 2026-10-05 (D285–D289):**
1. **Who an event rewards for a boss fight. → D285:** the Rust module's existing steps; nothing in stage 6.
2. **Who hears a boss's lines. → D286:** spawn and death, everyone; phases, the players near it.
3. **A boss's adds after it dies. → D287:** they fight on.
4. **How a passive NPC moves. → D288:** as its profile says.
5. **The example profiles. → D289:** the set in §0.
**Built (2026-10-05), three branches and this one.** RunicNPC API 5 (`runicnpc-rust` `feat/stage-6`), the bridge
(`Rust-Plugins` `feat/runicnpc-stage6`) and the site (`Module-Rust` `feat/runicnpc-stage6`). The API is
[API.md](API.md) version 5; the wire is [`PROTOCOL.md`](../rust-link/PROTOCOL.md) §19.14.
| Piece | What it does |
|---|---|
| RunicNPC API 5 | The boss box (bar, phases, lines and adds; the hooks `OnRunicNpcBossSpawned`, `OnRunicNpcBossPhase`, `OnRunicNpcBossDied` and `OnRunicNpcUsed`); the passive role with `stand` and press E through `OnPlayerInput`; the four example profiles and kits, written once (`data/RunicNPC/state.json`); `role` and `boss` in `RunicNpc_List`. The swap's field list regenerated for Rust 2634.289.1 |
| The bridge | `npc.boss.spawned`, `npc.boss.phase`, `npc.boss.died`; `runicnpc_api = 5`. A profile's `boss` and `use` travel in the push unchanged |
| The site | The profile form's boss box (phases with damage, aim, speed, ranges, a kit swap, adds and a line) and the passive role with its press-E answer; the checks in RunicNPC's words, a phase's kit checked on each server, its adds a site profile that is not a boss; the public triggers `rust.boss.spawned`, `rust.boss.phase`, `rust.boss.killed`; the feed's boss rows (a spawn public, a death behind the presence setting, a phase staff's) |
**The builds it was tested on (checked against the latest, 2026-10-05):** Rust 2634.289.1 (buildid 25681086,
the latest public build), Oxide 2.0.7801 and Carbon 2.0.262 (both the latest releases). This morning's spike ran on
the previous Rust build, before either rig restarted.
**Tested.** The harness's `s6` group passed 41 of 41 on both rigs: refusals, passive (never fights, unhurtable
by default, press E once a second, a window, wandering), the boss (three phases, its own copy of the profile, the
death hook's contributors, adds that fight on) and the examples. A 42nd check, `RunicNpc_Despawn` of the dead
boss taking its adds, came with the walk's fix below; after the walk it passed on both rigs too (42/42 each, the
same builds). `rnt.run all`: 244/246 on Oxide and 243/246 on Carbon before two checks were fixed (below); each fixed
check and the escort check then passed on both rigs. Module-Rust: 491 server and 67 client tests, and every
`check:*` script.
**Walked, 2026-10-05, through a walk site, signed in to the admin panel in the browser.** No player was on the rigs,
so a probe plugin hurt bosses as a stand-in player.
| Row | Oxide | Carbon |
|---|---|---|
| A boss profile made on the form (two phases: 50% two adds and a line; 25% damage ×1.5, speed ×1.3 and a line), saved and pushed; the rig holds the block as typed | pass | pass (made through the same API) |
| Placed by clicking the live map; `npc.boss.spawned` stored, `rust.boss.spawned` fired | pass | — |
| Phase 1 summoned two adds, owned as the boss is; `rust.boss.phase` fired for each phase | pass | pass (and the 25% kit swap: it then held `rnpc_raider`) |
| The kill: `rust.boss.killed` fired; the adds fought on (D287) | pass | pass |
| The public events route: signed out it shows the spawn only; signed in as an admin, the death too (its killer, 1 damager); never a phase | pass | — |
| An event placing the boss: its phase gate advanced on `rust.boss.killed` where `byEvent`, and the run's teardown removed the boss's adds | pass after a fix (below) | pass |
| A passive profile made on the form: choosing passive showed Press E, hid Boss, unticked "Players can hurt it" and set `stand`; pushed as typed | pass | — |
| Placed from the map; 500 hits from a player left it at 150/150; it stood and took no target | pass | — |
**Not walked:** everything a real client shows, waiting for the in-game walk with a player: the bar on screen and
its redraws, the lines in chat or a popup, a real E press and the window, and the example profiles on a fresh
server's own boot.
**What building it found:**
- **Rust's design picks targets that `IsTarget` refuses.** `HumanNPC.GetBestTarget` ranks every player in
`Senses.Players`, which `IsTarget` does not filter, and fires through `AttackTick`. A passive NPC shot at players
until RunicNPC answered both (IAIAttack, which it already re-implements) with nothing for a passive one. **Stage 5's
ally sparing may have the same hole**: it was not part of this stage and is not changed here.
- **A boss's adds outlived the event that placed it.** Teardown removes what a step placed by net id; the adds
RunicNPC spawned are in no ledger. RunicNPC now keeps each boss's adds by the boss's net id, after it dies too,
and `RunicNpc_Despawn` of that id removes them. Walked again: run 65's cleanup removed both.
- **Oxide warns on a permission Kits registers without its prefix.** The example kits' permission is `kits.runicnpc`
(the org lead's choice), not `runicnpc.examplekits`.
- **The Rust update's navmesh rebuild moved two checks' ground.** `sentry.shoots` now picks a spot with a clear line
(a rock was between the sentry and its stand-in; with a clear line it hit 28 times). `move.monument.roams`: the
military tunnel's own 30 scientists no longer move with no real player near, so ours standing too is the same
behaviour, and the check now says both.
- **The bridge's `npc.health` frame carries no owner** (stage 4's `OnRunicNpcHealth` passes null), so
`rust.npc.health` never says `byEvent`, and a gate on "the event's boss below 50%" cannot be written. Not changed
here; the boss phase trigger does carry it.
### Stage 7 — Loot
Loot tables authored on the site, the corpse's default loot cleared or kept, a crate on death, corpse removal.
**Tested by** harness kills whose corpses are read back, 100 times each, against the table's chances.
The org lead's answers on 2026-10-05 are D290–D299. **It is one stage, opening with a spike, walked once end to end
(D299).** Its wire changes join protocol 13 while it is unreleased.
- **The spike first (D299).** A harness answers what the rest depends on, on both rigs:
- where a corpse's loot is decided: which hook Oxide and Carbon raise for an NPC corpse being filled, and whether
our subclass can fill it itself (`ApplyLoot`, `CopyInventoryToCorpse`) for D290's three starts;
- the crates (D292): spawning each of Rust's crates at a death spot, emptying it and filling it, and that it
behaves as Rust's own (lootable, despawns);
- the locked crate (D297): whether its hack timer can be set per crate, since Rust's is one server-wide setting;
- removing a corpse after a set time, or at once (D294).
- **RunicNPC (API 6):**
- the profile's `loot` block: the corpse's start (D290), the table (D291), the crate and its timer (D292, D297)
and the corpse's time (D294);
- an override for one spawn that switches the table off, for an event step (D296).
- **Rust-Plugins (the bridge):** `world.place` with a profile takes the step's "drop loot" switch (D296).
- **Module-Rust:** the profile form's loot section (D295) and the Place NPCs step's switch (D296).
**Tested by** harness kills whose corpses and crates are read back, 100 times each, against the table's chances
and the pool's cap; and a walk on both rigs.
### Stage 8 — On the map and in the app

View File

@@ -2541,3 +2541,44 @@ placements), a new one by clicking the live map (D245, D246). It adds the trigge
and titles (D247, D250, D252).
**Walked on both rigs, 2026-09-30** (runicnpc PLAN.md stage 4).
### 19.14 RunicNPC stage 6: bosses (runicnpc PLAN.md stage 6)
Stage 6's wire changes **join protocol 13** while it is unreleased (D282); no message changes shape, and
`PROTOCOL_VERSION` stays 13. The bridge now needs **RunicNPC API 5**: `RunicNpcApiNeeded = 5`,
**`runicnpc_api = 5`** in `overlay.toml`. An older RunicNPC answers `npc.error` `runicnpc-old` as before.
**A profile's `boss` and `use` blocks** travel in `npc.profiles` and `npc.profiles.set` as RunicNPC holds them
(runicnpc `API.md`); the bridge reads neither.
**Three events,** from RunicNPC API 5's boss hooks, for the site's public boss triggers (D277):
```json
{"kind":"npc.boss.spawned","type":"event","netId":"981204","profile":"juggernaut","name":"Juggernaut",
"owner":"run:42","runId":"42","x":-2123.7,"z":2282.7,"maxHealth":2500}
{"kind":"npc.boss.phase","type":"event","netId":"981204","profile":"juggernaut","name":"Juggernaut",
"owner":"run:42","runId":"42","x":-2120.1,"z":2290.4,"phase":2,"at":0.25,"health":611.3,"maxHealth":2500}
{"kind":"npc.boss.died","type":"event","netId":"981204","profile":"juggernaut","name":"Juggernaut",
"owner":"run:42","runId":"42","x":-2118.0,"z":2291.2,"killerId":"76561198000000002","killerName":"Marisol",
"damagers":5}
```
- The NPC fields are `npc.died`'s (§19.12): `owner`, and `runId` or `placement` from it.
- `phase` counts from 1; `at` is the phase's health fraction. A hit that crosses two thresholds sends both,
highest first.
- `npc.boss.died` is sent **beside** `npc.died`, which still carries every contributor. It names no Steam id
but the killer's, and counts the players who hurt the boss in `damagers`. `killerId` and `killerName` are
absent when no player landed the killing blow.
| `kind` | Hook | `class` | Why |
|---|---|---|---|
| `npc.boss.spawned` | `OnRunicNpcBossSpawned` | public | The game says a boss's spawn to everyone on the server (D286). It names nobody |
| `npc.boss.died` | `OnRunicNpcBossDied` | **presence** | Said to everyone too, but it names the killer, which says that player was on |
| `npc.boss.phase` | `OnRunicNpcBossPhase` | **staff** | The game tells a phase only to the players near the boss (D286) |
**Hooks.** `OnRunicNpcBossSpawned`, `OnRunicNpcBossPhase` and `OnRunicNpcBossDied` join `rg.hooks`' list.
**The website** (Module-Rust) adds the public triggers `rust.boss.spawned`, `rust.boss.phase` and
`rust.boss.killed` (ceiling `everyone`, default `subscribers`) for app, in-app and email rules; Discord hears
a boss only through an event's announce step (D277). The feed shows a boss appearing, and its death where the
server's presence setting allows. The profile form gains the boss box and the passive role.