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:
@@ -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**.
|
||||
|
||||
109
runicnpc/PLAN.md
109
runicnpc/PLAN.md
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user