docs(runicnpc): stage 7 built and walked; API 6 and the dropLoot switch #320

Merged
whitlocktech merged 1 commits from docs/runicnpc-stage7-built into main 2026-10-06 07:17:22 +00:00
3 changed files with 90 additions and 5 deletions

View File

@@ -1,7 +1,8 @@
# RunicNPC — the API
**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**
**API version 6** (RunicNPC stage 7, 2026-10-06). 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, D273–D289 and D290–D301. **API 6
added** the profile's `loot` block (PLAN.md stage 7); nothing of API 5 changed shape. **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
@@ -19,7 +20,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 < 5) { /* too old for this caller: say so */ }
if (api < 6) { /* too old for this caller: say so */ }
BasePlayer npc = RunicNPC.Call("RunicNpc_Spawn", position, "warden", "plugin:MyPlugin", null) as BasePlayer;
```
@@ -49,7 +50,7 @@ Every NPC has an owner, and the owner decides its lifetime (PLAN.md §2).
### `RunicNpc_ApiVersion()` → `int`
The API version: `5`.
The API version: `6`.
### `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer`
@@ -281,7 +282,16 @@ All are called on every plugin, and **none of them answers**: a return value is
{ "at": 0.25, "damageDealt": 1.5, "speed": 1.3, "kit": "warden_heavy", "line": "{name} is enraged!" }
]
},
"use": null
"use": null,
"loot": {
"start": "rust",
"always": [ { "item": "scrap", "min": 100, "max": 300, "chance": 1 }, { "item": "rope", "min": 1, "max": 1, "chance": 0.5 } ],
"pool": { "pick": 1, "nothing": 85, "rows": [ { "item": "lmg.m249", "min": 1, "max": 1, "weight": 10 }, { "item": "explosive.timed", "min": 1, "max": 1, "weight": 5 } ] },
"crate": "locked",
"hackSeconds": 600,
"corpseSeconds": 0,
"dropTable": true
}
}
```
@@ -335,6 +345,15 @@ A passive profile has no `boss` and a `use` block:
| `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. |
| `loot` | API 6. null, or the block below | D290–D301. null keeps Rust's scientist loot, as before stage 7. |
| `loot.start` | `rust` (default), `kit` or `none` | D290, D300. What the corpse holds before the table: Rust's scientist loot and the clothes it wore; what it carried (its kit) and the clothes; or nothing at all, clothes included. |
| `loot.always` | up to 30 rows: `item` (a short name this Rust has), `min` and `max` (1 or more), `chance` (above 0, at most 1), optional `skin` | D291. Each row rolls on its own. |
| `loot.pool` | `pick` (0 to its rows), `nothing` (a weight, 0 or more), up to 30 `rows` with `weight` (above 0) instead of `chance` | D291. At most `pick` draws by weight, never the same row twice; a draw of "nothing" uses one up. A kill never pays out more than `pick` pool rows. |
| `loot.crate` | null (the corpse), `wooden`, `military`, `elite` or `locked` | D292, D301. The table goes into one of Rust's crates beside the corpse, with Rust's own loot off, and it stays until emptied (a locked crate keeps Rust's own 2-hour removal unhacked). The always-rows plus `pick` must fit its slots: 6, 6, 12 or 24 (the corpse has 24). |
| `loot.hackSeconds` | 1 to 86,400; default 900 | D297. A locked crate's hack time, counted once a player starts the hack. |
| `loot.corpseSeconds` | null (Rust's own 600 s), 0 (no corpse) or more | D294. |
| `loot.dropTable` | true (default) or false | D296. False rolls no table; the start and the corpse's time still apply. An event's Place NPCs step sends it false through a spawn's overrides. |
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).

View File

@@ -1414,6 +1414,52 @@ without a client, so the walk checks it. An unhacked locked crate is removed aft
1. **A "nothing" corpse and the clothes it wore. → D300:** truly empty, clothes too.
2. **How long an unopened crate lasts. → D301:** until it is emptied, as Rust's crates do.
**Built (2026-10-06), three branches and this one.** RunicNPC API 6 (`runicnpc-rust` `feat/stage-7`), the bridge
(`Rust-Plugins` `feat/runicnpc-stage7`) and the site (`Module-Rust` `feat/runicnpc-stage7`). The API is
[API.md](API.md) version 6; the wire is [`PROTOCOL.md`](../rust-link/PROTOCOL.md) §19.15.
| Piece | What it does |
|---|---|
| RunicNPC API 6 | The profile's `loot` block: the corpse's start (`rust`, `kit`, `none`), the always-rows, the pool with its picks and "nothing" weight, the crate and a locked crate's hack time, the corpse's time and `dropTable`; checked on load (items that exist, at most 30 rows, the table fits its crate or the corpse). The corpse is filled in `OnCorpsePopulate`; a spawn's overrides can set `loot.dropTable` false |
| The bridge | `world.place` with a profile reads the step's `dropLoot`; off, it spawns with `{"loot":{"dropTable":false}}`. `runicnpc_api = 6` |
| The site | The profile form's Loot section (D295), checked in RunicNPC's words, whether an item exists left to RunicNPC on the push; the Place NPCs step's `dropLoot` switch (D296), on by default and sent only when off; "own loot" in the profile list |
**The builds it was tested on (checked against the latest, 2026-10-06):** Rust 2634.289.1 (buildid 25681086, the
latest public build), Oxide 2.0.7801 and Carbon 2.0.262 (both the latest releases).
**Tested.** The harness's `s7` group passed 25 of 25 on both rigs: each start, the always-rows over 100 kills against
their chances, the pool's cap and its "nothing" weight (bounds at 3.5σ), each crate, the locked crate's timer, the
corpse's time and the event override. `rnt.run all`: 270 of 270 on both rigs. Module-Rust: 494 server and 67 client
tests, and every `check:*` script.
**Walked, 2026-10-06, through a walk site, signed in to the admin panel in the browser.** No player was on the rigs,
so a probe plugin killed the NPCs as a stand-in player and read back what they left.
| Row | Oxide | Carbon |
|---|---|---|
| A loot profile made on the form (a kit start; scrap 25–50 and a sewing kit 1–2 always; a pool of one pick from a tarp and a propane tank; the corpse for 120 s), saved and pushed; the rig holds the block as typed, 100% stored as chance 1 | pass | pass |
| Placed by clicking the live map; the kill's corpse: the kit's revolver and its ammunition, the clothes it wore, scrap and a sewing kit in range, one pool item, none of Rust's scientist loot | pass | pass |
| The corpse there at 60 s and gone at 125 s | pass | pass |
| Edited to start with nothing and a military crate: the corpse truly empty, clothes too (D300), the table in the crate | pass | pass |
| Edited to a locked crate of 17 minutes (1,020 s, longer than Rust's 15): the count started at −120 and the crate was hacked at 901 of 900, on time | pass | pass |
| The military crate still there unopened 25 minutes later (D301) | pass | pass |
| An event's Place NPCs step with "drop loot" off: its NPC's corpse empty and no crate, where the placement's NPCs of the same profile dropped one | pass | — |
**Not walked:** what a player's screen shows for a locked crate set longer than 15 minutes, while its count is below
zero; it waits for the in-game walk with a player. The server's side of it is walked above.
**What building it found:**
- **Rust fills the corpse again after `OnCorpsePopulate`.** `CreateCorpse` raises the hook and then calls
`ApplyLoot` unless the hook answered with a corpse. A kit or empty start answers with the corpse; a `rust` start
answers nothing, so Rust's loot goes in as before and the table is added to it.
- **The NPC's inventory is already empty when the hook runs.** A kit start therefore takes the kit's belt and main
items in our NPC's `OnDied`, before Rust empties them, and moves them into the corpse in the hook.
- **A destroyed corpse compares equal to null in Unity.** The harness checks the corpse's removal with
`ReferenceEquals` and the entity's own destroyed flag.
- **The profile list did not say a profile had its own loot**, where a boss says "boss". Found on the walk and fixed
on the same branch.
### Stage 8 — On the map and in the app
Event NPCs and bosses on the live map (protocol 11's `map.live`), a live "left: 3/8" on the event page, and the same

View File

@@ -2582,3 +2582,23 @@ Stage 6's wire changes **join protocol 13** while it is unreleased (D282); no me
`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.
### 19.15 RunicNPC stage 7: loot (runicnpc PLAN.md stage 7)
Stage 7's wire change **joins protocol 13** while it is unreleased (D299); no message changes shape, and
`PROTOCOL_VERSION` stays 13. The bridge now needs **RunicNPC API 6**: `RunicNpcApiNeeded = 6`,
**`runicnpc_api = 6`** in `overlay.toml`.
**A profile's `loot` block** travels in `npc.profiles` and `npc.profiles.set` as RunicNPC holds it (runicnpc
`API.md`); the bridge reads none of it.
**`world.place` with a `profile` takes `dropLoot`** (D296), optional:
```json
{"cmd":"world.place","runId":"42","key":"…","profile":"bandit","count":4,"server":"main","x":880,"z":-1850,
"dropLoot":false}
```
`false` spawns the NPCs with RunicNPC's override `{"loot":{"dropTable":false}}`: they keep what their profile
starts the corpse with, and roll no loot table. Absent or `true`, as before. The site sends it only when an event's
Place NPCs step switches it off.