Files
runicnpc-rust/tools/RunicNpcHarness.cs
wtclaude 18c90961ba
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 6s
chore(tools): the stage 1 harness, RunicNpcHarness.cs
A developer plugin, never shipped, that measured the six questions stage 2
depends on (docs/runicnpc/PLAN.md §9, stage 1) on both rigs: the subclass
swap, saving across a hard kill, what Rust's brain honours, navmesh coverage,
the cost of 1/10/100 idle and fighting, and the death screen and bridge frames.

It spawns stand-in players (player.prefab, a made-up user id, IsNpc false) as
targets, at the org lead's suggestion. It needs a hidden Kits kit, rnhrevolver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 23:04:46 -05:00

1102 lines
54 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>
///
/// 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.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";
/// <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>();
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 ----
/// <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;
}
}
/// <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));
}
}
ThinkMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency;
Thinks++;
}
}
// ---- 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";
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<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"]);
}
});
});
}
// ---- 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");
}
}
}