docs(runicnpc): stage 5 spike, measured (D231, D253)

What the stage 5 spike measured on both rigs, written into the stage 5
section of runicnpc/PLAN.md: why an NPC's bullets never hurt an NPC (Rust
skips the hit unless the victim's faction is Horror) and the per-shot
mark that fixes it; what our own sensing costs against stage 1's; what
Rust's turrets, its AI and TruePVE or NextGenPVE do with our NPC; and
four questions the answers leave for the org lead.

Stage 1's row on fighting NPCs is corrected: its "cover and facing states
win" was not the cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-01 01:02:16 -05:00
parent c28a688b03
commit ae0b8753bb

View File

@@ -500,7 +500,7 @@ it. That is the other reason our NPC must never be saved (§2).
| 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** (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. |
| **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 landed no hit. (Stage 5 found why: Rust's bullets skip any NPC an NPC shoots at, unless its faction is `Horror`. It was not the design's cover and facing states, as this row first said.) **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.
@@ -827,6 +827,141 @@ pieces below. Its wire changes join protocol 13 while it is unreleased.
**Tested by** harness fights between profiles on the rig (who shoots whom, with what, and within which leash), and
an in-game walk for escort and faction ties to a clan.
**Measured (2026-10-01, `tools/RunicNpcHarness.cs` 0.2.0: `rnh.npcfight`, `rnh.sense`, `rnh.turrets`, `rnh.items`,
`rnh.pve`).** Both rigs, the same 6000 map as stage 1. Costs were measured on one rig at a time, with the other
stopped. Nobody was connected; where a player was needed, the harness used stage 1's stand-ins. The prototype combat
state lives in the harness, not in RunicNPC. **Every answer held on both frameworks** unless it says otherwise.
*The prototype.* Our sensing reads only the factions an NPC is hostile to, from a registry the plugin keeps as
scientists, animals and our NPCs spawn and die. It costs no physics query, only distances. It sorts the nearest three
and tests line of sight on those alone, once a second. Rust's own design still owns every fight with a player. Only when
it has none does our state take over: it faces the target, closes in until the target is in range and in sight, and hands
Rust's own `TickAttack` the target, so the shot is still Rust's.
**1. Why stage 1's NPC never hit an NPC, and what fixes it.**
- **Rust drops the hit, not the shot.** `BaseProjectile.ServerUse` skips every hit where **the shooter is an NPC and
the victim is an NPC**, unless the victim's faction is `Horror` (pets excepted). Animals count as NPCs too. Stage 1
read the missing hits as "the design's cover and facing states win". That was wrong: our NPC fired 79–96 times in
30 s at a stock scientist, and hit it 0 times.
- **`faction` is a plain field that nothing else in Rust reads.** `GetFaction()` is not virtual and is used only by that
check. So our NPC marks **its own target, and only for the length of its own shot**: it sets the target's faction to
`Horror` inside its `ShotTest` and `TriggerDown`, then puts it back. Nothing changes for anyone else's bullets.
- **One trap, found and fixed.** `ShotTest` calls `TriggerDown` inside itself, so the mark nests. The first run let the
inner call save `Horror` as the value to put back. That left targets `Horror` for good, and allies then hit each other
about 475 times in a 50-against-50 fight. A mark is now made only when none is in place. After the fix, no entity was
ever left `Horror` (counted after every phase).
| Fight (15 m, the kit's revolver, 60 s) | Oxide | Carbon |
|---|---|---|
| Ours → stock scientist, no mark | 0 hits in 30 s | 0 hits in 30 s |
| Ours → stock scientist, marked per shot | killed in 44 s, 29 hits | killed in 48 s, 29 hits |
| Ours ↔ ours (two profiles) | both hit; one died at 41 s | both hit; one died at 35 s |
| Ours → boar | it fought back and killed ours (27 s) | the same (25 s) |
| Ours → wolf | 25 hits in 90 s, wolf at 12/150; it never attacked | killed in 43 s, 28 hits; it never attacked |
| Ally of ours standing on the line of fire | **0 hits on the ally**; the scientist died at 47 s | **0 hits**; died at 39 s |
- **A stock scientist never shoots back at ours.** Rust's `HumanNPC.IsTarget` is true only for players, pets and
scarecrows, and it is not virtual. Shot by ours, the scientist takes cover and does nothing else. **Open, question 1
below.**
- **Fire is not filtered.** Rust's NPC-to-NPC check exists only for bullets. An NPC with a flamethrower burned a
polar bear that walked in, and would burn allied NPCs the same way. **Open, question 4.**
**2. What our sensing costs.** Thirty seconds a phase, after five to settle. The frame times include the world's own AI.
About 570 brains (Rust's scientists and animals) already queue for Rust's 2 ms AI budget on this map, which is why the
empty baseline runs well under stage 1's 50 fps.
| Phase | Oxide: median frame | Oxide: ms per think / thinks every | Carbon: median frame | Carbon: ms per think / thinks every |
|---|---|---|---|---|
| Baseline, empty | 22.8 ms | — | 21.2 ms | — |
| 100 of ours, hostile to scientists, none near | 23.2 | 0.18 / 0.8 s | 22.7 | 0.12 / 0.6 s |
| 100 of ours fighting 100 stock scientists | 27.9 | **2.5** / 7.3 s | 24.6 | **2.4** / 6.3 s |
| 50 of ours fighting 50 of ours | 25.9 | **1.9** / 4.8 s | 23.2 | **1.5** / 3.6 s |
| Empty again | 24.2 | — | 21.0 | — |
- **About 7–10× cheaper than Rust's own sensing.** Stage 1 measured 17.7–20.4 ms per think for 100 NPCs sensing NPCs
Rust's way. Ours costs 1.5–2.5 ms per think, gunfire included: about what fighting players cost in stage 1 (2.4–2.5 ms).
- **One sense costs 0.1 ms with nothing near, and 0.5–1.1 ms in a fight.** Nearly all of that is the two line-of-sight
tests; the distance scan over 50–270 candidates is small.
- **Rust's AI budget again sets how quickly they react, not the frame rate.** With 100 fighting on top of the world's
AI, each NPC thinks every 3.5–7.5 s. The cost warning (D227) gets a line for NPCs that fight NPCs, as stage 1's has one
for fighting players.
**3. Turrets and traps (D259).** Each turret was armed with Rust's own inventory (an AK and ammunition, fuel, shells).
It faced a stand-in player as the control, then a stock scientist, then ours. Every target was spawned outside the
turret's trigger and moved in, because a flame turret only notices what *enters* its trigger.
| | Stand-in player | Stock scientist | Ours |
|---|---|---|---|
| Auto turret | 4 hits, 70 damage in 30 s | the same | the same; ours shot back: 20 hits, **11 damage** to the turret's 1,000 |
| Flame turret | burned (196–225 hits) | dead in 7–8 s | dead in 8–10 s; ours shot back, 5–8 hits |
| Shotgun trap | 16 pellets a shot | dead in 2–3 s | dead in 2.5 s; it answered with one shot on Oxide, none on Carbon |
- **Player turrets already treat ours exactly as they treat Rust's scientists**, so D259's `default` needs no code.
Outpost and bandit-camp sentries (`NPCAutoTurret`) ignore every `ScientistNPC`, and ours is one, so they ignore ours
as they do Rust's.
- **Shooting back works, and a revolver barely scratches a turret.** A flame turret's damage arrives through its
fireballs, so "who hurt me" follows the fireball's creator back to the turret.
- `ignore` has Rust's own hook to use: `CanBeTargeted` for the auto turret, the flame turret and the trap.
NextGenPVE uses the same hook. **What `always` means is open, question 2.**
**4. The kit items Rust's AI uses by itself (D260).** Ours carried the kit's revolver, 3 syringes, 2 grenades and a
machete, with Rust's `CanUseHealingItems` switched on. A stand-in "shot" it 20 damage every 4 s, down to 37 health.
Then the main weapon was swapped for a rocket launcher, and then for a flamethrower.
- **Healing: never, in 60 s on either rig.** Rust heals only when it *enters* its cover state at an AI zone's cover
point, below a health fraction, on a random chance, and out of its target's sight. In open ground that never
happens.
- **Grenades and melee: never.** In Rust, only scarecrows throw. No scientist code ever switches to a melee weapon.
- **A rocket launcher held as the main weapon: never fired.** Rust's design stood in combat with it and did not call an
attack once.
- **A flamethrower as the main weapon works** (515–539 hits on the stand-in in 40 s).
- **Nothing is consumed.** 151 attempts to fire the revolver (31 hits) and 40 s of flame left the inventory's ammunition and fuel untouched,
because an NPC reloads for free.
So **every behaviour D260 lists is ours to write**: healing, grenades, melee, rockets, and the flamethrower's range.
**Whether the main weapon's ammunition should be used up too is open, question 3.**
**5. TruePVE and NextGenPVE (D261).** Each damage direction was probed with a 10-damage hit, each plugin on its default
rules. The harness answered `CanEntityTakeDamage` three ways: not at all, "allow", and "deny". The PVE stand-ins
carry Steam-range ids, because both plugins treat only Steam ids as players.
| | Player → player (the control) | Player ↔ ours | Player ↔ stock scientist | Ours ↔ stock scientist |
|---|---|---|---|---|
| No PVE plugin | hurts | hurts | hurts | hurts |
| TruePVE 2.4.4 (uMod) | **blocked** | hurts; "deny" blocks it | hurts | hurts; "deny" blocks it |
| NextGenPVE 1.8.2 (Oxide) | **blocked** | hurts; **"deny" ignored** | hurts | hurts; "deny" ignored |
| NextGenPVE 1.8.2 (Carbon) | hurts: it failed to start (`SQLite.Interop` missing on this host) | hurts | hurts | hurts |
- **Both plugins' defaults already treat ours as Rust's scientists, both ways**, so D261's "allowed" changes nothing on
default rules. It protects ours from stricter rule sets.
- **Both plugins ask `CanEntityTakeDamage` once per hit. TruePVE honours `true` and `false`; NextGenPVE honours only
`true`.** D261's per-profile opt-out therefore cannot rely on that hook. RunicNPC does it itself, in places no PVE
plugin touches: an unkillable profile zeroes its damage in the NPC's own `Hurt`, and a harmless profile sets the NPC's
`damageScale` (Rust's own multiplier on an NPC's shots) to 0.
- NextGenPVE is not on uMod. Its source is on GitHub (`Remod-org/NextGenPVE`, GPL-2.0). Both plugins were removed from
both rigs afterwards, with their config, data and language files.
**6. Two notes on the harness itself.** A turret or trap spawned by the harness sometimes vanished the moment its
victim's body landed beside it. That looks like Rust's ground check on a deployable that was never placed by a player,
not anything an NPC did. The world's own wolves and a polar bear wandered into two scenarios; the tables count only the
entities the harness labelled.
**Open for the org lead** (each becomes a D-number when answered):
1. **Rust's scientists cannot fight back against ours.** A bandit profile set hostile to `scientists` will shoot them,
and they will only take cover. The two ways: leave it one-sided; or have RunicNPC drive a stock scientist that ours has
hit (face ours, fire, our mark on ours for that shot), which costs RunicNPC a loop over every scientist in a fight.
2. **`turrets: always`.** With `default`, player turrets already shoot ours. Does `always` mean that outpost and
bandit-camp sentries shoot it too, so a hostile NPC walking into Outpost is shot as a hostile player would be? Or
does it mean something else?
3. **Ammunition.** Rust never uses up an NPC's ammunition or fuel. D260 consumes the extras (syringes, grenades,
rockets). Should the main weapon's ammunition be used up too, so an NPC can run dry and its loot shows what it shot?
Or does it keep Rust's endless magazine?
4. **Fire and allies.** A flamethrower NPC burns every NPC in its flame, its own faction's included, because Rust
filters only bullets. The choices: leave it as Rust does; or protect allies of ours from the fire (our NPC's own
`Hurt`, which cannot protect Rust's scientists).
### Stage 6 — Roles
Bosses (a health bar drawn with CUI, phases at thresholds that swap profile values, announcements through the bridge