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(); + 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 + { + ["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 + { + ["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 row in rows.Cast>()) + 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>(); + var ours = new List(); + var stock = new List(); + var standIns = new List(); + int stockInWorld = BaseNetworkable.serverEntities.OfType().Count(); + + Func grid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f)); + Func foeGrid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, 45f + (i / 10) * 3f)); + + var queue = new Queue>(); + + // 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 + { + ["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 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 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> 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("baseline", () => { })); + queue.Enqueue(new KeyValuePair("spawn1", () => spawnTo(1, null))); + queue.Enqueue(new KeyValuePair("spawn10", () => spawnTo(10, null))); + queue.Enqueue(new KeyValuePair("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("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("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("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); + + /// + /// A stand-in player: player.prefab with a made-up user id, so IsNpc 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. + /// + 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(); + + 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(_deaths); + rec["corpses"] = new List(_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().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().ToArray()) { o.Kill(); loose++; } + Log("clear", $"killed {n} tracked and {loose} untracked harness NPCs"); + } + } +}