// Requires: Kits using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; using System.Reflection; using System.Text; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Plugins; using UnityEngine; using UnityEngine.AI; namespace Oxide.Plugins { /// /// RunicNPC's stage 1 harness (docs/runicnpc/PLAN.md §9). NEVER SHIPPED: it lives in tools/, /// is loaded on a rig by hand, and answers the six questions stage 2 is built on: /// /// /// the subclass swap spawns, walks, fights and dies cleanly (rnh.swap); /// enableSaving = false survives a hard kill and restart (rnh.save plant|check); /// which ranges and scales hold on Rust's brain as it is (rnh.tune); /// navmesh coverage at every monument and on a roof (rnh.nav); /// what 1, 10 and 100 cost, idle and fighting (rnh.cost); /// the name on the death screen, and what the bridge publishes (rnh.dummy). /// /// /// And stage 5's spike (D231, D253): our own sensing and combat state against NPC targets /// (rnh.npcfight), its cost (rnh.sense), turrets and traps (rnh.turrets, /// D259), the kit items Rust's AI uses by itself (rnh.items, D260), and TruePVE or /// NextGenPVE, whichever is loaded (rnh.pve, D261). /// /// Every result is a line starting [rnh] in the console and a record in the data file /// RunicNpcHarness.json, read back over the panel. Nothing here is RunicNPC's design: /// it is the measurement that design waits for. /// /// /// It needs a hidden Kits kit named rnhrevolver (a revolver, pistol ammo and burlap): /// add it to the rig's Kits/kits_data.json and reload Kits before loading this, and /// restore the file afterwards. ready in the console says whether it was found. /// /// /// /// Its NPC is swapped the way stage 2's will be: the prefab's ScientistNPC and /// ScientistBrain components are replaced, before the entity is spawned, by our own /// subclasses carrying the prefab's serialised fields. Written from Rust's own classes; no /// other plugin's code. /// /// [Info("RunicNpcHarness", "Runic Gateway", "0.2.0")] [Description("RunicNPC stage 1 and stage 5 measurements. A developer tool: never ship it.")] internal class RunicNpcHarness : RustPlugin { [PluginReference] private Plugin Kits; private const string ScientistPrefab = "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab"; private const string PlayerPrefab = "assets/prefabs/player/player.prefab"; private const string Kit = "rnhrevolver"; /// The fake player's user id: outside the Steam range, over Rust's bot range (10,000,000). private const ulong DummyUserId = 11400000001UL; private static RunicNpcHarness _self; private readonly Dictionary _results = new Dictionary(); private readonly HashSet _spawned = new HashSet(); private GameObject _meterHost; private FrameMeter _meter; // ---- counters the hooks feed ---- private sealed class HitTally { public int Hits; public float Damage; public readonly Dictionary Weapons = new Dictionary(); } private readonly HitTally _hitsByOurs = new HitTally(); private readonly HitTally _hitsOnOurs = new HitTally(); private readonly HitTally _hitsOnStandIns = new HitTally(); /// While set, stand-in players take no damage: the cost run's targets never die. private bool _standInsInvulnerable; private static bool IsStandIn(BaseEntity e) => e is BasePlayer p && !p.IsNpc && (ulong)p.userID >= DummyUserId && (ulong)p.userID < DummyUserId + 1000UL; private readonly List _deaths = new List(); private readonly List _corpses = new List(); // ---- lifecycle ---- private void Init() => _self = this; private void OnServerInitialized() { _meterHost = new GameObject("RunicNpcHarness.FrameMeter"); _meter = _meterHost.AddComponent(); foreach (BaseNetworkable n in BaseNetworkable.serverEntities) Register(n as BaseEntity); Log("ready", $"registry scientists={Scientists.Count} animals={Animals.Count} pveStandIn.isSteamId={PveUserId.IsSteamId()}"); Log("ready", $"oxide-or-carbon={Framework()} fps.target={Application.targetFrameRate} " + $"ai.budget.ms={AIThinkManager.framebudgetms} map={World.Size} seed={World.Seed} navmeshBuilt={NavmeshBuilt()} " + $"kit.{Kit}={KitExists()}"); } private void Unload() { foreach (BaseEntity e in _spawned.ToArray()) if (e != null && !e.IsDestroyed) e.Kill(); _spawned.Clear(); if (_meterHost != null) UnityEngine.Object.Destroy(_meterHost); Scientists.Clear(); Animals.Clear(); OursByTag.Clear(); _self = null; } private static string Framework() => AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide"; // ---- output ---- private void Log(string test, string line) => Puts($"[rnh] {test}: {line}"); private void Record(string test, object value) { _results[test] = value; Interface.Oxide.DataFileSystem.WriteObject(Name, _results); } // ---- the swap ---- /// /// Copies the fields Unity itself would copy between two instances of a component: every /// instance field of 's type chain down to /// that is public and not [NonSerialized], or private with [SerializeField]. That is the /// prefab's authored data. Runtime state (delegates, caches, anything [NonSerialized]) is /// left at the new component's own defaults, so nothing bound to the destroyed instance is /// carried across. /// private static int CopyPrefabFields(Component from, Component to) { int copied = 0; for (Type t = from.GetType(); t != null && t != typeof(MonoBehaviour); t = t.BaseType) { foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) { if (f.IsInitOnly || f.IsLiteral) continue; bool serialised = (f.IsPublic && !f.IsNotSerialized) || f.IsDefined(typeof(SerializeField), false); if (!serialised || typeof(Delegate).IsAssignableFrom(f.FieldType)) continue; f.SetValue(to, f.GetValue(from)); copied++; } } return copied; } private sealed class SpawnReport { public RnhNpc Npc; public int NpcFields; public int BrainFields; public double Ms; public string Error; public string Kit; } /// Our NPC: the prefab's scientist, its components swapped before it spawns. private SpawnReport SpawnOurs(Vector3 at, string name, Action tune = null, bool save = false) { var report = new SpawnReport(); Stopwatch sw = Stopwatch.StartNew(); BaseEntity made = GameManager.server.CreateEntity(ScientistPrefab, at, Quaternion.identity, false); ScientistNPC stock = made as ScientistNPC; if (stock == null) { report.Error = "prefab did not make a ScientistNPC"; if (made != null) UnityEngine.Object.Destroy(made.gameObject); return report; } ScientistBrain stockBrain = stock.GetComponent(); GameObject go = stock.gameObject; RnhNpc npc = go.AddComponent(); RnhBrain brain = go.AddComponent(); report.NpcFields = CopyPrefabFields(stock, npc); report.BrainFields = stockBrain != null ? CopyPrefabFields(stockBrain, brain) : -1; UnityEngine.Object.DestroyImmediate(stock, true); if (stockBrain != null) UnityEngine.Object.DestroyImmediate(stockBrain, true); npc.RnhName = name; npc.enableSaving = save; tune?.Invoke(brain); // CreateEntity(..., startActive: false) leaves the GameObject inactive and skips exactly this // call, which GameManager.CreatePrefab makes when active is true. Without it the entity // still spawns and thinks, but Unity never starts the brain: no design, no navigator, no state. go.AwakeFromInstantiate(); npc.Spawn(); report.Ms = sw.Elapsed.TotalMilliseconds; report.Npc = npc; _spawned.Add(npc); report.Kit = Equip(npc); return report; } /// /// Senses NPC players too, does not wait for a target to be "hostile", and checks line of /// sight as it senses: Rust refreshes LOS every think only for targets that are NOT NPCs /// (AIBrainSenses.UpdateKnownPlayersLOS), and HumanNPC.TickAttack never fires without it. /// private static void FightsScientists(RnhBrain b) { b.SenseTypes |= EntityType.BasePlayerNPC; b.HostileTargetsOnly = false; b.CheckLOS = true; RnhNpc npc = b.GetComponent(); if (npc != null) npc.RnhFightsNpcs = true; } private ScientistNPC SpawnStock(Vector3 at) { ScientistNPC s = GameManager.server.CreateEntity(ScientistPrefab, at) as ScientistNPC; if (s == null) return null; s.enableSaving = false; s.Spawn(); _spawned.Add(s); return s; } private string Equip(BasePlayer p) { p.inventory.Strip(); object r = Kits?.Call("GiveKit", p, Kit); (p as NPCPlayer)?.EquipWeapon(); return r == null ? "null" : r.ToString(); } private bool KitExists() => Kits != null && Kits.Call("IsKit", Kit) is bool b && b; /// The scientist subclass. Only what stage 1 has to prove it can override. public class RnhNpc : ScientistNPC, IAISenses, IAIAttack { public int AttackTicks, AttackTicksLos; public string LastAttackTarget = "-"; /// Counts what Rust's AttackTick event hands us: the last step before a shot. public new void AttackTick(float delta, BaseEntity target, bool targetIsLOS) { AttackTicks++; if (targetIsLOS) AttackTicksLos++; LastAttackTarget = target != null ? target.ShortPrefabName : "null"; base.AttackTick(delta, target, targetIsLOS); } public string RnhName = "Scientist"; /// /// Rust's HumanNPC.IsTarget answers true only for non-NPC players, pets and scarecrows, /// so a scientist never attacks another NPC. Re-implementing IAISenses is how ours can: /// it targets stock scientists (not other RnhNpcs) when this is set. /// public bool RnhFightsNpcs; public new bool IsTarget(BaseEntity entity) => base.IsTarget(entity) || (RnhFightsNpcs && entity is ScientistNPC && !(entity is RnhNpc)); public new bool IsThreat(BaseEntity entity) => IsTarget(entity); public new bool IsFriendly(BaseEntity entity) => !IsTarget(entity) && base.IsFriendly(entity); public float RnhAimCone = -1f; public override string displayName => RnhName; public override float GetAimConeScale() => RnhAimCone >= 0f ? RnhAimCone : base.GetAimConeScale(); /// /// HumanNPC.AttackerInfo writes the prefab's short name as the killer on the victim's /// death screen, after BasePlayer's displayName. Ours puts the name back. /// public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info) { base.AttackerInfo(info); info.attackerName = displayName; } // ---- stage 5: our own target, beside Rust's ---- /// This NPC's "profile" for the spike: allies share a tag. public string RnhTag = ""; /// The factions it hunts: scientists, animals, ours:<tag>. Null: none. public HashSet RnhHostile; /// /// BaseProjectile.ServerUse drops every hit on an NPC from an NPC's gun unless the victim's /// faction is Horror. When set, the target alone is made Horror for the length of each of /// this NPC's shots, then put back, so nothing else changes for anyone else's bullets. /// public bool RnhShotFaction; /// When a turret or trap hurts it and it has no target, the turret becomes its target. public bool RnhShootsBack; public BaseCombatEntity RnhNpcTarget; public int ShotTests, ShotsMarked; public string LastHurtBy = "-"; private BaseCombatEntity _marked; private BaseCombatEntity.Faction _markedWas; /// /// Marks the target unless a mark is already in place, and says whether it did. ShotTest /// calls TriggerDown inside itself, so the two nest: an inner mark would save "Horror" as /// the faction to put back and leave the target Horror for good, after which everyone's /// bullets hit it, allies' included (the first 50-v-50 run showed exactly that). /// private bool Mark() { if (_marked != null) return false; BaseCombatEntity t = RnhNpcTarget; if (!RnhShotFaction || t == null || t.IsDestroyed || !t.IsNpc) return false; _marked = t; _markedWas = t.faction; t.faction = BaseCombatEntity.Faction.Horror; ShotsMarked++; return true; } private void Unmark(bool marked) { if (!marked) return; if (_marked != null && !_marked.IsDestroyed) _marked.faction = _markedWas; _marked = null; } /// A single-shot weapon fires inside ShotTest; a burst weapon fires from TriggerDown. public override bool ShotTest(float targetDist) { ShotTests++; bool marked = Mark(); try { return base.ShotTest(targetDist); } finally { Unmark(marked); } } public override void TriggerDown() { bool marked = Mark(); try { base.TriggerDown(); } finally { Unmark(marked); } } public override void Hurt(HitInfo info) { if (info != null) { BaseEntity parent = info.Weapon != null ? info.Weapon.GetParentEntity() : null; LastHurtBy = $"initiator={info.Initiator?.GetType().Name ?? "null"} weapon={info.Weapon?.ShortPrefabName ?? "null"} weaponParent={parent?.GetType().Name ?? "null"} type={info.damageTypes.GetMajorityDamageType()}"; BaseCombatEntity turret = TurretOf(info.Initiator) ?? TurretOf(parent); if (RnhShootsBack && turret != null && (RnhNpcTarget == null || RnhNpcTarget.IsDestroyed)) RnhNpcTarget = turret; } base.Hurt(info); } /// /// The turret behind a hit. A flame turret's damage comes from its fireballs, whose /// creator is the turret, or the fireball that spawned them: follow the chain. /// public static BaseCombatEntity TurretOf(BaseEntity e) { for (int i = 0; i < 6 && e is FireBall; i++) e = e.creatorEntity == e ? null : e.creatorEntity; return e is AutoTurret || e is FlameTurret || e is GunTrap ? (BaseCombatEntity)e : null; } } /// /// The brain subclass. It adds no state of its own: stage 1 asks what Rust's own design /// does with our values. It times its own thinking, which is what the cost warning needs /// beside frame time, because Rust budgets human AI per frame (aithinkmanager.framebudgetms). /// public class RnhBrain : ScientistBrain { public static double ThinkMs; public static long Thinks; public float LastThinkAt = -1f; public float IntervalSum; public int IntervalCount; public override void Think(float delta) { if (LastThinkAt >= 0f) { IntervalSum += Time.time - LastThinkAt; IntervalCount++; } LastThinkAt = Time.time; long t0 = Stopwatch.GetTimestamp(); base.Think(delta); // Harness-only stand-in for the combat state stage 5 will write: Rust's design never // runs its AttackTick event for an NPC target, so when this NPC is set to fight NPCs // and knows one, face it and hand Rust's own attack routine the target and its LOS. RnhNpc npc = GetBaseEntity() as RnhNpc; if (npc != null && npc.RnhFightsNpcs && Senses?.Memory != null) { BaseEntity target = Senses.GetNearestTarget(1f); if (target != null) { Navigator?.SetFacingDirectionEntity(target); npc.AttackTick(delta, target, Senses.Memory.IsLOS(target)); } } if (npc != null && Navigator != null && (npc.RnhHostile != null || npc.RnhNpcTarget != null)) { long o0 = Stopwatch.GetTimestamp(); OwnThink(npc, delta); OwnMs += (Stopwatch.GetTimestamp() - o0) * 1000.0 / Stopwatch.Frequency; OwnThinks++; } ThinkMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency; Thinks++; } // ---- stage 5: the prototype of our own sensing and combat state ---- public const float OwnSenseRange = 40f; public const float OwnLoseRange = 60f; public const float OwnSenseEvery = 1f; private const int LosBudget = 3; private const int LosMask = 1218519041; public static double OwnMs, SenseMs; public static long OwnThinks, SenseRuns, Scanned, LosTests, Engaged; private float _nextSense, _nextLos, _nextMove; private bool _los; private static readonly BaseCombatEntity[] Best = new BaseCombatEntity[LosBudget]; private static readonly float[] BestD2 = new float[LosBudget]; /// /// After Rust's own think, so Rust's design still owns every fight with a player. With no /// player to fight and a target of ours: face it (the states clear facing every think, so /// it is set again after them; the navigator applies it on its own 0.1 s tick), close in /// until it is in range and in sight, and hand Rust's own attack routine the target. /// private void OwnThink(RnhNpc npc, float delta) { float now = Time.time; BaseCombatEntity t = npc.RnhNpcTarget; if (t != null && (t.IsDestroyed || t.IsDead() || (t.transform.position - npc.transform.position).sqrMagnitude > OwnLoseRange * OwnLoseRange)) t = npc.RnhNpcTarget = null; if (npc.RnhHostile != null && now >= _nextSense && RnhNpc.TurretOf(t) == null) { _nextSense = now + OwnSenseEvery; long s0 = Stopwatch.GetTimestamp(); BaseCombatEntity found = SenseHostile(npc); SenseMs += (Stopwatch.GetTimestamp() - s0) * 1000.0 / Stopwatch.Frequency; SenseRuns++; if (found != null) t = npc.RnhNpcTarget = found; } if (t == null || FightingAPlayer()) return; Engaged++; if (now >= _nextLos) { _nextLos = now + 0.5f; _los = Los(npc, t); LosTests++; } Navigator.SetFacingDirectionEntity(t); float dist = Vector3.Distance(npc.transform.position, t.transform.position); if (!_los || dist > npc.EngagementRange() * 0.9f) { if (now >= _nextMove) { _nextMove = now + 1f; Navigator.SetDestination(t.transform.position, BaseNavigator.NavigationSpeed.Fast); } } else if (Navigator.Moving) Navigator.Stop(); npc.AttackTick(delta, t, _los); } private bool FightingAPlayer() { if (CurrentState == null || !CurrentState.AgrresiveState || Senses?.Memory == null) return false; foreach (BaseEntity e in Senses.Memory.Targets) if (e is BasePlayer p && !p.IsNpc && p.IsAlive()) return true; return false; } /// /// Only the factions this NPC is hostile to, from the harness's registry rather than a /// physics query; distance first (no physics), then line of sight on the nearest few only. /// private static BaseCombatEntity SenseHostile(RnhNpc npc) { for (int i = 0; i < LosBudget; i++) { Best[i] = null; BestD2[i] = float.MaxValue; } Vector3 at = npc.transform.position; float r2 = OwnSenseRange * OwnSenseRange; foreach (string faction in npc.RnhHostile) { HashSet set = Registry(faction); if (set == null) continue; foreach (BaseCombatEntity e in set) { if (e == null || e.IsDestroyed || e == npc || e.IsDead()) continue; Scanned++; float d2 = (e.transform.position - at).sqrMagnitude; if (d2 > r2 || d2 >= BestD2[LosBudget - 1]) continue; int k = LosBudget - 1; while (k > 0 && BestD2[k - 1] > d2) { Best[k] = Best[k - 1]; BestD2[k] = BestD2[k - 1]; k--; } Best[k] = e; BestD2[k] = d2; } } for (int i = 0; i < LosBudget && Best[i] != null; i++) { LosTests++; if (Los(npc, Best[i])) return Best[i]; } return null; } /// /// HumanNPC.CanSeeTarget answers true for anything that is not a player, without a ray, /// so animals and turrets get a linecast that counts the target's own collider as seen. /// private static bool Los(RnhNpc npc, BaseCombatEntity t) { BasePlayer p = t as BasePlayer; if (p != null) return npc.CanSeeTarget(p); RaycastHit hit; if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore)) return true; BaseEntity e = hit.GetEntity(); return e != null && (e == t || e.EqualNetID(t) || e.HasParent() && e.GetParentEntity() == t); } } // ---- the stage 5 registry: who can be hunted, by faction ---- private static readonly HashSet Scientists = new HashSet(); private static readonly HashSet Animals = new HashSet(); private static readonly Dictionary> OursByTag = new Dictionary>(); private static HashSet Registry(string faction) { if (faction == "scientists") return Scientists; if (faction == "animals") return Animals; HashSet set; return faction.StartsWith("ours:") && OursByTag.TryGetValue(faction.Substring(5), out set) ? set : null; } private static void Register(BaseEntity e) { if (e is RnhNpc) return; if (e is ScientistNPC s) Scientists.Add(s); else if (e is BaseNpc || e is Rust.Ai.Gen2.BaseNPC2) Animals.Add((BaseCombatEntity)e); } private static void Tag(RnhNpc npc, string tag) { npc.RnhTag = tag; HashSet set; if (!OursByTag.TryGetValue(tag, out set)) OursByTag[tag] = set = new HashSet(); set.Add(npc); } private static void Unregister(BaseEntity e) { BaseCombatEntity c = e as BaseCombatEntity; if (c == null) return; Scientists.Remove(c); Animals.Remove(c); RnhNpc o = c as RnhNpc; HashSet set; if (o != null && OursByTag.TryGetValue(o.RnhTag, out set)) set.Remove(o); } // ---- frame meter ---- public class FrameMeter : MonoBehaviour { private readonly List _frames = new List(20000); public bool Recording; public void Begin() { _frames.Clear(); Recording = true; } public List End() { Recording = false; return new List(_frames); } private void Update() { if (Recording) _frames.Add(Time.unscaledDeltaTime * 1000f); } } private static Dictionary Summarise(List ms) { if (ms.Count == 0) return new Dictionary { ["frames"] = 0 }; ms.Sort(); Func pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)]; double mean = ms.Average(); return new Dictionary { ["frames"] = ms.Count, ["meanMs"] = Math.Round(mean, 2), ["fps"] = Math.Round(1000.0 / mean, 1), ["p50Ms"] = Math.Round(pct(0.50), 2), ["p95Ms"] = Math.Round(pct(0.95), 2), ["p99Ms"] = Math.Round(pct(0.99), 2), ["maxMs"] = Math.Round(ms[ms.Count - 1], 2), }; } // ---- places ---- /// /// A flat, dry spot on the navmesh at least m from every /// monument, found by sampling the map from a fixed seed so a rerun lands in the same field. /// private bool FindField(float clear, int seed, out Vector3 at) { var rng = new System.Random(seed); float half = World.Size / 2f - 300f; List monuments = TerrainMeta.Path.Monuments; int wet = 0, steep = 0, nearMonument = 0, offMesh = 0, offAnyMesh = 0; for (int i = 0; i < 4000; i++) { var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half); p.y = TerrainMeta.HeightMap.GetHeight(p); if (p.y < TerrainMeta.WaterMap.GetHeight(p) + 1f) { wet++; continue; } if (TerrainMeta.HeightMap.GetSlope(p) > 15f) { steep++; continue; } if (monuments.Any(m => Vector3.Distance(m.transform.position, p) < clear)) { nearMonument++; continue; } NavMeshHit hit; if (!OnNavmesh(p, 2f, out hit)) { offMesh++; if (!NavMesh.SamplePosition(p, out hit, 2f, NavMesh.AllAreas)) offAnyMesh++; continue; } at = hit.position; return true; } _self?.Log("field", $"none: wet={wet} steep={steep} nearMonument={nearMonument} offRustNavmesh={offMesh} offUnityNavmesh={offAnyMesh} rustNavmeshBuilt={NavmeshBuilt()}"); at = Vector3.zero; return false; } /// /// Rust's own navmesh, the one a scientist walks: its navigator is a Gen2 /// RustNavMeshAgent, not Unity's NavMeshAgent, and Unity's NavMesh queries see none of /// the open world. This is the helper Rust's navigator samples with, and it answers false /// until the default navmesh is built (minutes after a map's first boot). /// private static bool OnNavmesh(Vector3 p, float radius, out NavMeshHit hit) => Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, radius, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas); private static bool NavmeshBuilt() => Rust.Ai.Gen2.Nav.RustNavigation.Instance != null && Rust.Ai.Gen2.Nav.RustNavigation.Instance.IsDefaultNavmeshBuilt(); private static Vector3 Ring(Vector3 centre, int i, int n, float r) => centre + new Vector3(Mathf.Cos(i * 2f * Mathf.PI / n) * r, 0f, Mathf.Sin(i * 2f * Mathf.PI / n) * r); private static readonly FieldInfo StatesField = typeof(BaseAIBrain).GetField("states", BindingFlags.Instance | BindingFlags.NonPublic); private static readonly FieldInfo SleepingField = typeof(BaseAIBrain).GetField("sleeping", BindingFlags.Instance | BindingFlags.NonPublic); private static readonly FieldInfo DisabledField = typeof(BaseAIBrain).GetField("disabled", BindingFlags.Instance | BindingFlags.NonPublic); /// A brain's insides: what it takes for a scientist to have a state at all. private static string Diag(HumanNPC n) { if (n == null || n.IsDestroyed) return "gone"; ScientistBrain b = n.Brain; if (b == null) return $"{n.GetType().Name} Brain=null comps={string.Join(",", n.GetComponents().Select(c => c.GetType().Name).ToArray())}"; var states = StatesField?.GetValue(b) as System.Collections.IDictionary; return $"{n.GetType().Name}/{b.GetType().Name} enabled={b.enabled} state={b.CurrentState?.StateType} states={(states == null ? "null" : states.Count.ToString())} " + $"useDesign={b.UseAIDesign} design={(b.AIDesign != null)} designs={b.Designs?.Count} defaultSO={(b.DefaultDesignSO != null)} think={b.ThinkMode} " + $"sleeping={SleepingField?.GetValue(b)} disabled={DisabledField?.GetValue(b)} nav={(b.Navigator != null)} agent={(n.NavAgent != null)} " + $"onMesh={(n.NavAgent != null && n.NavAgent.isOnNavMesh)} pathFinder={(b.PathFinder != null)} senses={b.Senses?.Players?.Count} " + $"targets={b.Senses?.Memory?.Targets?.Count} threats={b.Senses?.Memory?.Threats?.Count} " + $"slot={(b.Events != null ? b.Events.Memory.Entity.Get(b.Events.CurrentInputMemorySlot)?.ShortPrefabName : "-")} " + (n is RnhNpc r ? $"attackTicks={r.AttackTicks} withLos={r.AttackTicksLos} lastTarget={r.LastAttackTarget} " : "") + $"pos={V(n.transform.position)}"; } private static Vector3 Ground(Vector3 p) { p.y = TerrainMeta.HeightMap.GetHeight(p); return p; } private static string V(Vector3 p) => $"{p.x:0},{p.y:0.#},{p.z:0}"; // ---- hooks (all void: none of them may change what the game does) ---- private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info) { if (info == null) return; string weapon = info.WeaponPrefab != null ? info.WeaponPrefab.ShortPrefabName : info.Weapon != null ? info.Weapon.ShortPrefabName : "none"; TallyPair(victim, info, weapon); if (IsStandIn(victim) && info.Initiator is RnhNpc) { _hitsOnStandIns.Hits++; if (_standInsInvulnerable) info.damageTypes.ScaleAll(0f); } HitTally tally = info.Initiator is RnhNpc ? _hitsByOurs : victim is RnhNpc ? _hitsOnOurs : null; if (tally == null) return; tally.Hits++; tally.Damage += info.damageTypes.Total(); int n; tally.Weapons.TryGetValue(weapon, out n); tally.Weapons[weapon] = n + 1; } private void OnEntityDeath(BaseCombatEntity victim, HitInfo info) { string label; if (victim != null && _labels.TryGetValue(victim, out label) && !_diedAt.ContainsKey(label)) _diedAt[label] = (float)Math.Round(Time.time - _scenarioStart, 1); if (!(victim is RnhNpc) && !IsStandIn(victim)) return; BasePlayer p = (BasePlayer)victim; _deaths.Add($"{p.GetType().Name} '{p.displayName}' by {(info?.Initiator != null ? info.Initiator.GetType().Name + " '" + (info.Initiator as BasePlayer)?.displayName + "'" : "none")}"); } private void OnEntityKill(BaseNetworkable entity) => Unregister(entity as BaseEntity); /// /// TruePVE and NextGenPVE ask every plugin this before applying their rules (D261). The /// harness counts the asks; in "allow" mode it allows any hit to or from one of ours, in /// "deny" mode it refuses them. /// private object CanEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { _pveAsks++; if (_pveMode == 0 || !(entity is RnhNpc || info?.Initiator is RnhNpc)) return null; return _pveMode == 1; } private void OnEntitySpawned(BaseEntity entity) { Register(entity); PlayerCorpse corpse = entity as PlayerCorpse; if (corpse == null) return; if (corpse.playerSteamID != DummyUserId && (corpse.playerName == null || !corpse.playerName.StartsWith("Rnh"))) return; _corpses.Add($"{corpse.GetType().Name} playerName='{corpse.playerName}' steamId={corpse.playerSteamID}"); } // ---- 1. the swap ---- [ConsoleCommand("rnh.swap")] private void CmdSwap(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; Vector3 field; if (!FindField(250f, 101, out field)) { Log("swap", "FAIL no field found"); return; } _hitsByOurs.Hits = _hitsOnOurs.Hits = 0; _hitsByOurs.Weapons.Clear(); _hitsOnOurs.Weapons.Clear(); _deaths.Clear(); _corpses.Clear(); SpawnReport r = SpawnOurs(field, "RnhWarden", FightsScientists); if (r.Npc == null) { Log("swap", "FAIL " + r.Error); return; } RnhNpc npc = r.Npc; Vector3 start = npc.transform.position; var rec = new Dictionary { ["at"] = V(start), ["spawnMs"] = Math.Round(r.Ms, 2), ["npcFieldsCopied"] = r.NpcFields, ["brainFieldsCopied"] = r.BrainFields, ["type"] = npc.GetType().Name, ["brainType"] = npc.Brain != null ? npc.Brain.GetType().Name : "null", ["isNpc"] = npc.IsNpc, ["displayName"] = npc.displayName, ["shortPrefab"] = npc.ShortPrefabName, ["enableSaving"] = npc.enableSaving, ["navigator"] = npc.Brain?.Navigator != null, ["kitResult"] = r.Kit, ["belt0"] = npc.inventory.containerBelt.GetSlot(0)?.info.shortname ?? "none", ["held"] = npc.GetHeldEntity() != null ? npc.GetHeldEntity().ShortPrefabName : "none", }; Log("swap", string.Join(" ", rec.Select(kv => kv.Key + "=" + kv.Value).ToArray())); var samples = new List(); float maxMoved = 0f; int t = 0; ScientistNPC foe = null; timer.Repeat(5f, 18, () => { t += 5; if (t == 30 && npc != null && !npc.IsDestroyed && !npc.IsDead()) foe = SpawnStock(Ground(start + new Vector3(8f, 0f, 0f))); if (npc == null || npc.IsDestroyed) { samples.Add($"+{t}s gone"); return; } float moved = Vector3.Distance(start, npc.transform.position); maxMoved = Mathf.Max(maxMoved, moved); string s = $"+{t}s state={npc.Brain?.CurrentState?.StateType} moved={moved:0.0} hp={npc.health:0} " + $"thinking={((npc.Brain as RnhBrain)?.LastThinkAt > Time.time - 2f)} foe={(foe != null && !foe.IsDestroyed ? foe.health.ToString("0") : "-")}"; samples.Add(s); Log("swap", s); if (t == 5 || t == 35 || t == 60) { Log("swap", " ours " + Diag(npc)); if (foe != null) Log("swap", " stock " + Diag(foe)); } }); timer.Once(92f, () => { bool alive = npc != null && !npc.IsDestroyed && !npc.IsDead(); if (alive) npc.Hurt(1000f, Rust.DamageType.Bullet, foe != null && !foe.IsDestroyed ? foe : null, false); timer.Once(3f, () => { rec["samples"] = samples; rec["walked"] = maxMoved > 3f; rec["maxMovedM"] = Math.Round(maxMoved, 1); rec["hitsByOurs"] = _hitsByOurs.Hits; rec["hitsByOursWeapons"] = new Dictionary(_hitsByOurs.Weapons); rec["hitsOnOurs"] = _hitsOnOurs.Hits; rec["killedByHarness"] = alive; rec["deaths"] = new List(_deaths); rec["corpses"] = new List(_corpses); rec["destroyedAfterDeath"] = npc == null || npc.IsDestroyed; Record("swap", rec); Log("swap", $"DONE walked={rec["walked"]} hitsByOurs={_hitsByOurs.Hits} weapons={string.Join(",", _hitsByOurs.Weapons.Select(kv => kv.Key + ":" + kv.Value).ToArray())} " + $"hitsOnOurs={_hitsOnOurs.Hits} deaths={_deaths.Count} corpses={string.Join(" | ", _corpses.ToArray())} destroyed={rec["destroyedAfterDeath"]}"); if (foe != null && !foe.IsDestroyed) foe.Kill(); }); }); } // ---- 2. saving ---- private sealed class Planted { public ulong NetId; public float X, Y, Z; public bool Saved; } [ConsoleCommand("rnh.save")] private void CmdSave(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; string mode = arg.GetString(0); if (mode == "plant") { Vector3 field; if (!FindField(250f, 202, out field)) { Log("save", "FAIL no field found"); return; } var planted = new List(); for (int i = 0; i < 6; i++) { // Five never saved, one saved: the control that proves the check can see a saved NPC. bool saved = i == 5; SpawnReport r = SpawnOurs(Ground(field + new Vector3(i * 6f, 0f, 0f)), "RnhSave" + i, null, saved); if (r.Npc == null) continue; _spawned.Remove(r.Npc); // Unload must not kill them: the restart is the test. planted.Add(new Planted { NetId = r.Npc.net.ID.Value, Saved = saved }); } timer.Once(3f, () => { foreach (Planted p in planted) { BaseNetworkable e = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId)); if (e == null) continue; p.X = e.transform.position.x; p.Y = e.transform.position.y; p.Z = e.transform.position.z; } Interface.Oxide.DataFileSystem.WriteObject(Name + "_planted", planted); ConsoleSystem.Run(ConsoleSystem.Option.Server, "server.save"); Log("save", $"planted {planted.Count} ({planted.Count(p => p.Saved)} saved control) at {V(field)} and ran server.save; now hard-kill the server"); }); return; } if (mode == "check") { List planted = Interface.Oxide.DataFileSystem.ReadObject>(Name + "_planted") ?? new List(); int backUnsaved = 0, backSaved = 0; var found = new List(); foreach (Planted p in planted) { // By net id: Rust restores a saved entity under the id it had. (A distance match was // tried first and matched the saved control from its neighbour 6 m away.) BaseNetworkable hit = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId)); BaseNetworkable byId = hit; if (hit == null) continue; if (p.Saved) backSaved++; else backUnsaved++; found.Add($"{p.NetId} saved={p.Saved} -> {hit.GetType().Name} '{(hit as BasePlayer)?.displayName}' byId={byId != null}"); } var rec = new Dictionary { ["planted"] = planted.Count, ["unsavedBack"] = backUnsaved, ["savedControlBack"] = backSaved, ["found"] = found, ["pass"] = backUnsaved == 0 && backSaved == planted.Count(p => p.Saved), }; Record("save." + Framework(), rec); Log("save", $"{(true.Equals(rec["pass"]) ? "PASS" : "FAIL")} unsaved back={backUnsaved}/{planted.Count(p => !p.Saved)} saved control back={backSaved}/{planted.Count(p => p.Saved)} :: {string.Join(" | ", found.ToArray())}"); foreach (Planted p in planted) { BaseNetworkable e = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId)); if (e != null) e.Kill(); } return; } Log("save", "usage: rnh.save plant|check"); } // ---- 3. tuning ---- /// /// Which of a profile's numbers Rust's own brain honours. Each is set either BEFORE the brain /// starts (the values Senses.Init copies) or AFTER it is running, and then observed. /// [ConsoleCommand("rnh.tune")] private void CmdTune(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; Vector3 field; if (!FindField(250f, 303, out field)) { Log("tune", "FAIL no field found"); return; } var rec = new Dictionary(); // Health and name and aim cone: the NPC's own values. SpawnReport h = SpawnOurs(field, "RnhTune", null); RnhNpc n = h.Npc; n.startHealth = 400f; n.InitializeHealth(400f, 400f); n.RnhAimCone = 0.5f; rec["health.set400"] = $"{n.health:0}/{n.MaxHealth():0}"; rec["name"] = n.displayName; rec["aimCone.override0.5"] = n.GetAimConeScale(); n.Kill(); // Sense range: a stock scientist 35 m away. Default SenseRange on the prefab vs 50 set before start vs after. Vector3 foeAt = Ground(field + new Vector3(35f, 0f, 0f)); ScientistNPC target = SpawnStock(foeAt); target.Brain.SetEnabled(false); SpawnReport dflt = SpawnOurs(Ground(field + new Vector3(0f, 0f, 4f)), "RnhDefault", b => b.SenseTypes |= EntityType.BasePlayerNPC); SpawnReport before = SpawnOurs(Ground(field + new Vector3(0f, 0f, -4f)), "RnhBefore", b => { b.SenseTypes |= EntityType.BasePlayerNPC; b.SenseRange = 50f; b.TargetLostRange = 60f; }); SpawnReport after = SpawnOurs(Ground(field + new Vector3(0f, 0f, -8f)), "RnhAfter", b => b.SenseTypes |= EntityType.BasePlayerNPC); rec["prefab.SenseRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.SenseRange : -1f; rec["prefab.TargetLostRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.TargetLostRange : -1f; rec["prefab.AttackRangeMultiplier"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.AttackRangeMultiplier : -1f; rec["prefab.VisionCone"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.VisionCone : -1f; rec["prefab.HostileTargetsOnly"] = dflt.Npc.Brain != null && dflt.Npc.Brain.HostileTargetsOnly; rec["prefab.CheckLOS"] = dflt.Npc.Brain != null && dflt.Npc.Brain.CheckLOS; rec["prefab.CheckVisionCone"] = dflt.Npc.Brain != null && dflt.Npc.Brain.CheckVisionCone; rec["prefab.ListenRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.ListenRange : -1f; rec["prefab.MemoryDuration"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.MemoryDuration : -1f; rec["prefab.SenseTypes"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.SenseTypes.ToString() : "-"; rec["prefab.MaxRoamDistanceFromHome"] = dflt.Npc.Brain?.Navigator != null ? dflt.Npc.Brain.Navigator.MaxRoamDistanceFromHome : -1f; rec["prefab.aimConeScale"] = dflt.Npc.aimConeScale; rec["prefab.startHealth"] = dflt.Npc.startHealth; timer.Once(3f, () => { if (after.Npc?.Brain != null) { after.Npc.Brain.SenseRange = 50f; after.Npc.Brain.TargetLostRange = 60f; } }); timer.Once(10f, () => { Func sees = s => s.Npc == null || s.Npc.IsDestroyed ? "gone" : $"knows={s.Npc.Brain.Senses.Players.Contains(target)} state={s.Npc.Brain.CurrentState?.StateType}"; rec["senseRange.default@35m"] = sees(dflt); rec["senseRange.50.beforeStart@35m"] = sees(before); rec["senseRange.50.afterStart@35m"] = sees(after); // Roam: where does a roamer go with no AI zone vs its home range? rec["roam.aiZoneHere"] = AIInformationZone.GetForPoint(field, false) != null; Record("tune", rec); Log("tune", string.Join(" ", rec.Select(kv => kv.Key + "=" + kv.Value).ToArray())); foreach (SpawnReport s in new[] { dflt, before, after }) if (s.Npc != null && !s.Npc.IsDestroyed) s.Npc.Kill(); if (target != null && !target.IsDestroyed) target.Kill(); }); } /// Roaming at a monument (inside an AI zone) vs in a field (outside one), 60 s each. [ConsoleCommand("rnh.roam")] private void CmdRoam(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; Vector3 field; if (!FindField(250f, 404, out field)) { Log("roam", "FAIL no field found"); return; } AIInformationZone zone = AIInformationZone.zones.FirstOrDefault(z => z != null && z.GetComponentInParent() != null); Vector3 monument = Vector3.zero; NavMeshHit mh; if (zone != null && OnNavmesh(zone.transform.position, 20f, out mh)) monument = mh.position; SpawnReport inField = SpawnOurs(field, "RnhField", null); SpawnReport inZone = monument != Vector3.zero ? SpawnOurs(monument, "RnhZone", null) : null; Vector3 f0 = field, z0 = monument; float fMax = 0f, zMax = 0f; var states = new List(); timer.Repeat(5f, 12, () => { if (inField.Npc != null && !inField.Npc.IsDestroyed) fMax = Mathf.Max(fMax, Vector3.Distance(f0, inField.Npc.transform.position)); if (inZone?.Npc != null && !inZone.Npc.IsDestroyed) zMax = Mathf.Max(zMax, Vector3.Distance(z0, inZone.Npc.transform.position)); states.Add($"field={inField.Npc?.Brain?.CurrentState?.StateType} zone={inZone?.Npc?.Brain?.CurrentState?.StateType}"); }); timer.Once(62f, () => { var rec = new Dictionary { ["field.maxMovedM"] = Math.Round(fMax, 1), ["zone"] = zone != null ? zone.GetComponentInParent()?.name : "none", ["zone.maxMovedM"] = Math.Round(zMax, 1), ["states"] = states, }; Record("roam", rec); Log("roam", $"field moved {fMax:0.0} m, zone ({rec["zone"]}) moved {zMax:0.0} m; last {states.LastOrDefault()}"); inField.Npc?.Kill(); inZone?.Npc?.Kill(); }); } // ---- 4. navmesh ---- [ConsoleCommand("rnh.nav")] private void CmdNav(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; int mask = LayerMask.GetMask("Terrain", "World", "Construction", "Default", "Deployed"); var rows = new List(); 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"]); } }); }); } // ==== stage 5 (PLAN.md §9, stage 5; D231, D253-D262) ==== /// /// The PVE stand-in's user id. TruePVE and NextGenPVE treat only Steam ids as players /// (IsSteamId), so this one is in the Steam range, and far beyond any id Steam has issued. /// private const ulong PveUserId = 76570997960265728UL; /// 0 observe (answer nothing), 1 allow (D261), 2 deny (D261's per-profile opt-out). private int _pveMode; private int _pveAsks; /// Labelled entities: every hit to or from one is tallied by "attacker>victim". private readonly Dictionary _labels = new Dictionary(); private readonly Dictionary _pairs = new Dictionary(); private readonly Dictionary _diedAt = new Dictionary(); private readonly HashSet _invulnerable = new HashSet(); private float _scenarioStart; private T Label(T e, string label) where T : BaseEntity { if (e == null) return null; _labels[e] = label; _spawned.Add(e); return e; } private void TallyPair(BaseCombatEntity victim, HitInfo info, string weapon) { string v = null, a = null; if (victim != null) _labels.TryGetValue(victim, out v); BaseEntity src = info.Initiator; if (src != null) _labels.TryGetValue(src, out a); if (a == null && info.Weapon != null) { BaseEntity parent = info.Weapon.GetParentEntity(); if (parent != null) _labels.TryGetValue(parent, out a); } if (v == null && a == null) return; string key = (a ?? (src != null ? src.ShortPrefabName : "none")) + ">" + (v ?? victim.ShortPrefabName); HitTally t; if (!_pairs.TryGetValue(key, out t)) _pairs[key] = t = new HitTally(); t.Hits++; t.Damage += info.damageTypes.Total(); int n; t.Weapons.TryGetValue(weapon, out n); t.Weapons[weapon] = n + 1; if (victim != null && _invulnerable.Contains(victim)) info.damageTypes.ScaleAll(0f); } private Dictionary Pairs() => _pairs.ToDictionary(kv => kv.Key, kv => (object)new Dictionary { ["hits"] = kv.Value.Hits, ["damage"] = Math.Round(kv.Value.Damage, 1), ["weapons"] = kv.Value.Weapons.ToDictionary(w => w.Key, w => (object)w.Value), }); private sealed class Scenario { public string Name; public float Seconds; public Action Setup; public Func Done; public Func> Extra; } /// /// Runs scenarios one after another: set up, wait until done or out of time, record the hit /// pairs, deaths and extras, then remove everything labelled and the corpses near the field. /// private void RunScenarios(string test, Vector3 field, List list, Dictionary header = null) { var results = new List>(); int index = 0; Action next = null; next = () => { if (index >= list.Count) { var rec = header ?? new Dictionary(); rec["framework"] = Framework(); rec["scenarios"] = results; Record(test + "." + Framework(), rec); Log(test, "DONE"); return; } Scenario sc = list[index++]; _labels.Clear(); _pairs.Clear(); _diedAt.Clear(); _invulnerable.Clear(); RnhBrain.OwnMs = RnhBrain.SenseMs = 0; RnhBrain.OwnThinks = RnhBrain.SenseRuns = RnhBrain.Scanned = RnhBrain.LosTests = RnhBrain.Engaged = 0; _scenarioStart = Time.time; string error = null; try { sc.Setup(); } catch (Exception e) { error = e.GetType().Name + ": " + e.Message; } float elapsed = 0f; Timer poll = null; poll = timer.Every(1f, () => { elapsed += 1f; bool done = error != null; if (!done) { try { done = sc.Done != null && sc.Done(); } catch { done = true; } } if (!done && elapsed < sc.Seconds) return; poll.Destroy(); var r = new Dictionary { ["scenario"] = sc.Name, ["seconds"] = elapsed, ["pairs"] = Pairs(), ["diedAt"] = new Dictionary(_diedAt), }; if (error != null) r["error"] = error; if (sc.Extra != null) { try { foreach (KeyValuePair kv in sc.Extra()) r[kv.Key] = kv.Value; } catch (Exception e) { r["extraError"] = e.Message; } } results.Add(r); Log(test, $"{sc.Name}: {JsonConvert.SerializeObject(r)}"); foreach (BaseEntity e in _labels.Keys.ToArray()) if (e != null && !e.IsDestroyed) e.Kill(); ClearLeftovers(field, 80f); timer.Once(2f, next); }); }; Log(test, $"field {V(field)}, {list.Count} scenarios"); next(); } /// Corpses and dropped backpacks near the field: a fight leaves both. private static void ClearLeftovers(Vector3 field, float radius) { foreach (BaseEntity e in BaseNetworkable.serverEntities.OfType() .Where(e => (e is BaseCorpse || e is DroppedItemContainer || e is DroppedItem) && Vector3.Distance(e.transform.position, field) < radius) .ToArray()) e.Kill(); } private RnhNpc Ours(Vector3 at, string label, string tag, bool shotFaction, params string[] hostile) { SpawnReport r = SpawnOurs(Ground(at), "Rnh" + label); if (r.Npc == null) throw new Exception("our NPC did not spawn: " + r.Error); Tag(r.Npc, tag); r.Npc.RnhShotFaction = shotFaction; if (hostile.Length > 0) r.Npc.RnhHostile = new HashSet(hostile); return Label(r.Npc, label); } private ScientistNPC Stock(Vector3 at, string label) => Label(SpawnStock(Ground(at)), label); private static string FindPrefab(string suffix) => GameManifest.Current.entities.FirstOrDefault(p => p.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); private BaseCombatEntity Animal(string suffix, Vector3 at, string label) { string path = FindPrefab(suffix); if (path == null) throw new Exception("no prefab ends with " + suffix); BaseCombatEntity a = GameManager.server.CreateEntity(path, Ground(at)) as BaseCombatEntity; if (a == null) throw new Exception(path + " is not a combat entity"); a.enableSaving = false; a.Spawn(); return Label(a, label); } private static Dictionary Stats(RnhNpc o) => o == null || o.IsDestroyed ? new Dictionary { ["gone"] = true } : new Dictionary { ["health"] = Math.Round(o.health, 0), ["target"] = o.RnhNpcTarget != null ? o.RnhNpcTarget.ShortPrefabName : "none", ["targetHealth"] = o.RnhNpcTarget != null && !o.RnhNpcTarget.IsDestroyed ? $"{o.RnhNpcTarget.health:0}/{o.RnhNpcTarget.MaxHealth():0} at {Vector3.Distance(o.transform.position, o.RnhNpcTarget.transform.position):0} m" : "-", ["shotTests"] = o.ShotTests, ["shotsMarked"] = o.ShotsMarked, ["attackTicks"] = o.AttackTicks, ["attackTicksWithLos"] = o.AttackTicksLos, ["state"] = o.Brain?.CurrentState?.StateType.ToString() ?? "none", ["held"] = o.GetActiveItem()?.info.shortname ?? "none", ["lastHurtBy"] = o.LastHurtBy, }; private static bool Dead(BaseEntity e) => e == null || e.IsDestroyed || (e as BaseCombatEntity)?.IsDead() == true; // ---- 5.1 our sensing and combat state against an NPC target ---- [ConsoleCommand("rnh.npcfight")] private void CmdNpcFight(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; Vector3 f; if (!FindField(250f, 505, out f)) { Log("npcfight", "FAIL no field found"); return; } Vector3 z(float m) => f + new Vector3(0f, 0f, m); float secs = arg.GetFloat(0, 60f); RnhNpc o = null, b = null, ally = null; BaseCombatEntity foe = null; Func> extra = () => new Dictionary { ["ours"] = Stats(o), ["other"] = b != null ? Stats(b) : null, ["ally"] = ally != null ? Stats(ally) : null, ["foe"] = foe == null ? null : new Dictionary { ["type"] = foe.GetType().Name, ["dead"] = Dead(foe), ["health"] = foe.IsDestroyed ? 0 : Math.Round(foe.health, 0), }, ["engagedThinks"] = RnhBrain.Engaged, ["senseRuns"] = RnhBrain.SenseRuns, ["leftHorror"] = _labels.Keys.Count(e => e is BaseCombatEntity c && !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror), }; Action reset = () => { o = b = ally = null; foe = null; }; var list = new List { // Rust's own filter, untouched: shots at a stock scientist, and what lands. new Scenario { Name = "scientist.noFaction", Seconds = 30f, Extra = extra, Setup = () => { reset(); o = Ours(f, "ours", "a", false, "scientists"); foe = Stock(z(15f), "stock"); } }, new Scenario { Name = "scientist.horrorPerShot", Seconds = secs, Extra = extra, Done = () => Dead(foe), Setup = () => { reset(); o = Ours(f, "ours", "a", true, "scientists"); foe = Stock(z(15f), "stock"); } }, new Scenario { Name = "ours.vs.ours", Seconds = secs, Extra = extra, Done = () => Dead(o) || Dead(b), Setup = () => { reset(); o = Ours(f, "ours", "a", true, "ours:b"); b = Ours(z(15f), "other", "b", true, "ours:a"); } }, new Scenario { Name = "animal.boar", Seconds = secs, Extra = extra, Done = () => Dead(foe), Setup = () => { reset(); o = Ours(f, "ours", "a", true, "animals"); foe = Animal("/boar.prefab", z(15f), "boar"); } }, new Scenario { Name = "animal.wolf2", Seconds = secs, Extra = extra, Done = () => Dead(foe) || Dead(o), Setup = () => { reset(); o = Ours(f, "ours", "a", true, "animals"); foe = Animal("/wolf2.prefab", z(15f), "wolf"); } }, // An ally of ours standing on the line of fire: the per-shot faction marks only the target. new Scenario { Name = "crossfire.allyOnTheLine", Seconds = secs, Extra = extra, Done = () => Dead(foe), Setup = () => { reset(); o = Ours(f, "ours", "a", true, "scientists"); ally = Ours(z(8f), "ally", "a", false); foe = Stock(z(16f), "stock"); } }, }; string only = arg.GetString(1, ""); RunScenarios("npcfight", f, only == "" ? list : list.Where(s => s.Name.Contains(only)).ToList()); } // ---- 5.2 what our sensing costs ---- [ConsoleCommand("rnh.sense")] private void CmdSense(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; Vector3 field; if (!FindField(400f, 606, out field)) { Log("sense", "FAIL no field found"); return; } float window = arg.GetFloat(0, 30f); var phases = new List>(); var all = new List(); 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, 30f + (i / 10) * 3f)); Action killAll = () => { foreach (BaseEntity e in all) if (e != null && !e.IsDestroyed) e.Kill(); all.Clear(); _invulnerable.Clear(); }; Func spawn = (i, tag, foeSide, hostile) => { SpawnReport r = SpawnOurs(foeSide ? foeGrid(i) : grid(i), "RnhSense" + tag + i); if (r.Npc == null) return null; Tag(r.Npc, tag); r.Npc.RnhShotFaction = true; r.Npc.RnhHostile = new HashSet(hostile); all.Add(r.Npc); _labels[r.Npc] = "ours." + tag; _invulnerable.Add(r.Npc); return r.Npc; }; var queue = new Queue>(); queue.Enqueue(new KeyValuePair("baseline", () => { })); queue.Enqueue(new KeyValuePair("ours100.hostileToScientists.noneNear", () => { for (int i = 0; i < 100; i++) spawn(i, "a", false, new[] { "scientists" }); })); queue.Enqueue(new KeyValuePair("ours100.v.scientists100", () => { for (int i = 0; i < 100; i++) { ScientistNPC s = SpawnStock(foeGrid(i)); if (s == null) continue; all.Add(s); _labels[s] = "stock"; _invulnerable.Add(s); } })); queue.Enqueue(new KeyValuePair("ours50.v.ours50", () => { killAll(); for (int i = 0; i < 50; i++) spawn(i, "a", false, new[] { "ours:b" }); for (int i = 0; i < 50; i++) spawn(i, "b", true, new[] { "ours:a" }); })); queue.Enqueue(new KeyValuePair("after.clear", killAll)); Action next = null; next = () => { if (queue.Count == 0) { Record("sense." + Framework(), new Dictionary { ["framework"] = Framework(), ["map"] = $"{World.Size}/{World.Seed}", ["players"] = BasePlayer.activePlayerList.Count, ["aiBudgetMs"] = AIThinkManager.framebudgetms, ["windowS"] = window, ["senseEveryS"] = RnhBrain.OwnSenseEvery, ["senseRangeM"] = RnhBrain.OwnSenseRange, ["phases"] = phases, }); _labels.Clear(); Log("sense", "DONE"); return; } KeyValuePair step = queue.Dequeue(); Stopwatch sw = Stopwatch.StartNew(); step.Value(); double setupMs = sw.Elapsed.TotalMilliseconds; // Five seconds to settle (spawn, first senses), then measure. timer.Once(5f, () => { RnhBrain.ThinkMs = 0; RnhBrain.Thinks = 0; RnhBrain.OwnMs = RnhBrain.SenseMs = 0; RnhBrain.OwnThinks = RnhBrain.SenseRuns = RnhBrain.Scanned = RnhBrain.LosTests = RnhBrain.Engaged = 0; _pairs.Clear(); foreach (BaseEntity e in all) if (e is RnhNpc o && o.Brain is RnhBrain rb) { rb.IntervalSum = 0; rb.IntervalCount = 0; } _meter.Begin(); timer.Once(window, () => { Dictionary f = Summarise(_meter.End()); float isum = 0f; int icount = 0; foreach (BaseEntity e in all) if (e is RnhNpc o && o.Brain is RnhBrain rb) { isum += rb.IntervalSum; icount += rb.IntervalCount; } f["phase"] = step.Key; f["setupMs"] = Math.Round(setupMs, 1); f["ours"] = all.Count(e => e is RnhNpc && !Dead(e)); f["stock"] = all.Count(e => e is ScientistNPC && !(e is RnhNpc) && !Dead(e)); f["msPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.ThinkMs / RnhBrain.Thinks, 3) : 0; f["ownMsPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.OwnMs / RnhBrain.Thinks, 3) : 0; f["senseRuns"] = RnhBrain.SenseRuns; f["msPerSense"] = RnhBrain.SenseRuns > 0 ? Math.Round(RnhBrain.SenseMs / RnhBrain.SenseRuns, 4) : 0; f["scannedPerSense"] = RnhBrain.SenseRuns > 0 ? Math.Round((double)RnhBrain.Scanned / RnhBrain.SenseRuns, 1) : 0; f["losTests"] = RnhBrain.LosTests; f["engagedThinks"] = RnhBrain.Engaged; f["thinkIntervalS"] = icount > 0 ? Math.Round(isum / icount, 3) : 0; f["aiQueue"] = AIThinkManager._processQueue.Count; f["leftHorror"] = all.Count(e => e is BaseCombatEntity c && !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror); f["pairs"] = Pairs(); phases.Add(f); Log("sense", JsonConvert.SerializeObject(f)); next(); }); }); }; Log("sense", $"field {V(field)}, {window:0} s per phase, {queue.Count} phases; players online {BasePlayer.activePlayerList.Count}"); next(); } // ---- 5.3 turrets and traps (D259) ---- private BaseCombatEntity Turret(string kind, Vector3 at, Quaternion rot, List notes) { string suffix = kind == "auto" ? "/autoturret_deployed.prefab" : kind == "flame" ? "/flameturret.deployed.prefab" : "/guntrap.deployed.prefab"; string path = FindPrefab(suffix); if (path == null) throw new Exception("no prefab ends with " + suffix); BaseCombatEntity t = GameManager.server.CreateEntity(path, at, rot) as BaseCombatEntity; if (t == null) throw new Exception(path + " is not a combat entity"); t.enableSaving = false; t.Spawn(); Label(t, "turret." + kind); AutoTurret auto = t as AutoTurret; if (auto != null) { ItemManager.CreateByName("rifle.ak").MoveToContainer(auto.inventory, 0); ItemManager.CreateByName("ammo.rifle", 256).MoveToContainer(auto.inventory, 1); auto.UpdateAttachedWeapon(); auto.UpdateTotalAmmo(); auto.UpdateFromInput(10, 0); timer.Once(4f, () => { if (auto.IsDestroyed) return; notes.Add($"auto: powered={auto.IsPowered()} online={auto.IsOnline()} weapon={auto.GetAttachedWeapon()?.ShortPrefabName ?? "none"}"); if (!auto.IsOnline()) { auto.SetIsOnline(true); notes.Add("auto: forced online"); } }); } else if (t is FlameTurret flame) ItemManager.CreateByName("lowgradefuel", 100).MoveToContainer(flame.inventory); else if (t is GunTrap trap) ItemManager.CreateByName("ammo.handmade.shell", 20).MoveToContainer(trap.inventory); return t; } [ConsoleCommand("rnh.turrets")] private void CmdTurrets(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; Vector3 f; if (!FindField(250f, 808, out f)) { Log("turrets", "FAIL no field found"); return; } float secs = arg.GetFloat(0, 30f); var list = new List(); foreach (string kind in new[] { "auto", "flame", "trap" }) { foreach (string victim in new[] { "standIn", "stock", "ours" }) { string k = kind, v = victim; BaseCombatEntity turret = null; BaseEntity target = null; var notes = new List(); list.Add(new Scenario { Name = k + "." + v, Seconds = secs, Done = () => Dead(target) || Dead(turret), Setup = () => { notes.Clear(); float d = k == "auto" ? 10f : 3f; Vector3 spot = Ground(f + new Vector3(0f, 0f, d)); // Spawned outside the turret's trigger and moved in: a flame turret's trigger only // notices what ENTERS it (a target spawned inside, standing still, was never burned). Vector3 outside = Ground(f + new Vector3(0f, 0f, d + 12f)); turret = Turret(k, Ground(f), Quaternion.LookRotation(new Vector3(0f, 0f, 1f)), notes); if (v == "stock") target = Stock(outside, "stock"); else if (v == "standIn") { // The control: a stand-in player shows the turret is armed. It takes no damage. target = Label(SpawnDummy(outside, 40), "standIn"); _invulnerable.Add(target); } else { RnhNpc o = Ours(outside, "ours", "a", false); o.RnhShootsBack = true; target = o; } BaseEntity moving = target; timer.Once(2f, () => { if (Dead(moving)) return; BasePlayer p = moving as BasePlayer; if (p is HumanNPC h && h.Brain?.Navigator?.Agent != null) h.Brain.Navigator.Agent.Warp(spot); p?.Teleport(spot); notes.Add($"moved in: at {V(moving.transform.position)}, spot {V(spot)}"); }); }, Extra = () => new Dictionary { ["turret"] = turret == null ? null : new Dictionary { ["type"] = turret.GetType().Name, ["dead"] = Dead(turret), ["health"] = turret.IsDestroyed ? 0 : Math.Round(turret.health, 0), }, ["target"] = target is RnhNpc o ? Stats(o) : new Dictionary { ["dead"] = Dead(target), ["health"] = target == null || target.IsDestroyed ? 0 : Math.Round(((BaseCombatEntity)target).health, 0), }, ["notes"] = new List(notes), }, }); } } string only = arg.GetString(1, ""); RunScenarios("turrets", f, only == "" ? list : list.Where(s => s.Name.Contains(only)).ToList()); } // ---- 5.4 the kit items Rust's AI uses on its own (D260) ---- private static Dictionary Inventory(BasePlayer p) { var counts = new Dictionary(); if (p == null || p.IsDestroyed || p.inventory == null) return counts; foreach (ItemContainer c in new[] { p.inventory.containerBelt, p.inventory.containerMain }) { if (c == null) continue; foreach (Item i in c.itemList) { object n; counts[i.info.shortname] = (counts.TryGetValue(i.info.shortname, out n) ? (int)n : 0) + i.amount; } } return counts; } private static void Give(BasePlayer p, string shortname, int amount, ItemContainer into, int slot = -1) { Item item = ItemManager.CreateByName(shortname, amount); if (item == null) return; if (!item.MoveToContainer(into, slot)) item.Remove(); } [ConsoleCommand("rnh.items")] private void CmdItems(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; Vector3 f; if (!FindField(250f, 909, out f)) { Log("items", "FAIL no field found"); return; } float secs = arg.GetFloat(0, 60f); Vector3 z(float m) => f + new Vector3(0f, 0f, m); RnhNpc o = null; BasePlayer standIn = null; Dictionary before = null; float lastHealth = 0f; int heals = 0, scripted = 0; float minHealth = float.MaxValue; Timer pulse = null; Action addStandIn = (dist, index) => { standIn = Label(SpawnDummy(Ground(z(dist)), index), "standIn"); _invulnerable.Add(standIn); timer.Once(1f, () => { if (o?.Brain?.Senses?.Memory != null && standIn != null && !standIn.IsDestroyed) o.Brain.Senses.Memory.SetKnown(standIn, o, null); }); }; Action> spawn = kit => { o = Ours(f, "ours", "a", false); kit(o); o.EquipWeapon(); before = Inventory(o); lastHealth = o.health; minHealth = o.health; heals = 0; scripted = 0; pulse?.Destroy(); pulse = timer.Every(1f, () => { if (Dead(o)) return; if (o.health > lastHealth + 5f) heals++; lastHealth = o.health; minHealth = Mathf.Min(minHealth, o.health); }); }; Func> extra = () => { pulse?.Destroy(); return new Dictionary { ["ours"] = Stats(o), ["before"] = before, ["after"] = Inventory(o), ["healthRises"] = heals, ["minHealth"] = Math.Round(minHealth, 0), ["scriptedHurts"] = scripted, ["canUseHealingItems"] = o?.Brain != null && o.Brain.CanUseHealingItems, }; }; var list = new List { // The kit's revolver plus every extra D260 names; the stand-in "shoots" ours every 4 s. new Scenario { Name = "extras.healingOn", Seconds = secs, Extra = extra, Setup = () => { spawn(n => { Give(n, "syringe.medical", 3, n.inventory.containerBelt, 1); Give(n, "grenade.f1", 2, n.inventory.containerBelt, 2); Give(n, "machete", 1, n.inventory.containerBelt, 3); n.Brain.CanUseHealingItems = true; }); addStandIn(15f, 31); timer.Repeat(4f, (int)(secs / 4f), () => { if (Dead(o) || Dead(standIn) || o.health < 45f) return; AttackEntity gun = standIn.GetActiveItem()?.GetHeldEntity() as AttackEntity; o.Hurt(new HitInfo(standIn, o, Rust.DamageType.Bullet, 20f) { Weapon = gun, WeaponPrefab = gun }); scripted++; }); } }, new Scenario { Name = "main.rocketLauncher", Seconds = Mathf.Min(secs, 40f), Extra = extra, Setup = () => { spawn(n => { n.inventory.Strip(); Give(n, "rocket.launcher", 1, n.inventory.containerBelt, 0); Give(n, "ammo.rocket.basic", 6, n.inventory.containerMain); }); addStandIn(25f, 32); } }, new Scenario { Name = "main.flamethrower", Seconds = Mathf.Min(secs, 40f), Extra = extra, Setup = () => { spawn(n => { n.inventory.Strip(); Give(n, "flamethrower", 1, n.inventory.containerBelt, 0); Give(n, "lowgradefuel", 300, n.inventory.containerMain); }); addStandIn(6f, 33); } }, }; RunScenarios("items", f, list); } // ---- 5.5 TruePVE and NextGenPVE (D261) ---- [ConsoleCommand("rnh.pve")] private void CmdPve(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; Vector3 f; if (!FindField(250f, 1001, out f)) { Log("pve", "FAIL no field found"); return; } _labels.Clear(); _pairs.Clear(); _invulnerable.Clear(); Plugin pve = plugins.Find("TruePVE") ?? plugins.Find("NextGenPVE"); string which = pve == null ? "none" : $"{pve.Name} {pve.Version}"; RnhNpc ours = Ours(f, "ours", "a", false); ScientistNPC stock = Stock(f + new Vector3(4f, 0f, 0f), "stock"); Func pvePlayer = (id, at, label) => { BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, Ground(at)) as BasePlayer; if (p == null) return null; p.userID = id; p.UserIDString = id.ToString(); p.displayName = "RnhPve" + label; p.enableSaving = false; p.Spawn(); p.EndSleeping(); LifeStoryStartMethod?.Invoke(p, null); Label(p, label); Equip(p); return p; }; BasePlayer player = pvePlayer(PveUserId, f + new Vector3(-4f, 0f, 0f), "player"); BasePlayer player2 = pvePlayer(PveUserId + 1UL, f + new Vector3(-4f, 0f, 4f), "player2"); if (player == null || player2 == null) { Log("pve", "FAIL no player"); return; } timer.Once(3f, () => { var pairs = new List> { // The control: player against player is what a PVE plugin exists to stop. new KeyValuePair(player2, player), new KeyValuePair(player, ours), new KeyValuePair(ours, player), new KeyValuePair(player, stock), new KeyValuePair(stock, player), new KeyValuePair(ours, stock), new KeyValuePair(stock, ours), }; var rows = new List>(); foreach (int mode in new[] { 0, 1, 2 }) { _pveMode = mode; foreach (KeyValuePair pr in pairs) { BaseCombatEntity a = pr.Key, v = pr.Value; if (Dead(a) || Dead(v)) { rows.Add(new Dictionary { ["pair"] = _labels[a] + ">" + _labels[v], ["error"] = "dead" }); continue; } v.health = v.MaxHealth(); AttackEntity gun = (a as BasePlayer)?.GetActiveItem()?.GetHeldEntity() as AttackEntity; int asks0 = _pveAsks; float h0 = v.health; v.Hurt(new HitInfo(a, v, Rust.DamageType.Bullet, 10f) { Weapon = gun, WeaponPrefab = gun }); rows.Add(new Dictionary { ["mode"] = mode == 0 ? "observe" : mode == 1 ? "allow" : "deny", ["pair"] = _labels[a] + ">" + _labels[v], ["damage"] = Math.Round(h0 - v.health, 2), ["asked"] = _pveAsks - asks0, }); } } _pveMode = 0; var rec = new Dictionary { ["framework"] = Framework(), ["plugin"] = which, ["rows"] = rows }; Record("pve." + (pve == null ? "none" : pve.Name) + "." + Framework(), rec); Log("pve", JsonConvert.SerializeObject(rec)); foreach (BaseEntity e in _labels.Keys.ToArray()) if (e != null && !e.IsDestroyed) e.Kill(); _labels.Clear(); ClearLeftovers(f, 40f); }); } // ---- housekeeping ---- [ConsoleCommand("rnh.clear")] private void CmdClear(ConsoleSystem.Arg arg) { if (!arg.IsAdmin) return; int n = 0; foreach (BaseEntity e in _spawned.ToArray()) if (e != null && !e.IsDestroyed) { e.Kill(); n++; } _spawned.Clear(); int loose = 0; foreach (RnhNpc o in BaseNetworkable.serverEntities.OfType().ToArray()) { o.Kill(); loose++; } Log("clear", $"killed {n} tracked and {loose} untracked harness NPCs"); } } }