From e3ae1905cb9b0f138729d1827a217c001f018d75 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 29 Sep 2026 23:23:35 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- modules/rust/PLAN_REDESIGNS.md | 2 +- runicnpc/PLAN.md | 109 ++++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/modules/rust/PLAN_REDESIGNS.md b/modules/rust/PLAN_REDESIGNS.md index 9c479a0..a45d082 100644 --- a/modules/rust/PLAN_REDESIGNS.md +++ b/modules/rust/PLAN_REDESIGNS.md @@ -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**. diff --git a/runicnpc/PLAN.md b/runicnpc/PLAN.md index 5effcb7..4ef4f7a 100644 --- a/runicnpc/PLAN.md +++ b/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....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,