docs(runicnpc): stage 5 spike, measured, and D263–D266 #310
148
runicnpc/PLAN.md
148
runicnpc/PLAN.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user