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