diff --git a/tools/RunicNpcHarness.cs b/tools/RunicNpcHarness.cs index 8066790..d511902 100644 --- a/tools/RunicNpcHarness.cs +++ b/tools/RunicNpcHarness.cs @@ -28,6 +28,11 @@ namespace Oxide.Plugins /// 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. @@ -45,8 +50,8 @@ namespace Oxide.Plugins /// other plugin's code. /// /// - [Info("RunicNpcHarness", "Runic Gateway", "0.1.0")] - [Description("RunicNPC stage 1 measurements. A developer tool: never ship it.")] + [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; @@ -93,6 +98,9 @@ namespace Oxide.Plugins { _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()}"); @@ -106,6 +114,9 @@ namespace Oxide.Plugins _spawned.Clear(); if (_meterHost != null) UnityEngine.Object.Destroy(_meterHost); + Scientists.Clear(); + Animals.Clear(); + OursByTag.Clear(); _self = null; } @@ -284,6 +295,100 @@ namespace Oxide.Plugins 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; + } } /// @@ -323,9 +428,182 @@ namespace Oxide.Plugins 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 ---- @@ -458,6 +736,7 @@ namespace Oxide.Plugins { 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++; @@ -474,13 +753,32 @@ namespace Oxide.Plugins 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; @@ -1083,6 +1381,670 @@ namespace Oxide.Plugins }); } + // ==== 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")]