Files
runicnpc-rust/tools/RunicNpcHarness.cs
wtclaude 9424dca6d1
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m28s
test(harness): stage 5 spike, NPC targets, turrets, kit items, PVE (D231, D253)
RunicNpcHarness 0.2.0 adds the measurements stage 5 opens with:

- rnh.npcfight: our own sensing and combat state against a stock
  scientist, another of ours, a boar and a wolf, and with an ally on
  the line of fire. Rust's BaseProjectile.ServerUse drops every hit on
  an NPC from an NPC's gun unless the victim's faction is Horror; the
  prototype marks only its current target Horror for the length of
  each of its own shots.
- rnh.sense: what that sensing costs, 100 against 100 scientists and
  50 against 50 of ours.
- rnh.turrets: auto turret, flame turret and shotgun trap against a
  stand-in player, a stock scientist and ours (D259).
- rnh.items: which kit items Rust's AI uses by itself (D260).
- rnh.pve: what TruePVE or NextGenPVE does with our NPC, and with an
  answer to CanEntityTakeDamage (D261).

Never shipped: a developer tool in tools/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 00:23:47 -05:00

2064 lines
102 KiB
C#

// Requires: Kits
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
using UnityEngine.AI;
namespace Oxide.Plugins
{
/// <summary>
/// RunicNPC's stage 1 harness (docs/runicnpc/PLAN.md §9). NEVER SHIPPED: it lives in tools/,
/// is loaded on a rig by hand, and answers the six questions stage 2 is built on:
///
/// <list type="number">
/// <item>the subclass swap spawns, walks, fights and dies cleanly (<c>rnh.swap</c>);</item>
/// <item><c>enableSaving = false</c> survives a hard kill and restart (<c>rnh.save plant|check</c>);</item>
/// <item>which ranges and scales hold on Rust's brain as it is (<c>rnh.tune</c>);</item>
/// <item>navmesh coverage at every monument and on a roof (<c>rnh.nav</c>);</item>
/// <item>what 1, 10 and 100 cost, idle and fighting (<c>rnh.cost</c>);</item>
/// <item>the name on the death screen, and what the bridge publishes (<c>rnh.dummy</c>).</item>
/// </list>
///
/// And stage 5's spike (D231, D253): our own sensing and combat state against NPC targets
/// (<c>rnh.npcfight</c>), its cost (<c>rnh.sense</c>), turrets and traps (<c>rnh.turrets</c>,
/// D259), the kit items Rust's AI uses by itself (<c>rnh.items</c>, D260), and TruePVE or
/// NextGenPVE, whichever is loaded (<c>rnh.pve</c>, D261).
///
/// Every result is a line starting <c>[rnh]</c> in the console and a record in the data file
/// <c>RunicNpcHarness.json</c>, read back over the panel. Nothing here is RunicNPC's design:
/// it is the measurement that design waits for.
///
/// <para>
/// It needs a hidden Kits kit named <c>rnhrevolver</c> (a revolver, pistol ammo and burlap):
/// add it to the rig's <c>Kits/kits_data.json</c> and reload Kits before loading this, and
/// restore the file afterwards. <c>ready</c> in the console says whether it was found.
/// </para>
///
/// <para>
/// Its NPC is swapped the way stage 2's will be: the prefab's <c>ScientistNPC</c> and
/// <c>ScientistBrain</c> components are replaced, before the entity is spawned, by our own
/// subclasses carrying the prefab's serialised fields. Written from Rust's own classes; no
/// other plugin's code.
/// </para>
/// </summary>
[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;
private const string ScientistPrefab = "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab";
private const string PlayerPrefab = "assets/prefabs/player/player.prefab";
private const string Kit = "rnhrevolver";
/// <summary>The fake player's user id: outside the Steam range, over Rust's bot range (10,000,000).</summary>
private const ulong DummyUserId = 11400000001UL;
private static RunicNpcHarness _self;
private readonly Dictionary<string, object> _results = new Dictionary<string, object>();
private readonly HashSet<BaseEntity> _spawned = new HashSet<BaseEntity>();
private GameObject _meterHost;
private FrameMeter _meter;
// ---- counters the hooks feed ----
private sealed class HitTally
{
public int Hits;
public float Damage;
public readonly Dictionary<string, int> Weapons = new Dictionary<string, int>();
}
private readonly HitTally _hitsByOurs = new HitTally();
private readonly HitTally _hitsOnOurs = new HitTally();
private readonly HitTally _hitsOnStandIns = new HitTally();
/// <summary>While set, stand-in players take no damage: the cost run's targets never die.</summary>
private bool _standInsInvulnerable;
private static bool IsStandIn(BaseEntity e) =>
e is BasePlayer p && !p.IsNpc && (ulong)p.userID >= DummyUserId && (ulong)p.userID < DummyUserId + 1000UL;
private readonly List<string> _deaths = new List<string>();
private readonly List<string> _corpses = new List<string>();
// ---- lifecycle ----
private void Init() => _self = this;
private void OnServerInitialized()
{
_meterHost = new GameObject("RunicNpcHarness.FrameMeter");
_meter = _meterHost.AddComponent<FrameMeter>();
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()}");
}
private void Unload()
{
foreach (BaseEntity e in _spawned.ToArray())
if (e != null && !e.IsDestroyed)
e.Kill();
_spawned.Clear();
if (_meterHost != null)
UnityEngine.Object.Destroy(_meterHost);
Scientists.Clear();
Animals.Clear();
OursByTag.Clear();
_self = null;
}
private static string Framework() =>
AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide";
// ---- output ----
private void Log(string test, string line) => Puts($"[rnh] {test}: {line}");
private void Record(string test, object value)
{
_results[test] = value;
Interface.Oxide.DataFileSystem.WriteObject(Name, _results);
}
// ---- the swap ----
/// <summary>
/// Copies the fields Unity itself would copy between two instances of a component: every
/// instance field of <paramref name="from"/>'s type chain down to <see cref="MonoBehaviour"/>
/// that is public and not [NonSerialized], or private with [SerializeField]. That is the
/// prefab's authored data. Runtime state (delegates, caches, anything [NonSerialized]) is
/// left at the new component's own defaults, so nothing bound to the destroyed instance is
/// carried across.
/// </summary>
private static int CopyPrefabFields(Component from, Component to)
{
int copied = 0;
for (Type t = from.GetType(); t != null && t != typeof(MonoBehaviour); t = t.BaseType)
{
foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (f.IsInitOnly || f.IsLiteral)
continue;
bool serialised = (f.IsPublic && !f.IsNotSerialized) || f.IsDefined(typeof(SerializeField), false);
if (!serialised || typeof(Delegate).IsAssignableFrom(f.FieldType))
continue;
f.SetValue(to, f.GetValue(from));
copied++;
}
}
return copied;
}
private sealed class SpawnReport
{
public RnhNpc Npc;
public int NpcFields;
public int BrainFields;
public double Ms;
public string Error;
public string Kit;
}
/// <summary>Our NPC: the prefab's scientist, its components swapped before it spawns.</summary>
private SpawnReport SpawnOurs(Vector3 at, string name, Action<RnhBrain> tune = null, bool save = false)
{
var report = new SpawnReport();
Stopwatch sw = Stopwatch.StartNew();
BaseEntity made = GameManager.server.CreateEntity(ScientistPrefab, at, Quaternion.identity, false);
ScientistNPC stock = made as ScientistNPC;
if (stock == null)
{
report.Error = "prefab did not make a ScientistNPC";
if (made != null) UnityEngine.Object.Destroy(made.gameObject);
return report;
}
ScientistBrain stockBrain = stock.GetComponent<ScientistBrain>();
GameObject go = stock.gameObject;
RnhNpc npc = go.AddComponent<RnhNpc>();
RnhBrain brain = go.AddComponent<RnhBrain>();
report.NpcFields = CopyPrefabFields(stock, npc);
report.BrainFields = stockBrain != null ? CopyPrefabFields(stockBrain, brain) : -1;
UnityEngine.Object.DestroyImmediate(stock, true);
if (stockBrain != null)
UnityEngine.Object.DestroyImmediate(stockBrain, true);
npc.RnhName = name;
npc.enableSaving = save;
tune?.Invoke(brain);
// CreateEntity(..., startActive: false) leaves the GameObject inactive and skips exactly this
// call, which GameManager.CreatePrefab makes when active is true. Without it the entity
// still spawns and thinks, but Unity never starts the brain: no design, no navigator, no state.
go.AwakeFromInstantiate();
npc.Spawn();
report.Ms = sw.Elapsed.TotalMilliseconds;
report.Npc = npc;
_spawned.Add(npc);
report.Kit = Equip(npc);
return report;
}
/// <summary>
/// Senses NPC players too, does not wait for a target to be "hostile", and checks line of
/// sight as it senses: Rust refreshes LOS every think only for targets that are NOT NPCs
/// (AIBrainSenses.UpdateKnownPlayersLOS), and HumanNPC.TickAttack never fires without it.
/// </summary>
private static void FightsScientists(RnhBrain b)
{
b.SenseTypes |= EntityType.BasePlayerNPC;
b.HostileTargetsOnly = false;
b.CheckLOS = true;
RnhNpc npc = b.GetComponent<RnhNpc>();
if (npc != null) npc.RnhFightsNpcs = true;
}
private ScientistNPC SpawnStock(Vector3 at)
{
ScientistNPC s = GameManager.server.CreateEntity(ScientistPrefab, at) as ScientistNPC;
if (s == null) return null;
s.enableSaving = false;
s.Spawn();
_spawned.Add(s);
return s;
}
private string Equip(BasePlayer p)
{
p.inventory.Strip();
object r = Kits?.Call("GiveKit", p, Kit);
(p as NPCPlayer)?.EquipWeapon();
return r == null ? "null" : r.ToString();
}
private bool KitExists() => Kits != null && Kits.Call("IsKit", Kit) is bool b && b;
/// <summary>The scientist subclass. Only what stage 1 has to prove it can override.</summary>
public class RnhNpc : ScientistNPC, IAISenses, IAIAttack
{
public int AttackTicks, AttackTicksLos;
public string LastAttackTarget = "-";
/// <summary>Counts what Rust's AttackTick event hands us: the last step before a shot.</summary>
public new void AttackTick(float delta, BaseEntity target, bool targetIsLOS)
{
AttackTicks++;
if (targetIsLOS) AttackTicksLos++;
LastAttackTarget = target != null ? target.ShortPrefabName : "null";
base.AttackTick(delta, target, targetIsLOS);
}
public string RnhName = "Scientist";
/// <summary>
/// Rust's HumanNPC.IsTarget answers true only for non-NPC players, pets and scarecrows,
/// so a scientist never attacks another NPC. Re-implementing IAISenses is how ours can:
/// it targets stock scientists (not other RnhNpcs) when this is set.
/// </summary>
public bool RnhFightsNpcs;
public new bool IsTarget(BaseEntity entity) =>
base.IsTarget(entity) || (RnhFightsNpcs && entity is ScientistNPC && !(entity is RnhNpc));
public new bool IsThreat(BaseEntity entity) => IsTarget(entity);
public new bool IsFriendly(BaseEntity entity) => !IsTarget(entity) && base.IsFriendly(entity);
public float RnhAimCone = -1f;
public override string displayName => RnhName;
public override float GetAimConeScale() => RnhAimCone >= 0f ? RnhAimCone : base.GetAimConeScale();
/// <summary>
/// HumanNPC.AttackerInfo writes the prefab's short name as the killer on the victim's
/// death screen, after BasePlayer's displayName. Ours puts the name back.
/// </summary>
public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info)
{
base.AttackerInfo(info);
info.attackerName = displayName;
}
// ---- stage 5: our own target, beside Rust's ----
/// <summary>This NPC's "profile" for the spike: allies share a tag.</summary>
public string RnhTag = "";
/// <summary>The factions it hunts: <c>scientists</c>, <c>animals</c>, <c>ours:&lt;tag&gt;</c>. Null: none.</summary>
public HashSet<string> RnhHostile;
/// <summary>
/// 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.
/// </summary>
public bool RnhShotFaction;
/// <summary>When a turret or trap hurts it and it has no target, the turret becomes its target.</summary>
public bool RnhShootsBack;
public BaseCombatEntity RnhNpcTarget;
public int ShotTests, ShotsMarked;
public string LastHurtBy = "-";
private BaseCombatEntity _marked;
private BaseCombatEntity.Faction _markedWas;
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>A single-shot weapon fires inside ShotTest; a burst weapon fires from TriggerDown.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// The brain subclass. It adds no state of its own: stage 1 asks what Rust's own design
/// does with our values. It times its own thinking, which is what the cost warning needs
/// beside frame time, because Rust budgets human AI per frame (aithinkmanager.framebudgetms).
/// </summary>
public class RnhBrain : ScientistBrain
{
public static double ThinkMs;
public static long Thinks;
public float LastThinkAt = -1f;
public float IntervalSum;
public int IntervalCount;
public override void Think(float delta)
{
if (LastThinkAt >= 0f)
{
IntervalSum += Time.time - LastThinkAt;
IntervalCount++;
}
LastThinkAt = Time.time;
long t0 = Stopwatch.GetTimestamp();
base.Think(delta);
// Harness-only stand-in for the combat state stage 5 will write: Rust's design never
// runs its AttackTick event for an NPC target, so when this NPC is set to fight NPCs
// and knows one, face it and hand Rust's own attack routine the target and its LOS.
RnhNpc npc = GetBaseEntity() as RnhNpc;
if (npc != null && npc.RnhFightsNpcs && Senses?.Memory != null)
{
BaseEntity target = Senses.GetNearestTarget(1f);
if (target != null)
{
Navigator?.SetFacingDirectionEntity(target);
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];
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<BaseCombatEntity> 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;
}
/// <summary>
/// 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.
/// </summary>
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<BaseCombatEntity> Scientists = new HashSet<BaseCombatEntity>();
private static readonly HashSet<BaseCombatEntity> Animals = new HashSet<BaseCombatEntity>();
private static readonly Dictionary<string, HashSet<BaseCombatEntity>> OursByTag = new Dictionary<string, HashSet<BaseCombatEntity>>();
private static HashSet<BaseCombatEntity> Registry(string faction)
{
if (faction == "scientists") return Scientists;
if (faction == "animals") return Animals;
HashSet<BaseCombatEntity> 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<BaseCombatEntity> set;
if (!OursByTag.TryGetValue(tag, out set)) OursByTag[tag] = set = new HashSet<BaseCombatEntity>();
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<BaseCombatEntity> set;
if (o != null && OursByTag.TryGetValue(o.RnhTag, out set)) set.Remove(o);
}
// ---- frame meter ----
public class FrameMeter : MonoBehaviour
{
private readonly List<float> _frames = new List<float>(20000);
public bool Recording;
public void Begin()
{
_frames.Clear();
Recording = true;
}
public List<float> End()
{
Recording = false;
return new List<float>(_frames);
}
private void Update()
{
if (Recording)
_frames.Add(Time.unscaledDeltaTime * 1000f);
}
}
private static Dictionary<string, object> Summarise(List<float> ms)
{
if (ms.Count == 0)
return new Dictionary<string, object> { ["frames"] = 0 };
ms.Sort();
Func<double, double> pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)];
double mean = ms.Average();
return new Dictionary<string, object>
{
["frames"] = ms.Count,
["meanMs"] = Math.Round(mean, 2),
["fps"] = Math.Round(1000.0 / mean, 1),
["p50Ms"] = Math.Round(pct(0.50), 2),
["p95Ms"] = Math.Round(pct(0.95), 2),
["p99Ms"] = Math.Round(pct(0.99), 2),
["maxMs"] = Math.Round(ms[ms.Count - 1], 2),
};
}
// ---- places ----
/// <summary>
/// A flat, dry spot on the navmesh at least <paramref name="clear"/> m from every
/// monument, found by sampling the map from a fixed seed so a rerun lands in the same field.
/// </summary>
private bool FindField(float clear, int seed, out Vector3 at)
{
var rng = new System.Random(seed);
float half = World.Size / 2f - 300f;
List<MonumentInfo> monuments = TerrainMeta.Path.Monuments;
int wet = 0, steep = 0, nearMonument = 0, offMesh = 0, offAnyMesh = 0;
for (int i = 0; i < 4000; i++)
{
var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half);
p.y = TerrainMeta.HeightMap.GetHeight(p);
if (p.y < TerrainMeta.WaterMap.GetHeight(p) + 1f) { wet++; continue; }
if (TerrainMeta.HeightMap.GetSlope(p) > 15f) { steep++; continue; }
if (monuments.Any(m => Vector3.Distance(m.transform.position, p) < clear)) { nearMonument++; continue; }
NavMeshHit hit;
if (!OnNavmesh(p, 2f, out hit))
{
offMesh++;
if (!NavMesh.SamplePosition(p, out hit, 2f, NavMesh.AllAreas)) offAnyMesh++;
continue;
}
at = hit.position;
return true;
}
_self?.Log("field", $"none: wet={wet} steep={steep} nearMonument={nearMonument} offRustNavmesh={offMesh} offUnityNavmesh={offAnyMesh} rustNavmeshBuilt={NavmeshBuilt()}");
at = Vector3.zero;
return false;
}
/// <summary>
/// Rust's own navmesh, the one a scientist walks: its navigator is a Gen2
/// <c>RustNavMeshAgent</c>, not Unity's NavMeshAgent, and Unity's NavMesh queries see none of
/// the open world. This is the helper Rust's navigator samples with, and it answers false
/// until the default navmesh is built (minutes after a map's first boot).
/// </summary>
private static bool OnNavmesh(Vector3 p, float radius, out NavMeshHit hit) =>
Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, radius, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas);
private static bool NavmeshBuilt() =>
Rust.Ai.Gen2.Nav.RustNavigation.Instance != null && Rust.Ai.Gen2.Nav.RustNavigation.Instance.IsDefaultNavmeshBuilt();
private static Vector3 Ring(Vector3 centre, int i, int n, float r) =>
centre + new Vector3(Mathf.Cos(i * 2f * Mathf.PI / n) * r, 0f, Mathf.Sin(i * 2f * Mathf.PI / n) * r);
private static readonly FieldInfo StatesField = typeof(BaseAIBrain).GetField("states", BindingFlags.Instance | BindingFlags.NonPublic);
private static readonly FieldInfo SleepingField = typeof(BaseAIBrain).GetField("sleeping", BindingFlags.Instance | BindingFlags.NonPublic);
private static readonly FieldInfo DisabledField = typeof(BaseAIBrain).GetField("disabled", BindingFlags.Instance | BindingFlags.NonPublic);
/// <summary>A brain's insides: what it takes for a scientist to have a state at all.</summary>
private static string Diag(HumanNPC n)
{
if (n == null || n.IsDestroyed) return "gone";
ScientistBrain b = n.Brain;
if (b == null) return $"{n.GetType().Name} Brain=null comps={string.Join(",", n.GetComponents<BaseAIBrain>().Select(c => c.GetType().Name).ToArray())}";
var states = StatesField?.GetValue(b) as System.Collections.IDictionary;
return $"{n.GetType().Name}/{b.GetType().Name} enabled={b.enabled} state={b.CurrentState?.StateType} states={(states == null ? "null" : states.Count.ToString())} " +
$"useDesign={b.UseAIDesign} design={(b.AIDesign != null)} designs={b.Designs?.Count} defaultSO={(b.DefaultDesignSO != null)} think={b.ThinkMode} " +
$"sleeping={SleepingField?.GetValue(b)} disabled={DisabledField?.GetValue(b)} nav={(b.Navigator != null)} agent={(n.NavAgent != null)} " +
$"onMesh={(n.NavAgent != null && n.NavAgent.isOnNavMesh)} pathFinder={(b.PathFinder != null)} senses={b.Senses?.Players?.Count} " +
$"targets={b.Senses?.Memory?.Targets?.Count} threats={b.Senses?.Memory?.Threats?.Count} " +
$"slot={(b.Events != null ? b.Events.Memory.Entity.Get(b.Events.CurrentInputMemorySlot)?.ShortPrefabName : "-")} " +
(n is RnhNpc r ? $"attackTicks={r.AttackTicks} withLos={r.AttackTicksLos} lastTarget={r.LastAttackTarget} " : "") +
$"pos={V(n.transform.position)}";
}
private static Vector3 Ground(Vector3 p)
{
p.y = TerrainMeta.HeightMap.GetHeight(p);
return p;
}
private static string V(Vector3 p) => $"{p.x:0},{p.y:0.#},{p.z:0}";
// ---- hooks (all void: none of them may change what the game does) ----
private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info)
{
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++;
if (_standInsInvulnerable) info.damageTypes.ScaleAll(0f);
}
HitTally tally = info.Initiator is RnhNpc ? _hitsByOurs : victim is RnhNpc ? _hitsOnOurs : null;
if (tally == null) return;
tally.Hits++;
tally.Damage += info.damageTypes.Total();
int n;
tally.Weapons.TryGetValue(weapon, out n);
tally.Weapons[weapon] = n + 1;
}
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);
/// <summary>
/// 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.
/// </summary>
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;
_corpses.Add($"{corpse.GetType().Name} playerName='{corpse.playerName}' steamId={corpse.playerSteamID}");
}
// ---- 1. the swap ----
[ConsoleCommand("rnh.swap")]
private void CmdSwap(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin) return;
Vector3 field;
if (!FindField(250f, 101, out field)) { Log("swap", "FAIL no field found"); return; }
_hitsByOurs.Hits = _hitsOnOurs.Hits = 0;
_hitsByOurs.Weapons.Clear(); _hitsOnOurs.Weapons.Clear();
_deaths.Clear(); _corpses.Clear();
SpawnReport r = SpawnOurs(field, "RnhWarden", FightsScientists);
if (r.Npc == null) { Log("swap", "FAIL " + r.Error); return; }
RnhNpc npc = r.Npc;
Vector3 start = npc.transform.position;
var rec = new Dictionary<string, object>
{
["at"] = V(start),
["spawnMs"] = Math.Round(r.Ms, 2),
["npcFieldsCopied"] = r.NpcFields,
["brainFieldsCopied"] = r.BrainFields,
["type"] = npc.GetType().Name,
["brainType"] = npc.Brain != null ? npc.Brain.GetType().Name : "null",
["isNpc"] = npc.IsNpc,
["displayName"] = npc.displayName,
["shortPrefab"] = npc.ShortPrefabName,
["enableSaving"] = npc.enableSaving,
["navigator"] = npc.Brain?.Navigator != null,
["kitResult"] = r.Kit,
["belt0"] = npc.inventory.containerBelt.GetSlot(0)?.info.shortname ?? "none",
["held"] = npc.GetHeldEntity() != null ? npc.GetHeldEntity().ShortPrefabName : "none",
};
Log("swap", string.Join(" ", rec.Select(kv => kv.Key + "=" + kv.Value).ToArray()));
var samples = new List<string>();
float maxMoved = 0f;
int t = 0;
ScientistNPC foe = null;
timer.Repeat(5f, 18, () =>
{
t += 5;
if (t == 30 && npc != null && !npc.IsDestroyed && !npc.IsDead())
foe = SpawnStock(Ground(start + new Vector3(8f, 0f, 0f)));
if (npc == null || npc.IsDestroyed) { samples.Add($"+{t}s gone"); return; }
float moved = Vector3.Distance(start, npc.transform.position);
maxMoved = Mathf.Max(maxMoved, moved);
string s = $"+{t}s state={npc.Brain?.CurrentState?.StateType} moved={moved:0.0} hp={npc.health:0} " +
$"thinking={((npc.Brain as RnhBrain)?.LastThinkAt > Time.time - 2f)} foe={(foe != null && !foe.IsDestroyed ? foe.health.ToString("0") : "-")}";
samples.Add(s);
Log("swap", s);
if (t == 5 || t == 35 || t == 60)
{
Log("swap", " ours " + Diag(npc));
if (foe != null) Log("swap", " stock " + Diag(foe));
}
});
timer.Once(92f, () =>
{
bool alive = npc != null && !npc.IsDestroyed && !npc.IsDead();
if (alive)
npc.Hurt(1000f, Rust.DamageType.Bullet, foe != null && !foe.IsDestroyed ? foe : null, false);
timer.Once(3f, () =>
{
rec["samples"] = samples;
rec["walked"] = maxMoved > 3f;
rec["maxMovedM"] = Math.Round(maxMoved, 1);
rec["hitsByOurs"] = _hitsByOurs.Hits;
rec["hitsByOursWeapons"] = new Dictionary<string, int>(_hitsByOurs.Weapons);
rec["hitsOnOurs"] = _hitsOnOurs.Hits;
rec["killedByHarness"] = alive;
rec["deaths"] = new List<string>(_deaths);
rec["corpses"] = new List<string>(_corpses);
rec["destroyedAfterDeath"] = npc == null || npc.IsDestroyed;
Record("swap", rec);
Log("swap", $"DONE walked={rec["walked"]} hitsByOurs={_hitsByOurs.Hits} weapons={string.Join(",", _hitsByOurs.Weapons.Select(kv => kv.Key + ":" + kv.Value).ToArray())} " +
$"hitsOnOurs={_hitsOnOurs.Hits} deaths={_deaths.Count} corpses={string.Join(" | ", _corpses.ToArray())} destroyed={rec["destroyedAfterDeath"]}");
if (foe != null && !foe.IsDestroyed) foe.Kill();
});
});
}
// ---- 2. saving ----
private sealed class Planted
{
public ulong NetId;
public float X, Y, Z;
public bool Saved;
}
[ConsoleCommand("rnh.save")]
private void CmdSave(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin) return;
string mode = arg.GetString(0);
if (mode == "plant")
{
Vector3 field;
if (!FindField(250f, 202, out field)) { Log("save", "FAIL no field found"); return; }
var planted = new List<Planted>();
for (int i = 0; i < 6; i++)
{
// Five never saved, one saved: the control that proves the check can see a saved NPC.
bool saved = i == 5;
SpawnReport r = SpawnOurs(Ground(field + new Vector3(i * 6f, 0f, 0f)), "RnhSave" + i, null, saved);
if (r.Npc == null) continue;
_spawned.Remove(r.Npc); // Unload must not kill them: the restart is the test.
planted.Add(new Planted { NetId = r.Npc.net.ID.Value, Saved = saved });
}
timer.Once(3f, () =>
{
foreach (Planted p in planted)
{
BaseNetworkable e = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId));
if (e == null) continue;
p.X = e.transform.position.x; p.Y = e.transform.position.y; p.Z = e.transform.position.z;
}
Interface.Oxide.DataFileSystem.WriteObject(Name + "_planted", planted);
ConsoleSystem.Run(ConsoleSystem.Option.Server, "server.save");
Log("save", $"planted {planted.Count} ({planted.Count(p => p.Saved)} saved control) at {V(field)} and ran server.save; now hard-kill the server");
});
return;
}
if (mode == "check")
{
List<Planted> planted = Interface.Oxide.DataFileSystem.ReadObject<List<Planted>>(Name + "_planted") ?? new List<Planted>();
int backUnsaved = 0, backSaved = 0;
var found = new List<string>();
foreach (Planted p in planted)
{
// By net id: Rust restores a saved entity under the id it had. (A distance match was
// tried first and matched the saved control from its neighbour 6 m away.)
BaseNetworkable hit = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId));
BaseNetworkable byId = hit;
if (hit == null) continue;
if (p.Saved) backSaved++; else backUnsaved++;
found.Add($"{p.NetId} saved={p.Saved} -> {hit.GetType().Name} '{(hit as BasePlayer)?.displayName}' byId={byId != null}");
}
var rec = new Dictionary<string, object>
{
["planted"] = planted.Count,
["unsavedBack"] = backUnsaved,
["savedControlBack"] = backSaved,
["found"] = found,
["pass"] = backUnsaved == 0 && backSaved == planted.Count(p => p.Saved),
};
Record("save." + Framework(), rec);
Log("save", $"{(true.Equals(rec["pass"]) ? "PASS" : "FAIL")} unsaved back={backUnsaved}/{planted.Count(p => !p.Saved)} saved control back={backSaved}/{planted.Count(p => p.Saved)} :: {string.Join(" | ", found.ToArray())}");
foreach (Planted p in planted)
{
BaseNetworkable e = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId));
if (e != null) e.Kill();
}
return;
}
Log("save", "usage: rnh.save plant|check");
}
// ---- 3. tuning ----
/// <summary>
/// Which of a profile's numbers Rust's own brain honours. Each is set either BEFORE the brain
/// starts (the values <c>Senses.Init</c> copies) or AFTER it is running, and then observed.
/// </summary>
[ConsoleCommand("rnh.tune")]
private void CmdTune(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin) return;
Vector3 field;
if (!FindField(250f, 303, out field)) { Log("tune", "FAIL no field found"); return; }
var rec = new Dictionary<string, object>();
// Health and name and aim cone: the NPC's own values.
SpawnReport h = SpawnOurs(field, "RnhTune", null);
RnhNpc n = h.Npc;
n.startHealth = 400f;
n.InitializeHealth(400f, 400f);
n.RnhAimCone = 0.5f;
rec["health.set400"] = $"{n.health:0}/{n.MaxHealth():0}";
rec["name"] = n.displayName;
rec["aimCone.override0.5"] = n.GetAimConeScale();
n.Kill();
// Sense range: a stock scientist 35 m away. Default SenseRange on the prefab vs 50 set before start vs after.
Vector3 foeAt = Ground(field + new Vector3(35f, 0f, 0f));
ScientistNPC target = SpawnStock(foeAt);
target.Brain.SetEnabled(false);
SpawnReport dflt = SpawnOurs(Ground(field + new Vector3(0f, 0f, 4f)), "RnhDefault", b => b.SenseTypes |= EntityType.BasePlayerNPC);
SpawnReport before = SpawnOurs(Ground(field + new Vector3(0f, 0f, -4f)), "RnhBefore", b =>
{
b.SenseTypes |= EntityType.BasePlayerNPC;
b.SenseRange = 50f;
b.TargetLostRange = 60f;
});
SpawnReport after = SpawnOurs(Ground(field + new Vector3(0f, 0f, -8f)), "RnhAfter", b => b.SenseTypes |= EntityType.BasePlayerNPC);
rec["prefab.SenseRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.SenseRange : -1f;
rec["prefab.TargetLostRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.TargetLostRange : -1f;
rec["prefab.AttackRangeMultiplier"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.AttackRangeMultiplier : -1f;
rec["prefab.VisionCone"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.VisionCone : -1f;
rec["prefab.HostileTargetsOnly"] = dflt.Npc.Brain != null && dflt.Npc.Brain.HostileTargetsOnly;
rec["prefab.CheckLOS"] = dflt.Npc.Brain != null && dflt.Npc.Brain.CheckLOS;
rec["prefab.CheckVisionCone"] = dflt.Npc.Brain != null && dflt.Npc.Brain.CheckVisionCone;
rec["prefab.ListenRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.ListenRange : -1f;
rec["prefab.MemoryDuration"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.MemoryDuration : -1f;
rec["prefab.SenseTypes"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.SenseTypes.ToString() : "-";
rec["prefab.MaxRoamDistanceFromHome"] = dflt.Npc.Brain?.Navigator != null ? dflt.Npc.Brain.Navigator.MaxRoamDistanceFromHome : -1f;
rec["prefab.aimConeScale"] = dflt.Npc.aimConeScale;
rec["prefab.startHealth"] = dflt.Npc.startHealth;
timer.Once(3f, () =>
{
if (after.Npc?.Brain != null)
{
after.Npc.Brain.SenseRange = 50f;
after.Npc.Brain.TargetLostRange = 60f;
}
});
timer.Once(10f, () =>
{
Func<SpawnReport, string> sees = s =>
s.Npc == null || s.Npc.IsDestroyed ? "gone" :
$"knows={s.Npc.Brain.Senses.Players.Contains(target)} state={s.Npc.Brain.CurrentState?.StateType}";
rec["senseRange.default@35m"] = sees(dflt);
rec["senseRange.50.beforeStart@35m"] = sees(before);
rec["senseRange.50.afterStart@35m"] = sees(after);
// Roam: where does a roamer go with no AI zone vs its home range?
rec["roam.aiZoneHere"] = AIInformationZone.GetForPoint(field, false) != null;
Record("tune", rec);
Log("tune", string.Join(" ", rec.Select(kv => kv.Key + "=" + kv.Value).ToArray()));
foreach (SpawnReport s in new[] { dflt, before, after })
if (s.Npc != null && !s.Npc.IsDestroyed) s.Npc.Kill();
if (target != null && !target.IsDestroyed) target.Kill();
});
}
/// <summary>Roaming at a monument (inside an AI zone) vs in a field (outside one), 60 s each.</summary>
[ConsoleCommand("rnh.roam")]
private void CmdRoam(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin) return;
Vector3 field;
if (!FindField(250f, 404, out field)) { Log("roam", "FAIL no field found"); return; }
AIInformationZone zone = AIInformationZone.zones.FirstOrDefault(z => z != null && z.GetComponentInParent<MonumentInfo>() != null);
Vector3 monument = Vector3.zero;
NavMeshHit mh;
if (zone != null && OnNavmesh(zone.transform.position, 20f, out mh)) monument = mh.position;
SpawnReport inField = SpawnOurs(field, "RnhField", null);
SpawnReport inZone = monument != Vector3.zero ? SpawnOurs(monument, "RnhZone", null) : null;
Vector3 f0 = field, z0 = monument;
float fMax = 0f, zMax = 0f;
var states = new List<string>();
timer.Repeat(5f, 12, () =>
{
if (inField.Npc != null && !inField.Npc.IsDestroyed) fMax = Mathf.Max(fMax, Vector3.Distance(f0, inField.Npc.transform.position));
if (inZone?.Npc != null && !inZone.Npc.IsDestroyed) zMax = Mathf.Max(zMax, Vector3.Distance(z0, inZone.Npc.transform.position));
states.Add($"field={inField.Npc?.Brain?.CurrentState?.StateType} zone={inZone?.Npc?.Brain?.CurrentState?.StateType}");
});
timer.Once(62f, () =>
{
var rec = new Dictionary<string, object>
{
["field.maxMovedM"] = Math.Round(fMax, 1),
["zone"] = zone != null ? zone.GetComponentInParent<MonumentInfo>()?.name : "none",
["zone.maxMovedM"] = Math.Round(zMax, 1),
["states"] = states,
};
Record("roam", rec);
Log("roam", $"field moved {fMax:0.0} m, zone ({rec["zone"]}) moved {zMax:0.0} m; last {states.LastOrDefault()}");
inField.Npc?.Kill();
inZone?.Npc?.Kill();
});
}
// ---- 4. navmesh ----
[ConsoleCommand("rnh.nav")]
private void CmdNav(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin) return;
int mask = LayerMask.GetMask("Terrain", "World", "Construction", "Default", "Deployed");
var rows = new List<object>();
int points = 0, onMesh = 0, roofs = 0, roofsOnMesh = 0;
Stopwatch sw = Stopwatch.StartNew();
long checkTicks = 0;
foreach (MonumentInfo m in TerrainMeta.Path.Monuments)
{
if (m == null) continue;
Vector3 c = m.transform.position;
float r = Mathf.Clamp(Mathf.Min(m.Bounds.extents.x, m.Bounds.extents.z) * 0.5f, 5f, 40f);
int p = 0, on = 0, rf = 0, rfOn = 0;
for (int i = 0; i < 9; i++)
{
Vector3 probe = i == 0 ? c : Ring(c, i - 1, 8, r);
RaycastHit hit;
if (!Physics.Raycast(new Vector3(probe.x, 1000f, probe.z), Vector3.down, out hit, 1100f, mask)) continue;
p++;
bool roof = hit.point.y > TerrainMeta.HeightMap.GetHeight(hit.point) + 2.5f;
long t0 = Stopwatch.GetTimestamp();
NavMeshHit nh;
bool ok = OnNavmesh(hit.point, 1f, out nh) && Mathf.Abs(nh.position.y - hit.point.y) < 0.6f;
checkTicks += Stopwatch.GetTimestamp() - t0;
if (ok) on++;
if (roof) { rf++; if (ok) rfOn++; }
}
points += p; onMesh += on; roofs += rf; roofsOnMesh += rfOn;
rows.Add(new Dictionary<string, object>
{
["monument"] = System.IO.Path.GetFileNameWithoutExtension(m.name),
["points"] = p, ["onMesh"] = on, ["elevated"] = rf, ["elevatedOnMesh"] = rfOn,
});
}
// A player-built roof: a floor held 4 m up on the field, sampled on top, then removed.
Vector3 field;
string built = "no field";
if (FindField(250f, 505, out field))
{
BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", field + Vector3.up * 4f);
if (floor != null)
{
floor.enableSaving = false;
floor.Spawn();
RaycastHit hit;
bool gotTop = Physics.Raycast(field + Vector3.up * 20f, Vector3.down, out hit, 30f, mask);
NavMeshHit nh;
bool ok = gotTop && OnNavmesh(hit.point, 1f, out nh) && Mathf.Abs(nh.position.y - hit.point.y) < 0.6f;
built = $"top={(gotTop ? hit.point.y - field.y : -1f):0.0}m onMesh={ok}";
floor.Kill();
}
}
double checkUs = points > 0 ? checkTicks * 1e6 / Stopwatch.Frequency / points : 0;
var rec = new Dictionary<string, object>
{
["monuments"] = rows.Count, ["points"] = points, ["onMesh"] = onMesh,
["elevated"] = roofs, ["elevatedOnMesh"] = roofsOnMesh,
["builtFloor4m"] = built,
["checkMicrosEach"] = Math.Round(checkUs, 1),
["scanMs"] = sw.ElapsedMilliseconds,
["rows"] = rows,
};
Record("nav." + Framework(), rec);
Log("nav", $"{rows.Count} monuments, {onMesh}/{points} points on mesh, elevated {roofsOnMesh}/{roofs}, built floor {built}, check {checkUs:0.0} us each, scan {sw.ElapsedMilliseconds} ms");
foreach (Dictionary<string, object> row in rows.Cast<Dictionary<string, object>>())
Log("nav", $" {row["monument"]}: {row["onMesh"]}/{row["points"]} elevated {row["elevatedOnMesh"]}/{row["elevated"]}");
}
// ---- 5. cost ----
[ConsoleCommand("rnh.cost")]
private void CmdCost(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin) return;
Vector3 field;
if (!FindField(400f, 606, out field)) { Log("cost", "FAIL no field found"); return; }
float window = arg.GetFloat(0, 30f);
var phases = new List<Dictionary<string, object>>();
var ours = new List<RnhNpc>();
var stock = new List<ScientistNPC>();
var standIns = new List<BasePlayer>();
int stockInWorld = BaseNetworkable.serverEntities.OfType<ScientistNPC>().Count();
Func<int, Vector3> grid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f));
Func<int, Vector3> foeGrid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, 45f + (i / 10) * 3f));
var queue = new Queue<KeyValuePair<string, Action>>();
// Each phase: run its setup (timed), then record frames for `window` seconds.
Action next = null;
next = () =>
{
if (queue.Count == 0)
{
foreach (RnhNpc o in ours) if (o != null && !o.IsDestroyed) o.Kill();
foreach (ScientistNPC s in stock) if (s != null && !s.IsDestroyed) s.Kill();
foreach (BasePlayer d in standIns) if (d != null && !d.IsDestroyed) d.Kill();
_standInsInvulnerable = false;
var rec = new Dictionary<string, object>
{
["framework"] = Framework(),
["map"] = $"{World.Size}/{World.Seed}",
["players"] = BasePlayer.activePlayerList.Count,
["stockScientistsInWorld"] = stockInWorld,
["aiBudgetMs"] = AIThinkManager.framebudgetms,
["windowS"] = window,
["phases"] = phases,
};
Record("cost." + Framework(), rec);
Log("cost", "DONE");
return;
}
KeyValuePair<string, Action> step = queue.Dequeue();
Stopwatch sw = Stopwatch.StartNew();
step.Value();
double setupMs = sw.Elapsed.TotalMilliseconds;
RnhBrain.ThinkMs = 0; RnhBrain.Thinks = 0;
foreach (RnhNpc o in ours)
{
if (o?.Brain is RnhBrain rb) { rb.IntervalSum = 0; rb.IntervalCount = 0; }
if (o != null) { o.AttackTicks = 0; o.AttackTicksLos = 0; }
}
_hitsByOurs.Hits = 0; _hitsOnOurs.Hits = 0; _hitsOnStandIns.Hits = 0;
long gc0 = GC.GetTotalMemory(false);
_meter.Begin();
timer.Once(window, () =>
{
Dictionary<string, object> f = Summarise(_meter.End());
float isum = 0f; int icount = 0;
foreach (RnhNpc o in ours) if (o?.Brain is RnhBrain rb) { isum += rb.IntervalSum; icount += rb.IntervalCount; }
f["phase"] = step.Key;
f["setupMs"] = Math.Round(setupMs, 1);
f["ours"] = ours.Count(o => o != null && !o.IsDestroyed && !o.IsDead());
f["stock"] = stock.Count(s => s != null && !s.IsDestroyed && !s.IsDead());
f["thinkMsPerSecond"] = Math.Round(RnhBrain.ThinkMs / window, 2);
f["thinksPerSecond"] = Math.Round(RnhBrain.Thinks / window, 1);
f["msPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.ThinkMs / RnhBrain.Thinks, 3) : 0;
f["thinkIntervalS"] = icount > 0 ? Math.Round(isum / icount, 3) : 0;
f["aiQueue"] = AIThinkManager._processQueue.Count;
f["hitsByOurs"] = _hitsByOurs.Hits;
f["hitsOnOurs"] = _hitsOnOurs.Hits;
f["hitsOnStandIns"] = _hitsOnStandIns.Hits;
f["attackTicksWithLos"] = ours.Where(o => o != null && !o.IsDestroyed).Sum(o => o.AttackTicksLos);
f["gcDeltaMB"] = Math.Round((GC.GetTotalMemory(false) - gc0) / 1048576.0, 1);
phases.Add(f);
Log("cost", string.Join(" ", f.Select(kv => kv.Key + "=" + kv.Value).ToArray()));
next();
});
};
// Rust's default senses (players only) unless a phase asks for NPC fighting: sensing NPCs with
// line of sight is a cost of its own, measured in its own phase.
Action<int, Action<RnhBrain>> spawnTo = (n, tune) =>
{
for (int i = ours.Count; i < n; i++)
{
SpawnReport r = SpawnOurs(grid(i), "RnhCost" + i, tune);
if (r.Npc != null) ours.Add(r.Npc);
}
};
queue.Enqueue(new KeyValuePair<string, Action>("baseline", () => { }));
queue.Enqueue(new KeyValuePair<string, Action>("spawn1", () => spawnTo(1, null)));
queue.Enqueue(new KeyValuePair<string, Action>("spawn10", () => spawnTo(10, null)));
queue.Enqueue(new KeyValuePair<string, Action>("spawn100.idle", () => spawnTo(100, null)));
// The org lead's second load: real gunfire. Rust's AI only shoots non-NPC players, so the
// targets are 20 stand-ins (IsNpc false) in a row 12 m in front of the grid; the harness
// zeroes the damage they take, so none die and the bridge records no deaths.
queue.Enqueue(new KeyValuePair<string, Action>("fight100vStandIns", () =>
{
_standInsInvulnerable = true;
for (int i = 1; i <= 20; i++)
{
BasePlayer d = SpawnDummy(Ground(field + new Vector3((i - 1) * 1.5f, 0f, -12f)), i);
if (d != null) standIns.Add(d);
}
}));
// The org lead's first load: a fresh 100 set to fight NPCs (sensing NPCs, LOS checks, the
// harness attack stand-in) beside 100 stock scientists. Rust's design never fires at an NPC,
// so this measures NPCs in combat without gunfire.
queue.Enqueue(new KeyValuePair<string, Action>("fight100vScientists", () =>
{
foreach (RnhNpc o in ours) if (o != null && !o.IsDestroyed) o.Kill();
foreach (BasePlayer d in standIns) if (d != null && !d.IsDestroyed) d.Kill();
_standInsInvulnerable = false;
ours.Clear(); standIns.Clear();
spawnTo(100, FightsScientists);
for (int i = 0; i < 100; i++)
{
ScientistNPC s = SpawnStock(foeGrid(i));
if (s != null) stock.Add(s);
}
}));
queue.Enqueue(new KeyValuePair<string, Action>("after.clear", () =>
{
foreach (RnhNpc o in ours) if (o != null && !o.IsDestroyed) o.Kill();
foreach (ScientistNPC s in stock) if (s != null && !s.IsDestroyed) s.Kill();
ours.Clear(); stock.Clear();
}));
Log("cost", $"field {V(field)}, {window:0} s per phase, {queue.Count} phases; players online {BasePlayer.activePlayerList.Count}");
next();
}
// ---- 6. the name on the death screen, and the bridge ----
private static readonly MethodInfo LifeStoryStartMethod =
typeof(BasePlayer).GetMethod("LifeStoryStart", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
/// <summary>
/// A stand-in player: <c>player.prefab</c> with a made-up user id, so <c>IsNpc</c> is false
/// and the bridge reports it as it would a real player (PLAN.md §1.2 found that by
/// accident with HumanNPC). It never connects. Only the harness ever makes one.
/// </summary>
private BasePlayer SpawnDummy(Vector3 at, int index = 0)
{
BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, at) as BasePlayer;
if (p == null) return null;
ulong id = DummyUserId + (ulong)index;
p.userID = id;
p.UserIDString = id.ToString();
p.displayName = index == 0 ? "RnhDummy" : "RnhStandIn" + index;
p.enableSaving = false;
p.Spawn();
p.EndSleeping();
LifeStoryStartMethod?.Invoke(p, null);
_spawned.Add(p);
Equip(p);
return p;
}
[ConsoleCommand("rnh.dummy")]
private void CmdDummy(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin) return;
Vector3 field;
if (!FindField(250f, 707, out field)) { Log("dummy", "FAIL no field found"); return; }
_deaths.Clear(); _corpses.Clear();
var rec = new Dictionary<string, object>();
BasePlayer dummy = SpawnDummy(field);
if (dummy == null) { Log("dummy", "FAIL no dummy"); return; }
rec["dummy"] = $"isNpc={dummy.IsNpc} isBot={dummy.IsBot} sleeping={dummy.IsSleeping()} lifeStory={dummy.lifeStory != null} held={dummy.GetActiveItem()?.info.shortname}";
Log("dummy", "spawned " + rec["dummy"]);
// (a) The stand-in kills one of ours, with the kit's revolver as the weapon on the hit.
SpawnReport victim = SpawnOurs(Ground(field + new Vector3(6f, 0f, 0f)), "RnhWarden", null);
timer.Once(2f, () =>
{
Item gunItem = dummy.inventory.containerBelt.GetSlot(0);
AttackEntity gun = gunItem?.GetHeldEntity() as AttackEntity;
var hit = new HitInfo(dummy, victim.Npc, Rust.DamageType.Bullet, 1000f) { Weapon = gun, WeaponPrefab = gun };
victim.Npc.Hurt(hit);
rec["playerKillsOurs"] = $"weapon={(gun != null ? gun.ShortPrefabName : "none")} dead={victim.Npc == null || victim.Npc.IsDestroyed || victim.Npc.IsDead()}";
Log("dummy", "(a) " + rec["playerKillsOurs"]);
});
// (b) One of ours kills the stand-in, fighting with its own kit.
timer.Once(6f, () =>
{
SpawnReport killer = SpawnOurs(Ground(field + new Vector3(-14f, 0f, 0f)), "RnhWarden", b => b.HostileTargetsOnly = false);
timer.Once(1f, () =>
{
if (killer.Npc?.Brain?.Senses?.Memory != null && dummy != null && !dummy.IsDestroyed)
killer.Npc.Brain.Senses.Memory.SetKnown(dummy, killer.Npc, null);
});
int waited = 0;
timer.Repeat(2f, 45, () =>
{
waited += 2;
if (rec.ContainsKey("deathScreen")) return;
if (dummy == null || dummy.IsDestroyed || dummy.IsDead())
{
ProtoBuf.PlayerLifeStory story = dummy?.previousLifeStory ?? dummy?.lifeStory;
ProtoBuf.PlayerLifeStory.DeathInfo d = story?.deathInfo;
rec["deathScreen"] = d == null ? "no death info" :
$"attackerName='{d.attackerName}' attackerSteamID={d.attackerSteamID} inflictorName='{d.inflictorName}' hitBone='{d.hitBone}' distance={d.attackerDistance:0.0}";
rec["ourKillerStillNamed"] = killer.Npc != null ? killer.Npc.displayName : "gone";
rec["secondsToKill"] = waited;
rec["deaths"] = new List<string>(_deaths);
rec["corpses"] = new List<string>(_corpses);
Record("dummy." + Framework(), rec);
Log("dummy", "(b) " + rec["deathScreen"] + " after " + waited + " s; deaths: " + string.Join(" | ", _deaths.ToArray()) + "; corpses: " + string.Join(" | ", _corpses.ToArray()));
timer.Once(5f, () =>
{
if (killer.Npc != null && !killer.Npc.IsDestroyed) killer.Npc.Kill();
if (dummy != null && !dummy.IsDestroyed) dummy.Kill();
foreach (PlayerCorpse c in BaseNetworkable.serverEntities.OfType<PlayerCorpse>().Where(c => c.playerSteamID == DummyUserId || c.playerName == "RnhWarden").ToArray())
c.Kill();
});
}
else if (waited >= 90)
{
rec["deathScreen"] = $"FAIL the stand-in was not killed in 90 s (hp {dummy.health:0}, killer state {killer.Npc?.Brain?.CurrentState?.StateType})";
Record("dummy." + Framework(), rec);
Log("dummy", (string)rec["deathScreen"]);
}
});
});
}
// ==== stage 5 (PLAN.md §9, stage 5; D231, D253-D262) ====
/// <summary>
/// The PVE stand-in's user id. TruePVE and NextGenPVE treat only Steam ids as players
/// (<c>IsSteamId</c>), so this one is in the Steam range, and far beyond any id Steam has issued.
/// </summary>
private const ulong PveUserId = 76570997960265728UL;
/// <summary>0 observe (answer nothing), 1 allow (D261), 2 deny (D261's per-profile opt-out).</summary>
private int _pveMode;
private int _pveAsks;
/// <summary>Labelled entities: every hit to or from one is tallied by "attacker>victim".</summary>
private readonly Dictionary<BaseEntity, string> _labels = new Dictionary<BaseEntity, string>();
private readonly Dictionary<string, HitTally> _pairs = new Dictionary<string, HitTally>();
private readonly Dictionary<string, float> _diedAt = new Dictionary<string, float>();
private readonly HashSet<BaseEntity> _invulnerable = new HashSet<BaseEntity>();
private float _scenarioStart;
private T Label<T>(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<string, object> Pairs() => _pairs.ToDictionary(kv => kv.Key, kv => (object)new Dictionary<string, object>
{
["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<bool> Done;
public Func<Dictionary<string, object>> Extra;
}
/// <summary>
/// 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.
/// </summary>
private void RunScenarios(string test, Vector3 field, List<Scenario> list, Dictionary<string, object> header = null)
{
var results = new List<Dictionary<string, object>>();
int index = 0;
Action next = null;
next = () =>
{
if (index >= list.Count)
{
var rec = header ?? new Dictionary<string, object>();
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<string, object>
{
["scenario"] = sc.Name,
["seconds"] = elapsed,
["pairs"] = Pairs(),
["diedAt"] = new Dictionary<string, float>(_diedAt),
};
if (error != null) r["error"] = error;
if (sc.Extra != null)
{
try { foreach (KeyValuePair<string, object> 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();
}
/// <summary>Corpses and dropped backpacks near the field: a fight leaves both.</summary>
private static void ClearLeftovers(Vector3 field, float radius)
{
foreach (BaseEntity e in BaseNetworkable.serverEntities.OfType<BaseEntity>()
.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<string>(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<string, object> Stats(RnhNpc o) => o == null || o.IsDestroyed
? new Dictionary<string, object> { ["gone"] = true }
: new Dictionary<string, object>
{
["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<Dictionary<string, object>> extra = () => new Dictionary<string, object>
{
["ours"] = Stats(o),
["other"] = b != null ? Stats(b) : null,
["ally"] = ally != null ? Stats(ally) : null,
["foe"] = foe == null ? null : new Dictionary<string, object>
{
["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<Scenario>
{
// 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<Dictionary<string, object>>();
var all = new List<BaseEntity>();
Func<int, Vector3> grid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f));
Func<int, Vector3> 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<int, string, bool, string[], RnhNpc> 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<string>(hostile);
all.Add(r.Npc); _labels[r.Npc] = "ours." + tag; _invulnerable.Add(r.Npc);
return r.Npc;
};
var queue = new Queue<KeyValuePair<string, Action>>();
queue.Enqueue(new KeyValuePair<string, Action>("baseline", () => { }));
queue.Enqueue(new KeyValuePair<string, Action>("ours100.hostileToScientists.noneNear", () =>
{
for (int i = 0; i < 100; i++) spawn(i, "a", false, new[] { "scientists" });
}));
queue.Enqueue(new KeyValuePair<string, Action>("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<string, Action>("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<string, Action>("after.clear", killAll));
Action next = null;
next = () =>
{
if (queue.Count == 0)
{
Record("sense." + Framework(), new Dictionary<string, object>
{
["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<string, Action> 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<string, object> 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<string> 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<Scenario>();
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<string>();
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<string, object>
{
["turret"] = turret == null ? null : new Dictionary<string, object>
{
["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<string, object>
{
["dead"] = Dead(target), ["health"] = target == null || target.IsDestroyed ? 0 : Math.Round(((BaseCombatEntity)target).health, 0),
},
["notes"] = new List<string>(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<string, object> Inventory(BasePlayer p)
{
var counts = new Dictionary<string, object>();
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<string, object> before = null;
float lastHealth = 0f;
int heals = 0, scripted = 0;
float minHealth = float.MaxValue;
Timer pulse = null;
Action<float, int> 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<Action<RnhNpc>> 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<Dictionary<string, object>> extra = () =>
{
pulse?.Destroy();
return new Dictionary<string, object>
{
["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<Scenario>
{
// 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<ulong, Vector3, string, BasePlayer> 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<KeyValuePair<BaseCombatEntity, BaseCombatEntity>>
{
// The control: player against player is what a PVE plugin exists to stop.
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(player2, player),
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(player, ours),
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(ours, player),
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(player, stock),
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(stock, player),
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(ours, stock),
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(stock, ours),
};
var rows = new List<Dictionary<string, object>>();
foreach (int mode in new[] { 0, 1, 2 })
{
_pveMode = mode;
foreach (KeyValuePair<BaseCombatEntity, BaseCombatEntity> pr in pairs)
{
BaseCombatEntity a = pr.Key, v = pr.Value;
if (Dead(a) || Dead(v)) { rows.Add(new Dictionary<string, object> { ["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<string, object>
{
["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<string, object> { ["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")]
private void CmdClear(ConsoleSystem.Arg arg)
{
if (!arg.IsAdmin) return;
int n = 0;
foreach (BaseEntity e in _spawned.ToArray())
if (e != null && !e.IsDestroyed) { e.Kill(); n++; }
_spawned.Clear();
int loose = 0;
foreach (RnhNpc o in BaseNetworkable.serverEntities.OfType<RnhNpc>().ToArray()) { o.Kill(); loose++; }
Log("clear", $"killed {n} tracked and {loose} untracked harness NPCs");
}
}
}