docs(runicnpc): stage 1 measured, D231

The harness answered all six stage 1 questions on both rigs, on one 6000 world:
the swap needs AwakeFromInstantiate; unsaved NPCs never return; Rust's brain
honours sense values only before it starts, roams only in AI zones and never
attacks NPCs; placement checks use Rust's Gen2 navmesh (89% of monument points,
never a player-built floor); the cost table for 1/10/100 idle and fighting on
Oxide and Carbon; the death screen needs an AttackerInfo override, and the
bridge's frames still name the prefab.

D231: each profile or group decides whether its NPCs target NPCs, and which
kinds, which needs our own combat state and sensing (stage 5 spike).

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-29 23:23:35 -05:00
parent 23c2a5a5cf
commit e3ae1905cb
2 changed files with 108 additions and 3 deletions

View File

@@ -8,7 +8,7 @@ they can be read together. **Built so far:** §1, walked 2026-09-28 (§1.9); §5
rig walk still to come (§5.8, D209); §3, built and walked on the rigs and the site 2026-09-29, its in-game
rows still to come (§3.4); §4, built 2026-09-29 (§4.1). **§6 was spiked on 2026-09-30, and the org lead
chose neither route: Runic Gateway writes its own NPC plugin, RunicNPC, planned in
[`../../runicnpc/PLAN.md`](../../runicnpc/PLAN.md) (D214–D230), and the rest of this plan waits for it.**
[`../../runicnpc/PLAN.md`](../../runicnpc/PLAN.md) (D214–D231), and the rest of this plan waits for it.**
This is a companion to [`PLAN_FIXES.md`](PLAN_FIXES.md) and [`PLAN.md`](PLAN.md). Where they disagree, this
document is later and wins. Its decisions continue PLAN_FIXES' numbering at **D188**.

View File

@@ -1,8 +1,8 @@
# RunicNPC — the plan
**Status:** plan, written 2026-09-30. Its eight questions (§11) were answered the same day: D221–D228 (§0).
**Stage 0 built 2026-09-30** (runicnpc-rust#1, §9): an empty plugin that loads on both rigs. It is closed by the
first release, v0.1.0, at the `edge`→`main` cutover. D229–D230 were decided with it.
**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.
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
@@ -42,6 +42,7 @@ architectural or design decision is implemented.
| **D228** | **`RunicNPC.cs`, console name `RunicNPC`, chat command `/rnpc`, permissions `runicnpc.*`** (Q8). | — |
| **D229** | **The empty repository was seeded by one `chore:` commit straight to `main`** (the licence and a stub README), `edge` was branched from it, and everything after goes by PR (stage 0). It is the only direct push. | You seeding it in the Gitea UI; the whole scaffold straight to `main`, unreviewed. |
| **D230** | **The layout is `plugin/RunicNPC.cs` and a root `plugin.toml`**: `api`, the framework floors, `requires_plugins` (stage 0). RunicNPC is one file, not an overlay of a server tree. | Mirroring Rust-Plugins' `overlay/oxide/plugins/` and `overlay.toml`. |
| **D231** | **Each profile (or group) decides whether its NPCs target other NPCs, and which kinds**: Rust's scientists, animals, other profiles, and so on (stage 1; the org lead's words). Rust's AI cannot do this (§9, stage 1, Q3), so it is our own combat state and sensing, and stage 5 opens with a spike for it. | Our NPCs fighting players only, as Rust's scientists do. |
**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
@@ -381,6 +382,110 @@ A harness plugin (`tools/RunicNpcHarness.cs`) answers, on **both** rigs, what st
**Done when** each has a measured answer written into this plan, and the org lead has picked anything the answers
leave open.
**Measured (2026-09-30, `tools/RunicNpcHarness.cs`, runicnpc-rust#3).** Both rigs ran the same world for this:
the Oxide rig was regenerated to the Carbon rig's **6000 map, seed 981448696, 12 GB**. Costs were measured on one rig
at a time, with the other stopped, because they share the node's CPU. Nobody was connected. Each answer below held on
**both** frameworks unless it says otherwise.
*How the harness makes a player.* Q6 and the fighting load need a player, and the rigs have none. At the org lead's
suggestion, the harness spawns **stand-ins**: `player.prefab` with a made-up user id above Rust's bot range
(11400000001 and up). `IsNpc` is false, so Rust's AI and the bridge treat one exactly as a player. This is §1.2's
HumanNPC lesson, used on purpose. A stand-in never connects, and only the harness makes one.
**1. The swap works, with one step the plan did not know about.**
- **`CreateEntity(prefab, pos, rot, startActive: false)` must be followed by `gameObject.AwakeFromInstantiate()`
before `Spawn()`.** That is the call `GameManager.CreatePrefab` makes itself when `active` is true. Without it the
entity still spawns, is networked and even thinks. But Unity never starts the brain, so it has no AI design, no
navigator and no state. It stood still for 90 s.
- With that call, the swap takes **9–16 ms**. The NPC is our type (`RnhNpc`, `RnhBrain`), `IsNpc` is true, it is
not saved, it keeps its name, and it wears and holds its kit (`pistol_revolver.entity`).
- It walks, chases and **fights a player with its kit's weapon**: it killed a stand-in from 12–13 m in 14–20 s, every
hit a `pistol_revolver.entity`. It dies cleanly. Its corpse, an `NPCPlayerCorpse`, carries its name.
- **The field copy differs between frameworks.** The rule "public and not `[NonSerialized]`, or `[SerializeField]`"
copied 64 NPC and 32 brain fields on Carbon, but **124 and 36 on Oxide**. Oxide's patcher makes private fields
public, so on Oxide the rule also carries runtime state across. It worked on both, but stage 2 needs a rule that
selects the same fields on both, for example a list read from Carbon's result and checked on Oxide.
**2. `enableSaving = false` holds.** Five of ours were never saved and one was, as a control. After `server.save`, a
hard kill and a restart, **none of the five came back, on either rig**, matched by net id. The control came back,
but as a plain `ScientistNPC` named "Scientist": a saved subclass loses its type and its name when the server loads
it. That is the other reason our NPC must never be saved (§2).
**3. What holds on Rust's brain, and what needs our own states.**
| Setting | Result |
|---|---|
| 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.** |
| 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. |
**4. Navmesh.** Rust's scientists walk **Rust's own navmesh** (its Gen2 `RustNavMeshAgent`), not Unity's: a Unity
`NavMesh` query found nothing in the open world. **The placement check is `Rust.Ai.Gen2.RustNavMeshHelpers.
SamplePosition`**, the same helper Rust's navigator uses. It answers false until `RustNavigation.Instance.
IsDefaultNavmeshBuilt()`. On a map's **first** boot that took 8–10 minutes (477 s on Carbon, 604 s on Oxide); later
boots load the saved `proceduralmap.<size>.<seed>.<n>.navmesh`. **Placements that respawn at boot must wait for it.**
| | Carbon | Oxide |
|---|---|---|
| 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** |
| One check | 13.6 µs | 8.3 µs |
Monument roofs (Launch Site, Airfield, Trainyard, the warehouses) are mostly walkable. Player-built floors and
roofs never are: that is §5's sentry rule, and stage 10.
**5. The cost (D227's numbers).** Seven 60 s phases on a field 400 m from any monument: a baseline, 1, 10 and 100
of ours idle, the same 100 shooting 20 stand-ins (whose damage the harness zeroes, so none die), a fresh 100 set to
fight NPCs beside 100 stock scientists, then empty again. Every phase also had one 1–3 s spike from the autosave, so
the table quotes the median frame rather than the worst.
| Phase | Oxide: median frame / fps | Oxide: time per think / each NPC thinks every | Carbon: median frame / fps | Carbon: time per think / each thinks every |
|---|---|---|---|---|
| Baseline, empty | 16.4 ms / 52.7 | — | 17.3 ms / 46.6 | — |
| 1 | 16.7 / 50.7 | 0.09 ms / 0.28 s | 17.3 / 48.1 | 0.05 ms / 0.29 s |
| 10 | 16.6 / 51.8 | 0.04 ms / 0.28 s | 17.7 / 47.4 | 0.03 ms / 0.29 s |
| 100 idle | 17.7 / 48.3 | 0.04 ms / 0.30 s | 18.9 / 41.8 | 0.03 ms / 0.33 s |
| 100 shooting 20 players | 21.6 / 44.5 | 2.5 ms / **2.5 s** | 22.1 / 43.1 | 2.4 ms / **2.6 s** |
| 100 sensing NPCs, beside 100 scientists | 26.4 / 33.9 | **17.7 ms** / **7.5 s** | 26.1 / 33.9 | **20.4 ms** / **8.2 s** |
| Spawning 100 at once | a **552 ms** hitch (5.5 ms each) | | a **483 ms** hitch (4.8 ms each) | |
What the numbers say:
- **The two frameworks cost the same.** Every row agrees within about 1 ms, so one warning serves both.
- **This rig is slow.** An empty server runs at about 50 fps, not its 240 target. The warning therefore reports
what NPCs **add**, not an absolute fps.
- **Idle NPCs are cheap:** 100 add about 1 ms to the median frame.
- **Fighting NPCs hit Rust's AI budget before they hit the frame rate.** Every human NPC's thinking shares
`aithinkmanager.framebudgetms`, 2 ms per frame. At 100 fighting, each NPC gets to think only every 2.5 s, so they
react sluggishly while the median frame rises by only about 5 ms. The warning has to say both things.
- **Sensing NPCs the way Rust does, with a line-of-sight test to every candidate, costs about 7× more per think than
fighting players.** 100 NPCs sensing each other make roughly 100² tests. D231's NPC targeting must sense
differently, for example by testing only the profiles it is hostile to, nearest first.
- **Spawn in batches.** 100 in one frame is a half-second hitch; stage 2 spreads a large placement over frames.
**6. The name on the death screen, and what the bridge publishes.**
- **`HumanNPC.AttackerInfo` writes the prefab's short name as the killer**, after `BasePlayer` has written the
display name. The death screen said "scientistnpc_roam" until our NPC overrode it. With the override it says
**"RnhWarden"**, and the weapon stays `pistol_revolver.entity`.
- **Open for the in-game walk:** the client may use that string to pick the killer's portrait. Only a real client
shows whether the override costs the portrait.
- **What the bridge publishes (protocol 13):**
- our NPC kills a player → `player.death` with `attackerType: "npc"`, **`attackerName: "scientistnpc_roam"`**
(the prefab name, as §1.4 said), the weapon and the distance;
- a player kills our NPC → **only** `player.tally` with `npcKills: 1` and the weapon. Nothing names the NPC or its
profile.
Both are stage 4's to fix: the NPC's name in the feed, and a death frame with its profile.
- **Left on the rigs:** each rig's sidecar store now has the stand-in "RnhDummy" (11400000001) as a player, with
one death and one tally row. It is test data on test rigs; a wipe clears it.
### Stage 2 — The NPC and its API
- The subclass, profiles (read from RunicNPC's own data file for now), kits (random pick), appearance, combat values,