diff --git a/tools/RunicNpcHarness.cs b/tools/RunicNpcHarness.cs
index 8066790..d511902 100644
--- a/tools/RunicNpcHarness.cs
+++ b/tools/RunicNpcHarness.cs
@@ -28,6 +28,11 @@ namespace Oxide.Plugins
/// - the name on the death screen, and what the bridge publishes (rnh.dummy).
///
///
+ /// And stage 5's spike (D231, D253): our own sensing and combat state against NPC targets
+ /// (rnh.npcfight), its cost (rnh.sense), turrets and traps (rnh.turrets,
+ /// D259), the kit items Rust's AI uses by itself (rnh.items, D260), and TruePVE or
+ /// NextGenPVE, whichever is loaded (rnh.pve, D261).
+ ///
/// Every result is a line starting [rnh] in the console and a record in the data file
/// RunicNpcHarness.json, read back over the panel. Nothing here is RunicNPC's design:
/// it is the measurement that design waits for.
@@ -45,8 +50,8 @@ namespace Oxide.Plugins
/// other plugin's code.
///
///
- [Info("RunicNpcHarness", "Runic Gateway", "0.1.0")]
- [Description("RunicNPC stage 1 measurements. A developer tool: never ship it.")]
+ [Info("RunicNpcHarness", "Runic Gateway", "0.2.0")]
+ [Description("RunicNPC stage 1 and stage 5 measurements. A developer tool: never ship it.")]
internal class RunicNpcHarness : RustPlugin
{
[PluginReference] private Plugin Kits;
@@ -93,6 +98,9 @@ namespace Oxide.Plugins
{
_meterHost = new GameObject("RunicNpcHarness.FrameMeter");
_meter = _meterHost.AddComponent();
+ foreach (BaseNetworkable n in BaseNetworkable.serverEntities)
+ Register(n as BaseEntity);
+ Log("ready", $"registry scientists={Scientists.Count} animals={Animals.Count} pveStandIn.isSteamId={PveUserId.IsSteamId()}");
Log("ready", $"oxide-or-carbon={Framework()} fps.target={Application.targetFrameRate} " +
$"ai.budget.ms={AIThinkManager.framebudgetms} map={World.Size} seed={World.Seed} navmeshBuilt={NavmeshBuilt()} " +
$"kit.{Kit}={KitExists()}");
@@ -106,6 +114,9 @@ namespace Oxide.Plugins
_spawned.Clear();
if (_meterHost != null)
UnityEngine.Object.Destroy(_meterHost);
+ Scientists.Clear();
+ Animals.Clear();
+ OursByTag.Clear();
_self = null;
}
@@ -284,6 +295,100 @@ namespace Oxide.Plugins
base.AttackerInfo(info);
info.attackerName = displayName;
}
+
+ // ---- stage 5: our own target, beside Rust's ----
+
+ /// This NPC's "profile" for the spike: allies share a tag.
+ public string RnhTag = "";
+
+ /// The factions it hunts: scientists, animals, ours:<tag>. Null: none.
+ public HashSet RnhHostile;
+
+ ///
+ /// BaseProjectile.ServerUse drops every hit on an NPC from an NPC's gun unless the victim's
+ /// faction is Horror. When set, the target alone is made Horror for the length of each of
+ /// this NPC's shots, then put back, so nothing else changes for anyone else's bullets.
+ ///
+ public bool RnhShotFaction;
+
+ /// When a turret or trap hurts it and it has no target, the turret becomes its target.
+ public bool RnhShootsBack;
+
+ public BaseCombatEntity RnhNpcTarget;
+ public int ShotTests, ShotsMarked;
+ public string LastHurtBy = "-";
+
+ private BaseCombatEntity _marked;
+ private BaseCombatEntity.Faction _markedWas;
+
+ ///
+ /// Marks the target unless a mark is already in place, and says whether it did. ShotTest
+ /// calls TriggerDown inside itself, so the two nest: an inner mark would save "Horror" as
+ /// the faction to put back and leave the target Horror for good, after which everyone's
+ /// bullets hit it, allies' included (the first 50-v-50 run showed exactly that).
+ ///
+ private bool Mark()
+ {
+ if (_marked != null)
+ return false;
+ BaseCombatEntity t = RnhNpcTarget;
+ if (!RnhShotFaction || t == null || t.IsDestroyed || !t.IsNpc)
+ return false;
+ _marked = t;
+ _markedWas = t.faction;
+ t.faction = BaseCombatEntity.Faction.Horror;
+ ShotsMarked++;
+ return true;
+ }
+
+ private void Unmark(bool marked)
+ {
+ if (!marked)
+ return;
+ if (_marked != null && !_marked.IsDestroyed)
+ _marked.faction = _markedWas;
+ _marked = null;
+ }
+
+ /// A single-shot weapon fires inside ShotTest; a burst weapon fires from TriggerDown.
+ public override bool ShotTest(float targetDist)
+ {
+ ShotTests++;
+ bool marked = Mark();
+ try { return base.ShotTest(targetDist); }
+ finally { Unmark(marked); }
+ }
+
+ public override void TriggerDown()
+ {
+ bool marked = Mark();
+ try { base.TriggerDown(); }
+ finally { Unmark(marked); }
+ }
+
+ public override void Hurt(HitInfo info)
+ {
+ if (info != null)
+ {
+ BaseEntity parent = info.Weapon != null ? info.Weapon.GetParentEntity() : null;
+ LastHurtBy = $"initiator={info.Initiator?.GetType().Name ?? "null"} weapon={info.Weapon?.ShortPrefabName ?? "null"} weaponParent={parent?.GetType().Name ?? "null"} type={info.damageTypes.GetMajorityDamageType()}";
+ BaseCombatEntity turret = TurretOf(info.Initiator) ?? TurretOf(parent);
+ if (RnhShootsBack && turret != null && (RnhNpcTarget == null || RnhNpcTarget.IsDestroyed))
+ RnhNpcTarget = turret;
+ }
+ base.Hurt(info);
+ }
+
+ ///
+ /// The turret behind a hit. A flame turret's damage comes from its fireballs, whose
+ /// creator is the turret, or the fireball that spawned them: follow the chain.
+ ///
+ public static BaseCombatEntity TurretOf(BaseEntity e)
+ {
+ for (int i = 0; i < 6 && e is FireBall; i++)
+ e = e.creatorEntity == e ? null : e.creatorEntity;
+ return e is AutoTurret || e is FlameTurret || e is GunTrap ? (BaseCombatEntity)e : null;
+ }
}
///
@@ -323,9 +428,182 @@ namespace Oxide.Plugins
npc.AttackTick(delta, target, Senses.Memory.IsLOS(target));
}
}
+
+ if (npc != null && Navigator != null && (npc.RnhHostile != null || npc.RnhNpcTarget != null))
+ {
+ long o0 = Stopwatch.GetTimestamp();
+ OwnThink(npc, delta);
+ OwnMs += (Stopwatch.GetTimestamp() - o0) * 1000.0 / Stopwatch.Frequency;
+ OwnThinks++;
+ }
ThinkMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency;
Thinks++;
}
+
+ // ---- stage 5: the prototype of our own sensing and combat state ----
+
+ public const float OwnSenseRange = 40f;
+ public const float OwnLoseRange = 60f;
+ public const float OwnSenseEvery = 1f;
+ private const int LosBudget = 3;
+ private const int LosMask = 1218519041;
+
+ public static double OwnMs, SenseMs;
+ public static long OwnThinks, SenseRuns, Scanned, LosTests, Engaged;
+
+ private float _nextSense, _nextLos, _nextMove;
+ private bool _los;
+
+ private static readonly BaseCombatEntity[] Best = new BaseCombatEntity[LosBudget];
+ private static readonly float[] BestD2 = new float[LosBudget];
+
+ ///
+ /// After Rust's own think, so Rust's design still owns every fight with a player. With no
+ /// player to fight and a target of ours: face it (the states clear facing every think, so
+ /// it is set again after them; the navigator applies it on its own 0.1 s tick), close in
+ /// until it is in range and in sight, and hand Rust's own attack routine the target.
+ ///
+ private void OwnThink(RnhNpc npc, float delta)
+ {
+ float now = Time.time;
+ BaseCombatEntity t = npc.RnhNpcTarget;
+ if (t != null && (t.IsDestroyed || t.IsDead() || (t.transform.position - npc.transform.position).sqrMagnitude > OwnLoseRange * OwnLoseRange))
+ t = npc.RnhNpcTarget = null;
+
+ if (npc.RnhHostile != null && now >= _nextSense && RnhNpc.TurretOf(t) == null)
+ {
+ _nextSense = now + OwnSenseEvery;
+ long s0 = Stopwatch.GetTimestamp();
+ BaseCombatEntity found = SenseHostile(npc);
+ SenseMs += (Stopwatch.GetTimestamp() - s0) * 1000.0 / Stopwatch.Frequency;
+ SenseRuns++;
+ if (found != null)
+ t = npc.RnhNpcTarget = found;
+ }
+
+ if (t == null || FightingAPlayer())
+ return;
+
+ Engaged++;
+ if (now >= _nextLos)
+ {
+ _nextLos = now + 0.5f;
+ _los = Los(npc, t);
+ LosTests++;
+ }
+
+ Navigator.SetFacingDirectionEntity(t);
+ float dist = Vector3.Distance(npc.transform.position, t.transform.position);
+ if (!_los || dist > npc.EngagementRange() * 0.9f)
+ {
+ if (now >= _nextMove)
+ {
+ _nextMove = now + 1f;
+ Navigator.SetDestination(t.transform.position, BaseNavigator.NavigationSpeed.Fast);
+ }
+ }
+ else if (Navigator.Moving)
+ Navigator.Stop();
+
+ npc.AttackTick(delta, t, _los);
+ }
+
+ private bool FightingAPlayer()
+ {
+ if (CurrentState == null || !CurrentState.AgrresiveState || Senses?.Memory == null)
+ return false;
+ foreach (BaseEntity e in Senses.Memory.Targets)
+ if (e is BasePlayer p && !p.IsNpc && p.IsAlive())
+ return true;
+ return false;
+ }
+
+ ///
+ /// Only the factions this NPC is hostile to, from the harness's registry rather than a
+ /// physics query; distance first (no physics), then line of sight on the nearest few only.
+ ///
+ private static BaseCombatEntity SenseHostile(RnhNpc npc)
+ {
+ for (int i = 0; i < LosBudget; i++) { Best[i] = null; BestD2[i] = float.MaxValue; }
+ Vector3 at = npc.transform.position;
+ float r2 = OwnSenseRange * OwnSenseRange;
+ foreach (string faction in npc.RnhHostile)
+ {
+ HashSet set = Registry(faction);
+ if (set == null) continue;
+ foreach (BaseCombatEntity e in set)
+ {
+ if (e == null || e.IsDestroyed || e == npc || e.IsDead()) continue;
+ Scanned++;
+ float d2 = (e.transform.position - at).sqrMagnitude;
+ if (d2 > r2 || d2 >= BestD2[LosBudget - 1]) continue;
+ int k = LosBudget - 1;
+ while (k > 0 && BestD2[k - 1] > d2) { Best[k] = Best[k - 1]; BestD2[k] = BestD2[k - 1]; k--; }
+ Best[k] = e; BestD2[k] = d2;
+ }
+ }
+ for (int i = 0; i < LosBudget && Best[i] != null; i++)
+ {
+ LosTests++;
+ if (Los(npc, Best[i])) return Best[i];
+ }
+ return null;
+ }
+
+ ///
+ /// HumanNPC.CanSeeTarget answers true for anything that is not a player, without a ray,
+ /// so animals and turrets get a linecast that counts the target's own collider as seen.
+ ///
+ private static bool Los(RnhNpc npc, BaseCombatEntity t)
+ {
+ BasePlayer p = t as BasePlayer;
+ if (p != null) return npc.CanSeeTarget(p);
+ RaycastHit hit;
+ if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore))
+ return true;
+ BaseEntity e = hit.GetEntity();
+ return e != null && (e == t || e.EqualNetID(t) || e.HasParent() && e.GetParentEntity() == t);
+ }
+ }
+
+ // ---- the stage 5 registry: who can be hunted, by faction ----
+
+ private static readonly HashSet Scientists = new HashSet();
+ private static readonly HashSet Animals = new HashSet();
+ private static readonly Dictionary> OursByTag = new Dictionary>();
+
+ private static HashSet Registry(string faction)
+ {
+ if (faction == "scientists") return Scientists;
+ if (faction == "animals") return Animals;
+ HashSet set;
+ return faction.StartsWith("ours:") && OursByTag.TryGetValue(faction.Substring(5), out set) ? set : null;
+ }
+
+ private static void Register(BaseEntity e)
+ {
+ if (e is RnhNpc) return;
+ if (e is ScientistNPC s) Scientists.Add(s);
+ else if (e is BaseNpc || e is Rust.Ai.Gen2.BaseNPC2) Animals.Add((BaseCombatEntity)e);
+ }
+
+ private static void Tag(RnhNpc npc, string tag)
+ {
+ npc.RnhTag = tag;
+ HashSet set;
+ if (!OursByTag.TryGetValue(tag, out set)) OursByTag[tag] = set = new HashSet();
+ set.Add(npc);
+ }
+
+ private static void Unregister(BaseEntity e)
+ {
+ BaseCombatEntity c = e as BaseCombatEntity;
+ if (c == null) return;
+ Scientists.Remove(c);
+ Animals.Remove(c);
+ RnhNpc o = c as RnhNpc;
+ HashSet set;
+ if (o != null && OursByTag.TryGetValue(o.RnhTag, out set)) set.Remove(o);
}
// ---- frame meter ----
@@ -458,6 +736,7 @@ namespace Oxide.Plugins
{
if (info == null) return;
string weapon = info.WeaponPrefab != null ? info.WeaponPrefab.ShortPrefabName : info.Weapon != null ? info.Weapon.ShortPrefabName : "none";
+ TallyPair(victim, info, weapon);
if (IsStandIn(victim) && info.Initiator is RnhNpc)
{
_hitsOnStandIns.Hits++;
@@ -474,13 +753,32 @@ namespace Oxide.Plugins
private void OnEntityDeath(BaseCombatEntity victim, HitInfo info)
{
+ string label;
+ if (victim != null && _labels.TryGetValue(victim, out label) && !_diedAt.ContainsKey(label))
+ _diedAt[label] = (float)Math.Round(Time.time - _scenarioStart, 1);
if (!(victim is RnhNpc) && !IsStandIn(victim)) return;
BasePlayer p = (BasePlayer)victim;
_deaths.Add($"{p.GetType().Name} '{p.displayName}' by {(info?.Initiator != null ? info.Initiator.GetType().Name + " '" + (info.Initiator as BasePlayer)?.displayName + "'" : "none")}");
}
+ private void OnEntityKill(BaseNetworkable entity) => Unregister(entity as BaseEntity);
+
+ ///
+ /// TruePVE and NextGenPVE ask every plugin this before applying their rules (D261). The
+ /// harness counts the asks; in "allow" mode it allows any hit to or from one of ours, in
+ /// "deny" mode it refuses them.
+ ///
+ private object CanEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
+ {
+ _pveAsks++;
+ if (_pveMode == 0 || !(entity is RnhNpc || info?.Initiator is RnhNpc))
+ return null;
+ return _pveMode == 1;
+ }
+
private void OnEntitySpawned(BaseEntity entity)
{
+ Register(entity);
PlayerCorpse corpse = entity as PlayerCorpse;
if (corpse == null) return;
if (corpse.playerSteamID != DummyUserId && (corpse.playerName == null || !corpse.playerName.StartsWith("Rnh"))) return;
@@ -1083,6 +1381,670 @@ namespace Oxide.Plugins
});
}
+ // ==== stage 5 (PLAN.md ยง9, stage 5; D231, D253-D262) ====
+
+ ///
+ /// The PVE stand-in's user id. TruePVE and NextGenPVE treat only Steam ids as players
+ /// (IsSteamId), so this one is in the Steam range, and far beyond any id Steam has issued.
+ ///
+ private const ulong PveUserId = 76570997960265728UL;
+
+ /// 0 observe (answer nothing), 1 allow (D261), 2 deny (D261's per-profile opt-out).
+ private int _pveMode;
+ private int _pveAsks;
+
+ /// Labelled entities: every hit to or from one is tallied by "attacker>victim".
+ private readonly Dictionary _labels = new Dictionary();
+ private readonly Dictionary _pairs = new Dictionary();
+ private readonly Dictionary _diedAt = new Dictionary();
+ private readonly HashSet _invulnerable = new HashSet();
+ private float _scenarioStart;
+
+ private T Label(T e, string label) where T : BaseEntity
+ {
+ if (e == null) return null;
+ _labels[e] = label;
+ _spawned.Add(e);
+ return e;
+ }
+
+ private void TallyPair(BaseCombatEntity victim, HitInfo info, string weapon)
+ {
+ string v = null, a = null;
+ if (victim != null) _labels.TryGetValue(victim, out v);
+ BaseEntity src = info.Initiator;
+ if (src != null) _labels.TryGetValue(src, out a);
+ if (a == null && info.Weapon != null)
+ {
+ BaseEntity parent = info.Weapon.GetParentEntity();
+ if (parent != null) _labels.TryGetValue(parent, out a);
+ }
+ if (v == null && a == null) return;
+
+ string key = (a ?? (src != null ? src.ShortPrefabName : "none")) + ">" + (v ?? victim.ShortPrefabName);
+ HitTally t;
+ if (!_pairs.TryGetValue(key, out t)) _pairs[key] = t = new HitTally();
+ t.Hits++;
+ t.Damage += info.damageTypes.Total();
+ int n;
+ t.Weapons.TryGetValue(weapon, out n);
+ t.Weapons[weapon] = n + 1;
+
+ if (victim != null && _invulnerable.Contains(victim))
+ info.damageTypes.ScaleAll(0f);
+ }
+
+ private Dictionary Pairs() => _pairs.ToDictionary(kv => kv.Key, kv => (object)new Dictionary
+ {
+ ["hits"] = kv.Value.Hits,
+ ["damage"] = Math.Round(kv.Value.Damage, 1),
+ ["weapons"] = kv.Value.Weapons.ToDictionary(w => w.Key, w => (object)w.Value),
+ });
+
+ private sealed class Scenario
+ {
+ public string Name;
+ public float Seconds;
+ public Action Setup;
+ public Func Done;
+ public Func> Extra;
+ }
+
+ ///
+ /// Runs scenarios one after another: set up, wait until done or out of time, record the hit
+ /// pairs, deaths and extras, then remove everything labelled and the corpses near the field.
+ ///
+ private void RunScenarios(string test, Vector3 field, List list, Dictionary header = null)
+ {
+ var results = new List>();
+ int index = 0;
+ Action next = null;
+ next = () =>
+ {
+ if (index >= list.Count)
+ {
+ var rec = header ?? new Dictionary();
+ rec["framework"] = Framework();
+ rec["scenarios"] = results;
+ Record(test + "." + Framework(), rec);
+ Log(test, "DONE");
+ return;
+ }
+ Scenario sc = list[index++];
+ _labels.Clear(); _pairs.Clear(); _diedAt.Clear(); _invulnerable.Clear();
+ RnhBrain.OwnMs = RnhBrain.SenseMs = 0; RnhBrain.OwnThinks = RnhBrain.SenseRuns = RnhBrain.Scanned = RnhBrain.LosTests = RnhBrain.Engaged = 0;
+ _scenarioStart = Time.time;
+ string error = null;
+ try { sc.Setup(); }
+ catch (Exception e) { error = e.GetType().Name + ": " + e.Message; }
+
+ float elapsed = 0f;
+ Timer poll = null;
+ poll = timer.Every(1f, () =>
+ {
+ elapsed += 1f;
+ bool done = error != null;
+ if (!done) { try { done = sc.Done != null && sc.Done(); } catch { done = true; } }
+ if (!done && elapsed < sc.Seconds) return;
+ poll.Destroy();
+
+ var r = new Dictionary
+ {
+ ["scenario"] = sc.Name,
+ ["seconds"] = elapsed,
+ ["pairs"] = Pairs(),
+ ["diedAt"] = new Dictionary(_diedAt),
+ };
+ if (error != null) r["error"] = error;
+ if (sc.Extra != null)
+ {
+ try { foreach (KeyValuePair kv in sc.Extra()) r[kv.Key] = kv.Value; }
+ catch (Exception e) { r["extraError"] = e.Message; }
+ }
+ results.Add(r);
+ Log(test, $"{sc.Name}: {JsonConvert.SerializeObject(r)}");
+
+ foreach (BaseEntity e in _labels.Keys.ToArray())
+ if (e != null && !e.IsDestroyed) e.Kill();
+ ClearLeftovers(field, 80f);
+ timer.Once(2f, next);
+ });
+ };
+ Log(test, $"field {V(field)}, {list.Count} scenarios");
+ next();
+ }
+
+ /// Corpses and dropped backpacks near the field: a fight leaves both.
+ private static void ClearLeftovers(Vector3 field, float radius)
+ {
+ foreach (BaseEntity e in BaseNetworkable.serverEntities.OfType()
+ .Where(e => (e is BaseCorpse || e is DroppedItemContainer || e is DroppedItem) && Vector3.Distance(e.transform.position, field) < radius)
+ .ToArray())
+ e.Kill();
+ }
+
+ private RnhNpc Ours(Vector3 at, string label, string tag, bool shotFaction, params string[] hostile)
+ {
+ SpawnReport r = SpawnOurs(Ground(at), "Rnh" + label);
+ if (r.Npc == null) throw new Exception("our NPC did not spawn: " + r.Error);
+ Tag(r.Npc, tag);
+ r.Npc.RnhShotFaction = shotFaction;
+ if (hostile.Length > 0) r.Npc.RnhHostile = new HashSet(hostile);
+ return Label(r.Npc, label);
+ }
+
+ private ScientistNPC Stock(Vector3 at, string label) => Label(SpawnStock(Ground(at)), label);
+
+ private static string FindPrefab(string suffix) =>
+ GameManifest.Current.entities.FirstOrDefault(p => p.EndsWith(suffix, StringComparison.OrdinalIgnoreCase));
+
+ private BaseCombatEntity Animal(string suffix, Vector3 at, string label)
+ {
+ string path = FindPrefab(suffix);
+ if (path == null) throw new Exception("no prefab ends with " + suffix);
+ BaseCombatEntity a = GameManager.server.CreateEntity(path, Ground(at)) as BaseCombatEntity;
+ if (a == null) throw new Exception(path + " is not a combat entity");
+ a.enableSaving = false;
+ a.Spawn();
+ return Label(a, label);
+ }
+
+ private static Dictionary Stats(RnhNpc o) => o == null || o.IsDestroyed
+ ? new Dictionary { ["gone"] = true }
+ : new Dictionary
+ {
+ ["health"] = Math.Round(o.health, 0),
+ ["target"] = o.RnhNpcTarget != null ? o.RnhNpcTarget.ShortPrefabName : "none",
+ ["targetHealth"] = o.RnhNpcTarget != null && !o.RnhNpcTarget.IsDestroyed
+ ? $"{o.RnhNpcTarget.health:0}/{o.RnhNpcTarget.MaxHealth():0} at {Vector3.Distance(o.transform.position, o.RnhNpcTarget.transform.position):0} m"
+ : "-",
+ ["shotTests"] = o.ShotTests,
+ ["shotsMarked"] = o.ShotsMarked,
+ ["attackTicks"] = o.AttackTicks,
+ ["attackTicksWithLos"] = o.AttackTicksLos,
+ ["state"] = o.Brain?.CurrentState?.StateType.ToString() ?? "none",
+ ["held"] = o.GetActiveItem()?.info.shortname ?? "none",
+ ["lastHurtBy"] = o.LastHurtBy,
+ };
+
+ private static bool Dead(BaseEntity e) => e == null || e.IsDestroyed || (e as BaseCombatEntity)?.IsDead() == true;
+
+ // ---- 5.1 our sensing and combat state against an NPC target ----
+
+ [ConsoleCommand("rnh.npcfight")]
+ private void CmdNpcFight(ConsoleSystem.Arg arg)
+ {
+ if (!arg.IsAdmin) return;
+ Vector3 f;
+ if (!FindField(250f, 505, out f)) { Log("npcfight", "FAIL no field found"); return; }
+ Vector3 z(float m) => f + new Vector3(0f, 0f, m);
+ float secs = arg.GetFloat(0, 60f);
+
+ RnhNpc o = null, b = null, ally = null;
+ BaseCombatEntity foe = null;
+ Func> extra = () => new Dictionary
+ {
+ ["ours"] = Stats(o),
+ ["other"] = b != null ? Stats(b) : null,
+ ["ally"] = ally != null ? Stats(ally) : null,
+ ["foe"] = foe == null ? null : new Dictionary
+ {
+ ["type"] = foe.GetType().Name, ["dead"] = Dead(foe), ["health"] = foe.IsDestroyed ? 0 : Math.Round(foe.health, 0),
+ },
+ ["engagedThinks"] = RnhBrain.Engaged,
+ ["senseRuns"] = RnhBrain.SenseRuns,
+ ["leftHorror"] = _labels.Keys.Count(e => e is BaseCombatEntity c && !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror),
+ };
+ Action reset = () => { o = b = ally = null; foe = null; };
+
+ var list = new List
+ {
+ // Rust's own filter, untouched: shots at a stock scientist, and what lands.
+ new Scenario { Name = "scientist.noFaction", Seconds = 30f, Extra = extra,
+ Setup = () => { reset(); o = Ours(f, "ours", "a", false, "scientists"); foe = Stock(z(15f), "stock"); } },
+ new Scenario { Name = "scientist.horrorPerShot", Seconds = secs, Extra = extra, Done = () => Dead(foe),
+ Setup = () => { reset(); o = Ours(f, "ours", "a", true, "scientists"); foe = Stock(z(15f), "stock"); } },
+ new Scenario { Name = "ours.vs.ours", Seconds = secs, Extra = extra, Done = () => Dead(o) || Dead(b),
+ Setup = () => { reset(); o = Ours(f, "ours", "a", true, "ours:b"); b = Ours(z(15f), "other", "b", true, "ours:a"); } },
+ new Scenario { Name = "animal.boar", Seconds = secs, Extra = extra, Done = () => Dead(foe),
+ Setup = () => { reset(); o = Ours(f, "ours", "a", true, "animals"); foe = Animal("/boar.prefab", z(15f), "boar"); } },
+ new Scenario { Name = "animal.wolf2", Seconds = secs, Extra = extra, Done = () => Dead(foe) || Dead(o),
+ Setup = () => { reset(); o = Ours(f, "ours", "a", true, "animals"); foe = Animal("/wolf2.prefab", z(15f), "wolf"); } },
+ // An ally of ours standing on the line of fire: the per-shot faction marks only the target.
+ new Scenario { Name = "crossfire.allyOnTheLine", Seconds = secs, Extra = extra, Done = () => Dead(foe),
+ Setup = () => { reset(); o = Ours(f, "ours", "a", true, "scientists"); ally = Ours(z(8f), "ally", "a", false); foe = Stock(z(16f), "stock"); } },
+ };
+ string only = arg.GetString(1, "");
+ RunScenarios("npcfight", f, only == "" ? list : list.Where(s => s.Name.Contains(only)).ToList());
+ }
+
+ // ---- 5.2 what our sensing costs ----
+
+ [ConsoleCommand("rnh.sense")]
+ private void CmdSense(ConsoleSystem.Arg arg)
+ {
+ if (!arg.IsAdmin) return;
+ Vector3 field;
+ if (!FindField(400f, 606, out field)) { Log("sense", "FAIL no field found"); return; }
+ float window = arg.GetFloat(0, 30f);
+ var phases = new List>();
+ var all = new List();
+ Func grid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f));
+ Func foeGrid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, 30f + (i / 10) * 3f));
+ Action killAll = () => { foreach (BaseEntity e in all) if (e != null && !e.IsDestroyed) e.Kill(); all.Clear(); _invulnerable.Clear(); };
+
+ Func spawn = (i, tag, foeSide, hostile) =>
+ {
+ SpawnReport r = SpawnOurs(foeSide ? foeGrid(i) : grid(i), "RnhSense" + tag + i);
+ if (r.Npc == null) return null;
+ Tag(r.Npc, tag);
+ r.Npc.RnhShotFaction = true;
+ r.Npc.RnhHostile = new HashSet(hostile);
+ all.Add(r.Npc); _labels[r.Npc] = "ours." + tag; _invulnerable.Add(r.Npc);
+ return r.Npc;
+ };
+
+ var queue = new Queue>();
+ queue.Enqueue(new KeyValuePair("baseline", () => { }));
+ queue.Enqueue(new KeyValuePair("ours100.hostileToScientists.noneNear", () =>
+ {
+ for (int i = 0; i < 100; i++) spawn(i, "a", false, new[] { "scientists" });
+ }));
+ queue.Enqueue(new KeyValuePair("ours100.v.scientists100", () =>
+ {
+ for (int i = 0; i < 100; i++)
+ {
+ ScientistNPC s = SpawnStock(foeGrid(i));
+ if (s == null) continue;
+ all.Add(s); _labels[s] = "stock"; _invulnerable.Add(s);
+ }
+ }));
+ queue.Enqueue(new KeyValuePair("ours50.v.ours50", () =>
+ {
+ killAll();
+ for (int i = 0; i < 50; i++) spawn(i, "a", false, new[] { "ours:b" });
+ for (int i = 0; i < 50; i++) spawn(i, "b", true, new[] { "ours:a" });
+ }));
+ queue.Enqueue(new KeyValuePair("after.clear", killAll));
+
+ Action next = null;
+ next = () =>
+ {
+ if (queue.Count == 0)
+ {
+ Record("sense." + Framework(), new Dictionary
+ {
+ ["framework"] = Framework(), ["map"] = $"{World.Size}/{World.Seed}", ["players"] = BasePlayer.activePlayerList.Count,
+ ["aiBudgetMs"] = AIThinkManager.framebudgetms, ["windowS"] = window, ["senseEveryS"] = RnhBrain.OwnSenseEvery,
+ ["senseRangeM"] = RnhBrain.OwnSenseRange, ["phases"] = phases,
+ });
+ _labels.Clear();
+ Log("sense", "DONE");
+ return;
+ }
+ KeyValuePair step = queue.Dequeue();
+ Stopwatch sw = Stopwatch.StartNew();
+ step.Value();
+ double setupMs = sw.Elapsed.TotalMilliseconds;
+ // Five seconds to settle (spawn, first senses), then measure.
+ timer.Once(5f, () =>
+ {
+ RnhBrain.ThinkMs = 0; RnhBrain.Thinks = 0;
+ RnhBrain.OwnMs = RnhBrain.SenseMs = 0; RnhBrain.OwnThinks = RnhBrain.SenseRuns = RnhBrain.Scanned = RnhBrain.LosTests = RnhBrain.Engaged = 0;
+ _pairs.Clear();
+ foreach (BaseEntity e in all) if (e is RnhNpc o && o.Brain is RnhBrain rb) { rb.IntervalSum = 0; rb.IntervalCount = 0; }
+ _meter.Begin();
+ timer.Once(window, () =>
+ {
+ Dictionary f = Summarise(_meter.End());
+ float isum = 0f; int icount = 0;
+ foreach (BaseEntity e in all) if (e is RnhNpc o && o.Brain is RnhBrain rb) { isum += rb.IntervalSum; icount += rb.IntervalCount; }
+ f["phase"] = step.Key;
+ f["setupMs"] = Math.Round(setupMs, 1);
+ f["ours"] = all.Count(e => e is RnhNpc && !Dead(e));
+ f["stock"] = all.Count(e => e is ScientistNPC && !(e is RnhNpc) && !Dead(e));
+ f["msPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.ThinkMs / RnhBrain.Thinks, 3) : 0;
+ f["ownMsPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.OwnMs / RnhBrain.Thinks, 3) : 0;
+ f["senseRuns"] = RnhBrain.SenseRuns;
+ f["msPerSense"] = RnhBrain.SenseRuns > 0 ? Math.Round(RnhBrain.SenseMs / RnhBrain.SenseRuns, 4) : 0;
+ f["scannedPerSense"] = RnhBrain.SenseRuns > 0 ? Math.Round((double)RnhBrain.Scanned / RnhBrain.SenseRuns, 1) : 0;
+ f["losTests"] = RnhBrain.LosTests;
+ f["engagedThinks"] = RnhBrain.Engaged;
+ f["thinkIntervalS"] = icount > 0 ? Math.Round(isum / icount, 3) : 0;
+ f["aiQueue"] = AIThinkManager._processQueue.Count;
+ f["leftHorror"] = all.Count(e => e is BaseCombatEntity c && !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror);
+ f["pairs"] = Pairs();
+ phases.Add(f);
+ Log("sense", JsonConvert.SerializeObject(f));
+ next();
+ });
+ });
+ };
+ Log("sense", $"field {V(field)}, {window:0} s per phase, {queue.Count} phases; players online {BasePlayer.activePlayerList.Count}");
+ next();
+ }
+
+ // ---- 5.3 turrets and traps (D259) ----
+
+ private BaseCombatEntity Turret(string kind, Vector3 at, Quaternion rot, List notes)
+ {
+ string suffix = kind == "auto" ? "/autoturret_deployed.prefab" : kind == "flame" ? "/flameturret.deployed.prefab" : "/guntrap.deployed.prefab";
+ string path = FindPrefab(suffix);
+ if (path == null) throw new Exception("no prefab ends with " + suffix);
+ BaseCombatEntity t = GameManager.server.CreateEntity(path, at, rot) as BaseCombatEntity;
+ if (t == null) throw new Exception(path + " is not a combat entity");
+ t.enableSaving = false;
+ t.Spawn();
+ Label(t, "turret." + kind);
+
+ AutoTurret auto = t as AutoTurret;
+ if (auto != null)
+ {
+ ItemManager.CreateByName("rifle.ak").MoveToContainer(auto.inventory, 0);
+ ItemManager.CreateByName("ammo.rifle", 256).MoveToContainer(auto.inventory, 1);
+ auto.UpdateAttachedWeapon();
+ auto.UpdateTotalAmmo();
+ auto.UpdateFromInput(10, 0);
+ timer.Once(4f, () =>
+ {
+ if (auto.IsDestroyed) return;
+ notes.Add($"auto: powered={auto.IsPowered()} online={auto.IsOnline()} weapon={auto.GetAttachedWeapon()?.ShortPrefabName ?? "none"}");
+ if (!auto.IsOnline()) { auto.SetIsOnline(true); notes.Add("auto: forced online"); }
+ });
+ }
+ else if (t is FlameTurret flame)
+ ItemManager.CreateByName("lowgradefuel", 100).MoveToContainer(flame.inventory);
+ else if (t is GunTrap trap)
+ ItemManager.CreateByName("ammo.handmade.shell", 20).MoveToContainer(trap.inventory);
+ return t;
+ }
+
+ [ConsoleCommand("rnh.turrets")]
+ private void CmdTurrets(ConsoleSystem.Arg arg)
+ {
+ if (!arg.IsAdmin) return;
+ Vector3 f;
+ if (!FindField(250f, 808, out f)) { Log("turrets", "FAIL no field found"); return; }
+ float secs = arg.GetFloat(0, 30f);
+ var list = new List();
+
+ foreach (string kind in new[] { "auto", "flame", "trap" })
+ {
+ foreach (string victim in new[] { "standIn", "stock", "ours" })
+ {
+ string k = kind, v = victim;
+ BaseCombatEntity turret = null;
+ BaseEntity target = null;
+ var notes = new List();
+ list.Add(new Scenario
+ {
+ Name = k + "." + v,
+ Seconds = secs,
+ Done = () => Dead(target) || Dead(turret),
+ Setup = () =>
+ {
+ notes.Clear();
+ float d = k == "auto" ? 10f : 3f;
+ Vector3 spot = Ground(f + new Vector3(0f, 0f, d));
+ // Spawned outside the turret's trigger and moved in: a flame turret's trigger only
+ // notices what ENTERS it (a target spawned inside, standing still, was never burned).
+ Vector3 outside = Ground(f + new Vector3(0f, 0f, d + 12f));
+ turret = Turret(k, Ground(f), Quaternion.LookRotation(new Vector3(0f, 0f, 1f)), notes);
+ if (v == "stock")
+ target = Stock(outside, "stock");
+ else if (v == "standIn")
+ {
+ // The control: a stand-in player shows the turret is armed. It takes no damage.
+ target = Label(SpawnDummy(outside, 40), "standIn");
+ _invulnerable.Add(target);
+ }
+ else
+ {
+ RnhNpc o = Ours(outside, "ours", "a", false);
+ o.RnhShootsBack = true;
+ target = o;
+ }
+ BaseEntity moving = target;
+ timer.Once(2f, () =>
+ {
+ if (Dead(moving)) return;
+ BasePlayer p = moving as BasePlayer;
+ if (p is HumanNPC h && h.Brain?.Navigator?.Agent != null)
+ h.Brain.Navigator.Agent.Warp(spot);
+ p?.Teleport(spot);
+ notes.Add($"moved in: at {V(moving.transform.position)}, spot {V(spot)}");
+ });
+ },
+ Extra = () => new Dictionary
+ {
+ ["turret"] = turret == null ? null : new Dictionary
+ {
+ ["type"] = turret.GetType().Name, ["dead"] = Dead(turret),
+ ["health"] = turret.IsDestroyed ? 0 : Math.Round(turret.health, 0),
+ },
+ ["target"] = target is RnhNpc o ? Stats(o) : new Dictionary
+ {
+ ["dead"] = Dead(target), ["health"] = target == null || target.IsDestroyed ? 0 : Math.Round(((BaseCombatEntity)target).health, 0),
+ },
+ ["notes"] = new List(notes),
+ },
+ });
+ }
+ }
+ string only = arg.GetString(1, "");
+ RunScenarios("turrets", f, only == "" ? list : list.Where(s => s.Name.Contains(only)).ToList());
+ }
+
+ // ---- 5.4 the kit items Rust's AI uses on its own (D260) ----
+
+ private static Dictionary Inventory(BasePlayer p)
+ {
+ var counts = new Dictionary();
+ if (p == null || p.IsDestroyed || p.inventory == null) return counts;
+ foreach (ItemContainer c in new[] { p.inventory.containerBelt, p.inventory.containerMain })
+ {
+ if (c == null) continue;
+ foreach (Item i in c.itemList)
+ {
+ object n;
+ counts[i.info.shortname] = (counts.TryGetValue(i.info.shortname, out n) ? (int)n : 0) + i.amount;
+ }
+ }
+ return counts;
+ }
+
+ private static void Give(BasePlayer p, string shortname, int amount, ItemContainer into, int slot = -1)
+ {
+ Item item = ItemManager.CreateByName(shortname, amount);
+ if (item == null) return;
+ if (!item.MoveToContainer(into, slot)) item.Remove();
+ }
+
+ [ConsoleCommand("rnh.items")]
+ private void CmdItems(ConsoleSystem.Arg arg)
+ {
+ if (!arg.IsAdmin) return;
+ Vector3 f;
+ if (!FindField(250f, 909, out f)) { Log("items", "FAIL no field found"); return; }
+ float secs = arg.GetFloat(0, 60f);
+ Vector3 z(float m) => f + new Vector3(0f, 0f, m);
+
+ RnhNpc o = null;
+ BasePlayer standIn = null;
+ Dictionary before = null;
+ float lastHealth = 0f;
+ int heals = 0, scripted = 0;
+ float minHealth = float.MaxValue;
+ Timer pulse = null;
+
+ Action addStandIn = (dist, index) =>
+ {
+ standIn = Label(SpawnDummy(Ground(z(dist)), index), "standIn");
+ _invulnerable.Add(standIn);
+ timer.Once(1f, () =>
+ {
+ if (o?.Brain?.Senses?.Memory != null && standIn != null && !standIn.IsDestroyed)
+ o.Brain.Senses.Memory.SetKnown(standIn, o, null);
+ });
+ };
+ Action> spawn = kit =>
+ {
+ o = Ours(f, "ours", "a", false);
+ kit(o);
+ o.EquipWeapon();
+ before = Inventory(o);
+ lastHealth = o.health; minHealth = o.health; heals = 0; scripted = 0;
+ pulse?.Destroy();
+ pulse = timer.Every(1f, () =>
+ {
+ if (Dead(o)) return;
+ if (o.health > lastHealth + 5f) heals++;
+ lastHealth = o.health;
+ minHealth = Mathf.Min(minHealth, o.health);
+ });
+ };
+ Func> extra = () =>
+ {
+ pulse?.Destroy();
+ return new Dictionary
+ {
+ ["ours"] = Stats(o),
+ ["before"] = before,
+ ["after"] = Inventory(o),
+ ["healthRises"] = heals,
+ ["minHealth"] = Math.Round(minHealth, 0),
+ ["scriptedHurts"] = scripted,
+ ["canUseHealingItems"] = o?.Brain != null && o.Brain.CanUseHealingItems,
+ };
+ };
+
+ var list = new List
+ {
+ // The kit's revolver plus every extra D260 names; the stand-in "shoots" ours every 4 s.
+ new Scenario { Name = "extras.healingOn", Seconds = secs, Extra = extra,
+ Setup = () =>
+ {
+ spawn(n =>
+ {
+ Give(n, "syringe.medical", 3, n.inventory.containerBelt, 1);
+ Give(n, "grenade.f1", 2, n.inventory.containerBelt, 2);
+ Give(n, "machete", 1, n.inventory.containerBelt, 3);
+ n.Brain.CanUseHealingItems = true;
+ });
+ addStandIn(15f, 31);
+ timer.Repeat(4f, (int)(secs / 4f), () =>
+ {
+ if (Dead(o) || Dead(standIn) || o.health < 45f) return;
+ AttackEntity gun = standIn.GetActiveItem()?.GetHeldEntity() as AttackEntity;
+ o.Hurt(new HitInfo(standIn, o, Rust.DamageType.Bullet, 20f) { Weapon = gun, WeaponPrefab = gun });
+ scripted++;
+ });
+ } },
+ new Scenario { Name = "main.rocketLauncher", Seconds = Mathf.Min(secs, 40f), Extra = extra,
+ Setup = () =>
+ {
+ spawn(n =>
+ {
+ n.inventory.Strip();
+ Give(n, "rocket.launcher", 1, n.inventory.containerBelt, 0);
+ Give(n, "ammo.rocket.basic", 6, n.inventory.containerMain);
+ });
+ addStandIn(25f, 32);
+ } },
+ new Scenario { Name = "main.flamethrower", Seconds = Mathf.Min(secs, 40f), Extra = extra,
+ Setup = () =>
+ {
+ spawn(n =>
+ {
+ n.inventory.Strip();
+ Give(n, "flamethrower", 1, n.inventory.containerBelt, 0);
+ Give(n, "lowgradefuel", 300, n.inventory.containerMain);
+ });
+ addStandIn(6f, 33);
+ } },
+ };
+ RunScenarios("items", f, list);
+ }
+
+ // ---- 5.5 TruePVE and NextGenPVE (D261) ----
+
+ [ConsoleCommand("rnh.pve")]
+ private void CmdPve(ConsoleSystem.Arg arg)
+ {
+ if (!arg.IsAdmin) return;
+ Vector3 f;
+ if (!FindField(250f, 1001, out f)) { Log("pve", "FAIL no field found"); return; }
+ _labels.Clear(); _pairs.Clear(); _invulnerable.Clear();
+
+ Plugin pve = plugins.Find("TruePVE") ?? plugins.Find("NextGenPVE");
+ string which = pve == null ? "none" : $"{pve.Name} {pve.Version}";
+
+ RnhNpc ours = Ours(f, "ours", "a", false);
+ ScientistNPC stock = Stock(f + new Vector3(4f, 0f, 0f), "stock");
+ Func pvePlayer = (id, at, label) =>
+ {
+ BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, Ground(at)) as BasePlayer;
+ if (p == null) return null;
+ p.userID = id;
+ p.UserIDString = id.ToString();
+ p.displayName = "RnhPve" + label;
+ p.enableSaving = false;
+ p.Spawn();
+ p.EndSleeping();
+ LifeStoryStartMethod?.Invoke(p, null);
+ Label(p, label);
+ Equip(p);
+ return p;
+ };
+ BasePlayer player = pvePlayer(PveUserId, f + new Vector3(-4f, 0f, 0f), "player");
+ BasePlayer player2 = pvePlayer(PveUserId + 1UL, f + new Vector3(-4f, 0f, 4f), "player2");
+ if (player == null || player2 == null) { Log("pve", "FAIL no player"); return; }
+
+ timer.Once(3f, () =>
+ {
+ var pairs = new List>
+ {
+ // The control: player against player is what a PVE plugin exists to stop.
+ new KeyValuePair(player2, player),
+ new KeyValuePair(player, ours),
+ new KeyValuePair(ours, player),
+ new KeyValuePair(player, stock),
+ new KeyValuePair(stock, player),
+ new KeyValuePair(ours, stock),
+ new KeyValuePair(stock, ours),
+ };
+ var rows = new List>();
+ foreach (int mode in new[] { 0, 1, 2 })
+ {
+ _pveMode = mode;
+ foreach (KeyValuePair pr in pairs)
+ {
+ BaseCombatEntity a = pr.Key, v = pr.Value;
+ if (Dead(a) || Dead(v)) { rows.Add(new Dictionary { ["pair"] = _labels[a] + ">" + _labels[v], ["error"] = "dead" }); continue; }
+ v.health = v.MaxHealth();
+ AttackEntity gun = (a as BasePlayer)?.GetActiveItem()?.GetHeldEntity() as AttackEntity;
+ int asks0 = _pveAsks;
+ float h0 = v.health;
+ v.Hurt(new HitInfo(a, v, Rust.DamageType.Bullet, 10f) { Weapon = gun, WeaponPrefab = gun });
+ rows.Add(new Dictionary
+ {
+ ["mode"] = mode == 0 ? "observe" : mode == 1 ? "allow" : "deny",
+ ["pair"] = _labels[a] + ">" + _labels[v],
+ ["damage"] = Math.Round(h0 - v.health, 2),
+ ["asked"] = _pveAsks - asks0,
+ });
+ }
+ }
+ _pveMode = 0;
+ var rec = new Dictionary { ["framework"] = Framework(), ["plugin"] = which, ["rows"] = rows };
+ Record("pve." + (pve == null ? "none" : pve.Name) + "." + Framework(), rec);
+ Log("pve", JsonConvert.SerializeObject(rec));
+ foreach (BaseEntity e in _labels.Keys.ToArray()) if (e != null && !e.IsDestroyed) e.Kill();
+ _labels.Clear();
+ ClearLeftovers(f, 40f);
+ });
+ }
+
// ---- housekeeping ----
[ConsoleCommand("rnh.clear")]