// 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
{
///
/// 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:
///
///
/// the subclass swap spawns, walks, fights and dies cleanly (rnh.swap);
/// enableSaving = false survives a hard kill and restart (rnh.save plant|check);
/// which ranges and scales hold on Rust's brain as it is (rnh.tune);
/// navmesh coverage at every monument and on a roof (rnh.nav);
/// what 1, 10 and 100 cost, idle and fighting (rnh.cost);
/// 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.
///
///
/// It needs a hidden Kits kit named rnhrevolver (a revolver, pistol ammo and burlap):
/// add it to the rig's Kits/kits_data.json and reload Kits before loading this, and
/// restore the file afterwards. ready in the console says whether it was found.
///
///
///
/// Its NPC is swapped the way stage 2's will be: the prefab's ScientistNPC and
/// ScientistBrain 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.
///
///
[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";
/// The fake player's user id: outside the Steam range, over Rust's bot range (10,000,000).
private const ulong DummyUserId = 11400000001UL;
private static RunicNpcHarness _self;
private readonly Dictionary _results = new Dictionary();
private readonly HashSet _spawned = new HashSet();
private GameObject _meterHost;
private FrameMeter _meter;
// ---- counters the hooks feed ----
private sealed class HitTally
{
public int Hits;
public float Damage;
public readonly Dictionary Weapons = new Dictionary();
}
private readonly HitTally _hitsByOurs = new HitTally();
private readonly HitTally _hitsOnOurs = new HitTally();
private readonly HitTally _hitsOnStandIns = new HitTally();
/// While set, stand-in players take no damage: the cost run's targets never die.
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 _deaths = new List();
private readonly List _corpses = new List();
// ---- lifecycle ----
private void Init() => _self = this;
private void OnServerInitialized()
{
_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()}");
}
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 ----
///
/// Copies the fields Unity itself would copy between two instances of a component: every
/// instance field of 's type chain down to
/// 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.
///
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;
}
/// Our NPC: the prefab's scientist, its components swapped before it spawns.
private SpawnReport SpawnOurs(Vector3 at, string name, Action 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();
GameObject go = stock.gameObject;
RnhNpc npc = go.AddComponent();
RnhBrain brain = go.AddComponent();
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;
}
///
/// 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.
///
private static void FightsScientists(RnhBrain b)
{
b.SenseTypes |= EntityType.BasePlayerNPC;
b.HostileTargetsOnly = false;
b.CheckLOS = true;
RnhNpc npc = b.GetComponent();
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;
/// The scientist subclass. Only what stage 1 has to prove it can override.
public class RnhNpc : ScientistNPC, IAISenses, IAIAttack
{
public int AttackTicks, AttackTicksLos;
public string LastAttackTarget = "-";
/// Counts what Rust's AttackTick event hands us: the last step before a shot.
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";
///
/// 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.
///
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();
///
/// 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.
///
public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info)
{
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;
}
}
///
/// 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).
///
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];
///
/// 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 ----
public class FrameMeter : MonoBehaviour
{
private readonly List _frames = new List(20000);
public bool Recording;
public void Begin()
{
_frames.Clear();
Recording = true;
}
public List End()
{
Recording = false;
return new List(_frames);
}
private void Update()
{
if (Recording)
_frames.Add(Time.unscaledDeltaTime * 1000f);
}
}
private static Dictionary Summarise(List ms)
{
if (ms.Count == 0)
return new Dictionary { ["frames"] = 0 };
ms.Sort();
Func pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)];
double mean = ms.Average();
return new Dictionary
{
["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 ----
///
/// A flat, dry spot on the navmesh at least m from every
/// monument, found by sampling the map from a fixed seed so a rerun lands in the same field.
///
private bool FindField(float clear, int seed, out Vector3 at)
{
var rng = new System.Random(seed);
float half = World.Size / 2f - 300f;
List 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;
}
///
/// Rust's own navmesh, the one a scientist walks: its navigator is a Gen2
/// RustNavMeshAgent, 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).
///
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);
/// A brain's insides: what it takes for a scientist to have a state at all.
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().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);
///
/// 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;
_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
{
["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();
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(_hitsByOurs.Weapons);
rec["hitsOnOurs"] = _hitsOnOurs.Hits;
rec["killedByHarness"] = alive;
rec["deaths"] = new List(_deaths);
rec["corpses"] = new List(_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();
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 = Interface.Oxide.DataFileSystem.ReadObject>(Name + "_planted") ?? new List();
int backUnsaved = 0, backSaved = 0;
var found = new List();
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
{
["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 ----
///
/// Which of a profile's numbers Rust's own brain honours. Each is set either BEFORE the brain
/// starts (the values Senses.Init copies) or AFTER it is running, and then observed.
///
[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();
// 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 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();
});
}
/// Roaming at a monument (inside an AI zone) vs in a field (outside one), 60 s each.
[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() != 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();
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
{
["field.maxMovedM"] = Math.Round(fMax, 1),
["zone"] = zone != null ? zone.GetComponentInParent()?.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