Merge pull request 'docs(runicnpc): stage 5 spike, measured, and D263–D266' (#310) from docs/runicnpc-stage5-spike into main

Reviewed-on: #310
This commit is contained in:
2026-10-01 08:17:12 +00:00

View File

@@ -79,6 +79,10 @@ architectural or design decision is implemented.
| **D260** | **A kit's extra items are used only when the profile opts in, behaviour by behaviour:** healing with syringes, grenades, switching to melee, rockets, flamethrowers. All are off by default; the main weapon is always used. An item used comes out of the NPC's inventory, so the loot matches what is left (stage 5). | Every behaviour on whenever the kit carries the item; the main weapon only, with extras as loot. |
| **D261** | **On a PVE server, RunicNPC answers TruePVE's and NextGenPVE's damage hook with "allowed" for its own NPCs, both ways**, so players can fight them and they fight back, as with Rust's scientists. A profile may opt out, for example to make an NPC unkillable (stage 5). | Leaving it to the server's TruePVE rules; a config switch, off by default. |
| **D262** | **A patrol that leaves its route to fight returns to the point it was heading to** and carries on from there (stage 5, refines D234). | The nearest point, in the same direction; starting the route again. |
| **D263** | **A fight with Rust's scientists stays one-sided, and our NPCs never go hunting.** A scientist one of ours shoots takes cover, as Rust's AI does, and is not made to fire back. A profile hostile to `scientists` fights only scientists that come within its own leash of its spot. It never walks into a monument to look for them, so our NPCs never clear a monument on their own (stage 5 spike; the org lead's words: "our NPCs will not clear monuments on their own"). | RunicNPC driving a hit scientist to fire back; balancing fights so a few cannot clear a monument; refusing such profiles near monuments at placement. |
| **D264** | **`turrets: always` means the safe-zone sentries too**: Outpost's and Bandit Camp's turrets shoot that profile's NPCs as they shoot a hostile player. `default` is what Rust does (player turrets shoot it, safe-zone sentries do not), and `ignore` means no turret shoots it (stage 5 spike, refines D259). | Dropping `always`, since `default` already means player turrets. |
| **D265** | **The main weapon's ammunition is endless, as in Rust.** An NPC never runs dry, and its loot keeps the kit's ammunition. Only D260's extras (syringes, grenades, rockets and the like) are used up (stage 5 spike, refines D260). | Ammunition used up shot by shot; a per-profile setting. |
| **D266** | **Fire burns as it does in Rust.** A flamethrower NPC's flames burn every NPC in them, its own allies included, because Rust filters only bullets between NPCs. Admins place flamethrower NPCs with that in mind (stage 5 spike). | Our NPCs taking no fire damage from their allies. |
**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
@@ -500,7 +504,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 +831,148 @@ 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. **One-sided, as
D263 decided.**
- **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. **It stays that way (D266).**
**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. **`always` adds the safe-zone sentries (D264).**
**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.
**The main weapon's ammunition stays endless (D265).**
**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.
**What the answers left open, and the org lead's answers (2026-10-01):**
1. **Rust's scientists cannot fight back against ours. → D263:** the fight stays one-sided; the scientist takes cover.
A profile hostile to `scientists` fights only the scientists that come within its own leash, and never goes into a
monument to look for them.
2. **What `turrets: always` means. → D264:** the safe-zone sentries too. Outpost's and Bandit Camp's turrets shoot that
profile's NPCs as they shoot a hostile player.
3. **Ammunition. → D265:** endless, as in Rust. Only D260's extras are used up.
4. **Fire and allies. → D266:** fire burns as it does in Rust, allies included.
What this adds to the build (D263–D266):
- **Sensing (D263):** a profile's hunt for `scientists`, `animals` or another profile is limited by its own leash
around its spot (its chase range, D233). Our sensing never drives an NPC towards a target beyond it, so it never
walks into a monument to look for scientists.
- **Turrets (D264):** `default` needs no code. `ignore` answers `CanBeTargeted` with "no" for the auto turret, flame
turret and trap. `always` makes Outpost's and Bandit Camp's sentries (`NPCAutoTurret`) target it, which Rust's own
`Ignore` and `IsEntityHostile` refuse for every `ScientistNPC`. Stage 5's build finds the hook that reaches those
two, or answers for the sentry from RunicNPC's side, and says which in this plan.
- **Ammunition (D265) and fire (D266):** nothing to build. The NPC's reload stays Rust's own, and fire damage is not
touched.
### Stage 6 — Roles
Bosses (a health bar drawn with CUI, phases at thresholds that swap profile values, announcements through the bridge