diff --git a/README.md b/README.md
index 6b505de..1e522c3 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ changes shape, not on every release.
| `plugin/RunicNPC.cs` | The plugin. The only file a server gets. |
| `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. |
| `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). |
-| `tools/` | Developer scaffolding for the test rigs, never shipped (see `tools/rigs.example.json`). |
+| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`) and `RunicNpcHarness.cs`, the stage 1 measurement plugin (`docs/runicnpc/PLAN.md` §9). |
## Releases
diff --git a/tools/RunicNpcHarness.cs b/tools/RunicNpcHarness.cs
new file mode 100644
index 0000000..8066790
--- /dev/null
+++ b/tools/RunicNpcHarness.cs
@@ -0,0 +1,1101 @@
+// 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).
+ ///
+ ///
+ /// 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.1.0")]
+ [Description("RunicNPC stage 1 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();
+ 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);
+ _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;
+ }
+ }
+
+ ///
+ /// 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));
+ }
+ }
+ ThinkMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency;
+ Thinks++;
+ }
+ }
+
+ // ---- 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";
+ 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)
+ {
+ 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 OnEntitySpawned(BaseEntity 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