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 # RunicNPC — the API
**API version 5** (RunicNPC stage 6, 2026-10-05). This is the reference for other plugins. Why it has this **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 and D273–D289. **API 5 added** 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 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` `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 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; [PluginReference] private Plugin RunicNPC;
int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0; 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; 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` ### `RunicNpc_ApiVersion()` → `int`
The API version: `5`. The API version: `6`.
### `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer` ### `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!" } { "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. | | `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. | | `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 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). 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. 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. 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 ### 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 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 `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 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. 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.