diff --git a/README.md b/README.md index d493be2..57465e4 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ requires it once it releases (D220). | Doc | What it covers | |---|---| -| [PLAN.md](runicnpc/PLAN.md) | **The plan** — what the 2026-09-30 spike found (route A, HumanNPC, NpcSpawn), the org lead's decisions D214–D220, the features, the API, the chat commands, and stages 0–10 with how each is tested | +| [PLAN.md](runicnpc/PLAN.md) | **The plan** — what the 2026-09-30 spike found (route A, HumanNPC, NpcSpawn), the org lead's decisions D214–D238, the features, the API, the chat commands, and stages 0–10 with how each is tested and what each found | +| [API.md](runicnpc/API.md) | **The API** other plugins call (version 2): owners, every `RunicNpc_*` call, the hooks it raises, and the profile, placement and route shapes | ### `android/` | Doc | What it covers | diff --git a/runicnpc/API.md b/runicnpc/API.md new file mode 100644 index 0000000..2c2f17d --- /dev/null +++ b/runicnpc/API.md @@ -0,0 +1,234 @@ +# 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. + +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 +what Oxide's `Call` reaches by name (PLAN.md §1.2). + +```csharp +[PluginReference] private Plugin RunicNPC; + +int api = RunicNPC?.Call("RunicNpc_ApiVersion") ?? 0; +if (api < 2) { /* too old for this caller: say so */ } + +BasePlayer npc = RunicNPC.Call("RunicNpc_Spawn", position, "warden", "plugin:MyPlugin", null) as BasePlayer; +``` + +**The version moves when a call or a raised hook changes shape**, not on every release. `plugin.toml` in the +repository declares the same number, and the release's `manifest.json` carries it, so an installer or the +bridge can refuse a RunicNPC that is too old before it is loaded. + +A call that cannot do what it was asked **returns null (or 0, or false) and says why in the server log**. Calls +that change data return the reason as a string instead, and null means success. + +--- + +## Owners + +Every NPC has an owner, and the owner decides its lifetime (PLAN.md §2). + +| Owner | Who uses it | Lifetime | +|---|---|---| +| `run:` | An event run, through the bridge | Never saved. Removed by the caller, or by `RunicNpc_DespawnOwner`. | +| `plugin:` | Any other plugin | Never saved. **Removed when that plugin unloads.** Use your plugin's own name. | +| `placement:` | RunicNPC itself | The NPC is never saved; the placement is, and spawns a fresh one at boot. Callers cannot spawn with this owner. | + +--- + +## Calls + +### `RunicNpc_ApiVersion()` → `int` + +The API version: `2`. + +### `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer` + +Spawns one NPC now, or returns null. + +- `owner` must be `run:` or `plugin:`. +- `overrides` may change any profile value for this NPC only, in the profile's own shape (below). It may be null. + For example: `{"health": 500, "names": ["Captain"], "movement": {"mode": "route:gate", "radius": 0}}`. Arrays + are replaced, not merged. The merged profile is validated like a saved one. +- A roamer must stand on Rust's navmesh: within 3 m across and 2 m up or down of `at`. A sentry may stand + anywhere. +- It is refused until the map's navmesh is built, which takes 8–10 minutes on a map's first boot. + +Refusals: an unknown or refused profile, a bad owner, a failed override, a spot off the navmesh, a cap an admin has +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. + +### `RunicNpc_DespawnOwner(string owner)` → `int` + +Removes every NPC of `owner`, and returns how many it removed. + +### `RunicNpc_List(string owner)` → `List>` + +The live NPCs of `owner`; null lists them all. Each has: + +| Key | Type | | +|---|---|---| +| `netId` | `ulong` | | +| `profile`, `name`, `owner`, `kit` | `string` | The kit is the one picked for this NPC. | +| `placement` | `string` | Its placement's id, or null. | +| `position`, `home` | `{x, y, z}` | `home` is where it spawned: where it wanders around, and walks back to before it sleeps. | +| `health`, `maxHealth` | `float` | | +| `movement` | `string` | `wander`, `monument` or `route:`. | +| `rest` | `string` | `Awake`, `GoingHome` or `Asleep` (D235). | +| `state` | `string` | Rust's AI state: `Idle`, `Roam`, `Chase`, `Combat` and so on. | +| `leashes` | `int` | How many times it has given up a chase at its chase range. | + +### `RunicNpc_Profiles()` → `JObject` + +`{"managed": bool, "profiles": {name: profile, …}, "refused": {name: reason, …}}`. A refused profile is kept in +the file but never spawns (a kit the server lacks, for example). + +### `RunicNpc_SetProfiles(JObject all)` → `Dictionary` + +Replaces the whole profile set with `all` (`{name: profile, …}`), as a site does on every push, and **marks the +server managed** (D221). It returns the profiles refused and why; the rest are in use at once. A placement whose +profile has gone waits, and spawns again when the profile returns (D237). + +### `RunicNpc_Placements()` → `List>` + +Each placement as `{id, placement, alive, waiting, lastError}`. `placement` is the placement's JSON (below), +`alive` is how many of its NPCs are alive, and `waiting` says why it cannot spawn right now, or is null. + +### `RunicNpc_SetPlacement(string id, JObject placement)` → `string` + +Adds or replaces a persistent placement (D222). Null on success, or the reason. Replacing one despawns its NPCs and +spawns them again from the new values. Every set logs the cost warning (D227). + +### `RunicNpc_RemovePlacement(string id)` → `bool` + +Removes a placement and its NPCs. + +### `RunicNpc_Routes()` → `JObject` + +`{name: route, …}`. + +### `RunicNpc_SetRoute(string name, JObject route)` → `string` + +Adds or replaces a route (D234). Every point must be on Rust's navmesh. Null on success, or the reason. + +### `RunicNpc_RemoveRoute(string name)` → `bool` + +Removes a route. Placements that walk it wait until it exists again. + +### `RunicNpc_IsRunicNpc(BaseEntity entity)` → `bool` + +Whether `entity` is one of RunicNPC's NPCs. + +### `RunicNpc_ProfileOf(BaseEntity entity)` → `string` + +Its profile's name, or null. + +### `RunicNpc_CostWarning(int adding)` → `string` + +The cost warning (D227) for the NPCs the server plans, plus `adding`. It is built from stage 1's measurements +(PLAN.md §9), and says what the NPCs add to a server frame, idle and fighting, and how slowly each then reacts. A +caller that adds NPCs shows it to whoever added them. + +--- + +## Hooks it raises + +All are called on every plugin, and **none of them answers**: a return value is ignored. + +| Hook | When | +|---|---| +| `OnRunicNpcSpawned(BasePlayer npc, string profile, string owner)` | After an NPC spawns and is equipped. | +| `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 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. | + +--- + +## Data shapes + +### A profile (D238) + +```json +{ + "names": ["Warden", "Old Warden"], + "kits": ["warden_rifle", "warden_smg"], + "prefab": "scientistnpc_roam", + "role": "roamer", + "movement": { "mode": "wander", "radius": 20 }, + "health": 250, + "damageDealt": 1.0, + "damageTaken": { "head": 1.0, "body": 1.0, "legs": 1.0 }, + "aimCone": 2.0, + "ranges": { "sense": 30, "loseTarget": 40, "chase": 40, "attack": 30 }, + "visionCone": -0.8, + "sleepDistance": 160, + "healthThresholds": [0.5] +} +``` + +| Field | Rule | Meaning | +|---|---|---| +| `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` or `sentry` | A sentry never moves, and may stand off the navmesh. | +| `movement.mode` | `wander`, `monument` or `route:` | D233. A placement may override 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. | +| `damageTaken` | each 0 or more | Multiplies the damage it takes to the head, the legs (legs and feet), and the body (everything else, including hits with no body part, like fire and explosions). | +| `aimCone` | 0 or more | How much its aim spreads; Rust's scientists use 2. | +| `ranges.sense` | above 0 | How far it notices players. | +| `ranges.loseTarget` | at least `sense` | How far a target must get before it is forgotten. | +| `ranges.chase` | 0 or more | How far from home it will chase (0: no limit). At that distance it waits 5 s for the target to come within range, then gives up and walks home. | +| `ranges.attack` | above 0 | How far it shoots from. This replaces the weapon's own range. | +| `visionCone` | −1 to 1 | Rust's vision cone. | +| `sleepDistance` | 0 or more | D235: with no player this close it walks home, then sleeps. 0: it never sleeps. | +| `healthThresholds` | fractions between 0 and 1 | Where `OnRunicNpcHealth` is raised. | + +### A placement + +```json +{ + "profile": "warden", + "position": { "x": 100.0, "y": 12.5, "z": -340.0 }, + "yaw": 90, + "count": 3, + "respawn": 300, + "respawnMode": "each", + "movement": { "mode": "route:gate", "radius": 0 } +} +``` + +- `count` NPCs spawn at the spot, and all but the first are scattered within 3 m of it. +- `respawn` is in seconds, at least 1. `respawnMode` is `each` (every NPC returns `respawn` after its own death) + or `group` (none returns until all are dead, then all return together) (D236). +- `movement` is optional and overrides the profile's. + +### A route + +```json +{ "points": [ { "x": 1, "y": 2, "z": 3 }, { "x": 4, "y": 2, "z": 6 } ], "loop": true } +``` + +At least two points, each on Rust's navmesh. With `loop`, the last point leads back to the first; without it, the +route is walked back and forth. An NPC joins the route at its nearest point. + +--- + +## Files and config + +- `data/RunicNPC/profiles.json` (`managed` and `profiles`), `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. +- `config/RunicNPC.json`: + - `caps`: `total`, `perOwner`, `perProfile` and `spawnsPerSecond`. **Each is 0, meaning no cap, until an admin + sets it** (D227). + - `spawnBudgetMs`: how many milliseconds of each server frame placements may spend spawning. The default is 8. +- `rnpc.status` at the console reports the swap's field check, the NPCs by owner and rest, profiles refused, + placements waiting, the caps, and the cost warning. diff --git a/runicnpc/PLAN.md b/runicnpc/PLAN.md index aeb4c1a..cd61e54 100644 --- a/runicnpc/PLAN.md +++ b/runicnpc/PLAN.md @@ -3,7 +3,8 @@ **Status:** plan, written 2026-09-30. Its eight questions (§11) were answered the same day: D221–D228 (§0). **Stage 0 closed 2026-09-30** (runicnpc-rust#1/#2, §9): v0.1.0 released and loaded on both rigs. D229–D230 were decided with it. **Stage 1 measured 2026-09-30** (§9): six answers on both rigs; D231 was decided with it. -**Stage 2's design answered 2026-09-30:** D232–D238 (§0), which reshape stage 2 (§9). +**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). 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 @@ -460,7 +461,7 @@ it. That is the other reason our NPC must never be saved (§2). | Health, name, aim cone | Hold: set on the NPC and read back (400/400, the name, 0.5). | | Sense range, target-lost range, vision cone, listen range, hostile-only, sense types | **Only if set before the brain starts.** `Senses.Init` copies them once. At 35 m, a range of 50 set before the start saw the target, and the same range set afterwards did not. | | The prefab's own values | Sense 30 m, lose target 40 m, vision cone −0.8, listen 10 m, memory 10 s, line-of-sight checks on, hostile-only off, senses players only, health 150, aim cone 2. | -| Roaming | **Rust's roam only follows an AI zone's move points.** In a monument's zone (Desert Military Base) ours roamed 36–47 m in 60 s. In an open field it stood still (0 m). **A roamer anywhere but a monument needs our own roam state, so stage 2's roamer role includes one.** | +| Roaming | **Rust's roam only follows an AI zone's move points** (and so does its chase: stage 2). In a monument's zone (Desert Military Base) ours roamed 36–47 m in 60 s. In an open field it stood still (0 m). **A roamer anywhere but a monument needs our own roam state, so stage 2's roamer role includes one.** | | Sleep | An NPC outside an AI zone is never put to sleep, and `ai_dormant` does not apply to this AI. So an idle NPC keeps thinking with nobody near, which is what the costs below measure. | | **Fighting NPCs** | **Rust's scientist AI never attacks an NPC.** `HumanNPC.IsTarget` is true only for non-NPC players, pets and scarecrows. `IsFriendly` means "same prefab id", which the swap copies, so every stock scientist counts ours as a friend. Re-implementing Rust's internal `IAISenses` puts a scientist in our NPC's target list. But Rust's AI design never runs its attack event for an NPC target. Calling `AttackTick` directly passed line of sight every time and still fired no shot, because the design's cover and facing states win. **NPC-versus-NPC combat needs our own combat state, and sensing NPCs needs our own sensing** (see the cost below). D231 makes it a profile setting, so stage 5 opens with its own spike. | @@ -475,7 +476,7 @@ boots load the saved `proceduralmap....navmesh`. **Placements tha | Monuments probed (9 points each) | 144 | 144 | | Points on the navmesh | 1,152 / 1,296 (89%) | 1,151 / 1,296 (89%) | | Of those on a structure (a roof or raised floor) | 132 / 209 (63%) | 130 / 211 (62%) | -| A player-built floor 4 m up | **not on the mesh** | **not on the mesh** | +| A player-built floor 4 m up | **not on the mesh** in the frame it spawned; **on it 0.1–0.3 s later** (stage 2) | the same | | One check | 13.6 µs | 8.3 µs | Monument roofs (Launch Site, Airfield, Trainyard, the warehouses) are mostly walkable. Player-built floors and @@ -553,6 +554,46 @@ and reload checks on both rigs: no NPC saved, every placement back, nothing left also checks each movement mode, sleep and the walk home, both respawn modes, and a placement whose profile is deleted and restored. +**Built (2026-09-30, runicnpc-rust#4, API version 2).** The API as built is [API.md](API.md). What else landed: + +- `tools/fieldlist` generates the swap's field list from the Carbon rig's assemblies (`tools/managed.js` + downloads them). It picks **64 NPC and 32 brain fields**, exactly stage 1's Carbon count. On both rigs every + name resolves, and Carbon's boot check finds no field Rust has added. +- `tools/RunicNpcTest.cs`, the harness: `rnt.run api|hooks|move|sentry|sleep|place|all`, then `rnt.after` after a + reload or restart. + +| Group | What it proves | Oxide | Carbon | +|---|---|---|---| +| api | every call's answer; seven refusals (bad owner, `placement:` owner, unknown or refused profile, bad override, off the navmesh) | 24/24 | 24/24 | +| hooks | spawned, a health threshold once, body damage ×0.5, died with contributors (250 of 250), despawned | 8/8 | 8/8 | +| move | wander within its radius; a route in order (3>0>1>2>3>0); a monument roamer 36 m on Rust's roam; our chase to 9.6–9.8 m of a 10 m chase range, and giving up twice | 8/8 | 8/8 | +| sentry | holds 0.00 m for 40 s and hits a target 12 m off 29–34 times; stands 40 m up, off the navmesh, for 20 s | 7/7 | 7/7 | +| sleep | wanders 8.6 m off, walks home at ≤2.8 m/s when no player is within 160 m, sleeps 1.7 m from its spot, stays still, wakes | 7/7 | 7/7 | +| place | `each` returns only the dead one; `group` waits for all, then all return; a deleted profile waits and returns; a missing route waits | 14/14 | 14/14 | +| a plugin unloads | its NPCs are removed with it | pass | pass | +| RunicNPC reloads | every placement back, the world equals the registry, no plain scientists at the spots | 5/5 | 5/5 | +| the server restarts | the same, after `server.save`, a restart, and the navmesh load | 5/5 | 5/5 | + +What building it found: + +- **Outside a monument, Rust's scientists never chase.** Rust's chase state looks for an AI zone's move points and + returns an error without them, as its roam does (stage 1, Q3). A `wander` or `route` roamer therefore has our + own chase. It closes to three quarters of the attack range, never past the profile's chase range from home, and + if the target stays out of reach at that edge for 5 s it gives up and walks home. A `monument` roamer keeps + Rust's chase. +- **Rust's navmesh covers a new player-built floor in 0.1–0.3 s** (2–3 frames), on both rigs. Stage 1's "a + player-built floor is never on the mesh" (Q4) sampled in the same frame the floor spawned, so it is wrong: a + roamer can stand on a player-built floor a moment after it exists. §5's sentry rule, §8 and stage 10 were + written on stage 1's reading. **Open for the org lead.** +- **Rust's navmesh sampler reaches further down than across.** A roamer asked to stand on a roof found the + ground 4.7 m below. A roamer's spot must now be within 2 m, up or down, of the navmesh it is put on. +- **A brain can think before Unity has started it.** For that moment it has no navigator, so RunicNPC leaves that + think to Rust. +- **A hit with no body part reports every part at once** (`(HitArea)(-1)`: fire, explosions, falls). Only an + exact head or leg hit uses those scales; everything else is body. +- **The game manifest's entity list leaves out the NPC prefabs.** A profile's `prefab` is looked up in its prefab + list instead. + ### Stage 3 — In game The chat and console commands (§5), their permissions, the navmesh placement check, respawn delays, and recording