diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index d4dd848..753f264 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -7,6 +7,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core; using Oxide.Core.Plugins; +using Oxide.Game.Rust.Cui; using UnityEngine; using UnityEngine.AI; @@ -44,11 +45,12 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [Info("RunicNpcTest", "Runic Gateway", "0.5.0")] - [Description("RunicNPC stage 2, 3, 4 and 5 tests. A developer tool: never ship it.")] + [Info("RunicNpcTest", "Runic Gateway", "0.6.0")] + [Description("RunicNPC stage 2, 3, 4, 5 tests and the stage 6 spike. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { [PluginReference] private Plugin RunicNPC; + [PluginReference] private Plugin Kits; private const string Kit = "rnhrevolver"; private const string Owner = "plugin:RunicNpcTest"; @@ -238,6 +240,17 @@ namespace Oxide.Plugins private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info) { + // Stage 6's spike: the scientist a boss fights takes no damage, and every hit is tallied by weapon. + if (_spikeTarget != null && victim == _spikeTarget && info != null) + { + string w = info.Weapon?.GetItem()?.info.shortname ?? info.WeaponPrefab?.ShortPrefabName ?? "?"; + int n; + _spikeHits.TryGetValue(w, out n); + _spikeHits[w] = n + 1; + info.damageTypes.ScaleAll(0f); + return; + } + BasePlayer p = victim as BasePlayer; if (p == null || !_standIns.Contains(p) || info == null) return; if (info.Initiator is BasePlayer && ((BasePlayer)info.Initiator).IsNpc) _hitsOnStandIns++; @@ -1893,6 +1906,493 @@ namespace Oxide.Plugins Api("RunicNpc_DespawnOwner", Owner); } + // ---- stage 6's spike (D282): press E, the health bar, Kits' data file, a kit swap mid-fight, adds ---- + + private const string SwapKit = "rnt6smg"; + private const string CooldownKit = "rnt6cd"; + private BaseCombatEntity _spikeTarget; + private readonly Dictionary _spikeHits = new Dictionary(); + + private static bool IsCarbon() => AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")); + + private Plugin K => plugins.Find("Kits"); + + private int SpikeHits(string weapon) + { + int n; + return _spikeHits.TryGetValue(weapon, out n) ? n : 0; + } + + private static string Held(BasePlayer npc) => Gone(npc) ? "-" : npc.GetActiveItem()?.info.shortname ?? "none"; + + /// + /// rnt.spike6 [part,...]: stage 6's measurements, one part or all in order: + /// use, bar, kits, swap, adds, unkits. swap needs the + /// kit kits writes; unkits takes both spike kits out of Kits' file again. + /// + [ConsoleCommand("rnt.spike6")] + private void CmdSpike6(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + string part = arg.GetString(0, "all"); + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + _running = ServerMgr.Instance.StartCoroutine(Spike6(part)); + arg.ReplyWith($"rnt: spike6 {part}"); + } + + private IEnumerator Spike6(string part) + { + _pass = _fail = 0; + Note($"spike6 {part} on {(IsCarbon() ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}, Kits {K?.Version}"); + SetTestProfiles(); + Vector3 f; + if (!FindField(300f, 2025, out f)) + { + Check("s6.setup.field", false, "no open field on the navmesh"); + yield break; + } + + string[] parts = part == "all" ? new[] { "use", "bar", "kits", "swap", "adds", "unkits" } : part.Split(','); + foreach (string p in parts) + { + StandIn(f + new Vector3(0, 0, 60), 50); + IEnumerator inner = p == "use" ? UseSpike(f) + : p == "bar" ? BarSpike(f) + : p == "kits" ? KitsSpike(f) + : p == "swap" ? SwapSpike(f) + : p == "adds" ? AddsSpike(f) + : p == "unkits" ? UnkitsSpike() + : null; + if (inner == null) + { + Check("s6.setup.part", false, "no part " + p); + continue; + } + + while (inner.MoveNext()) yield return inner.Current; + _spikeTarget = null; + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + yield return new WaitForSeconds(1f); + } + + Note($"done spike6 {part}: {_pass} pass, {_fail} fail"); + _running = null; + } + + // ---- press E (D278) ---- + + private IEnumerator UseSpike(Vector3 f) + { + Vector3 at = OnMesh(f + new Vector3(30, 0, -60)); + BasePlayer npc = Spawn(at, "t_sentry"); + yield return new WaitForSeconds(1.5f); + Check("s6.use.spawned", !Gone(npc), npc != null ? npc.GetType().Name : "null"); + if (Gone(npc)) yield break; + + string cols = string.Join("; ", npc.GetComponentsInChildren(true) + .Select(c => $"{c.GetType().Name} '{c.name}' layer={LayerMask.LayerToName(c.gameObject.layer)} enabled={c.enabled} trigger={c.isTrigger}").ToArray()); + Note($"s6.use npc layer={LayerMask.LayerToName(npc.gameObject.layer)} colliders: {cols}"); + + int playerMask = LayerMask.GetMask("Player (Server)"); + int broadMask = LayerMask.GetMask("Player (Server)", "Construction", "Deployed", "World", "Terrain", "Default", "Tree"); + BasePlayer p = StandIn(at + new Vector3(2f, 0, 0), 61); + int hitsPlayerMask = 0, hitsBroad = 0, rays = 0; + foreach (float dist in new[] { 1.5f, 2.5f, 3.5f }) + { + MoveStandIn(p, at + new Vector3(dist, 0, 0)); + yield return new WaitForSeconds(0.5f); + if (Gone(npc)) break; + foreach (KeyValuePair aim in new Dictionary + { + ["head"] = npc.eyes.position, ["chest"] = npc.CenterPoint(), ["legs"] = npc.transform.position + Vector3.up * 0.4f + }) + { + Vector3 eye = p.eyes.position; + Vector3 dir = (aim.Value - eye).normalized; + RaycastHit h1, h2; + bool a = Physics.Raycast(eye, dir, out h1, 5f, playerMask, QueryTriggerInteraction.Ignore) && h1.GetEntity() == npc; + bool b = Physics.Raycast(eye, dir, out h2, 5f, broadMask, QueryTriggerInteraction.Ignore) && h2.GetEntity() == npc; + rays++; + if (a) hitsPlayerMask++; + if (b) hitsBroad++; + Note($"s6.use ray {dist} m at {aim.Key}: player-mask {(a ? $"HIT '{h1.collider.name}' at {h1.distance:0.00}" : "miss " + (h1.collider != null ? h1.collider.name : "-"))}, broad {(b ? $"HIT at {h2.distance:0.00}" : "miss " + (h2.collider != null ? $"{h2.collider.name}/{h2.GetEntity()?.ShortPrefabName}" : "-"))}"); + } + } + + Check("s6.use.ray", hitsBroad == rays, $"a ray from a player's eyes reaches our NPC: {hitsBroad}/{rays} with the world in the mask, {hitsPlayerMask}/{rays} on Player (Server) alone"); + + // What OnPlayerInput costs per player tick when E is not pressed, and one use ray when it is. + var input = new InputState(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + int pressed = 0; + for (int i = 0; i < 1000000; i++) + if (input.WasJustPressed(BUTTON.USE)) pressed++; + double perTickNs = sw.Elapsed.TotalMilliseconds * 1e6 / 1000000; + sw.Restart(); + int found = 0; + for (int i = 0; i < 10000; i++) + { + RaycastHit h; + if (Physics.Raycast(p.eyes.position, (npc.CenterPoint() - p.eyes.position).normalized, out h, 3f, broadMask, QueryTriggerInteraction.Ignore)) found++; + } + + double perRayUs = sw.Elapsed.TotalMilliseconds * 1000 / 10000; + Check("s6.use.cost", perTickNs < 1000 && perRayUs < 100, $"OnPlayerInput's not-pressed test {perTickNs:0.0} ns a tick; one 3 m use ray {perRayUs:0.00} µs ({found} hit)"); + } + + // ---- the health bar (D274) ---- + + private IEnumerator BarSpike(Vector3 f) + { + Type el = typeof(CuiElement); + MemberInfo upd = (MemberInfo)el.GetProperty("Update") ?? el.GetField("Update"); + Note("s6.bar CuiElement: " + string.Join(", ", el.GetProperties().Select(x => x.Name).Concat(el.GetFields().Select(x => x.Name)).ToArray())); + Check("s6.bar.updateMember", upd != null, upd != null ? $"CuiElement.{upd.Name} ({upd.MemberType})" : "no Update: every redraw is destroy + add"); + + Func> build = (frac, text, fillOnly) => + { + var c = new CuiElementContainer(); + if (!fillOnly) + c.Add(new CuiPanel { Image = { Color = "0 0 0 0.6" }, RectTransform = { AnchorMin = "0.35 0.9", AnchorMax = "0.65 0.93" } }, "Hud", "rnpc.bar"); + c.Add(new CuiPanel { Image = { Color = "0.75 0.1 0.1 0.9" }, RectTransform = { AnchorMin = "0 0", AnchorMax = $"{frac:0.###} 1" } }, "rnpc.bar", "rnpc.bar.fill"); + c.Add(new CuiLabel { Text = { Text = text, FontSize = 12, Align = TextAnchor.MiddleCenter }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, "rnpc.bar", "rnpc.bar.text"); + if (fillOnly && upd != null) + foreach (CuiElement e in c) + { + if (upd is PropertyInfo) ((PropertyInfo)upd).SetValue(e, true, null); + else ((FieldInfo)upd).SetValue(e, true); + } + + return c; + }; + + string full = CuiHelper.ToJson(build(0.73f, "Juggernaut 1825 / 2500", false)); + string fill = CuiHelper.ToJson(build(0.72f, "Juggernaut 1800 / 2500", true)); + Note("s6.bar full json: " + full); + Note("s6.bar update json: " + fill); + var sw = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < 2000; i++) CuiHelper.ToJson(build(i / 2000f, "Juggernaut " + i + " / 2500", false)); + double fullUs = sw.Elapsed.TotalMilliseconds * 1000 / 2000; + sw.Restart(); + for (int i = 0; i < 2000; i++) CuiHelper.ToJson(build(i / 2000f, "Juggernaut " + i + " / 2500", true)); + double fillUs = sw.Elapsed.TotalMilliseconds * 1000 / 2000; + Check("s6.bar.build", fullUs < 500 && fillUs < 500, + $"whole bar {full.Length} B in {fullUs:0.0} µs; update {fill.Length} B in {fillUs:0.0} µs, contains update={fill.Contains("\"update\"")}"); + + // Who sees it: 40 players scattered over 150 m, the ones within 100 m found by one sphere query. + var rng = new System.Random(6); + var crowd = new List(); + for (int i = 0; i < 40; i++) + { + float a = (float)(rng.NextDouble() * Math.PI * 2), r = (float)(rng.NextDouble() * 150); + BasePlayer s = StandIn(OnMesh(f + new Vector3(Mathf.Cos(a) * r, 0, Mathf.Sin(a) * r)), 70 + i); + if (s != null) crowd.Add(s); + } + + yield return new WaitForSeconds(1f); + int within = crowd.Count(s => Vector3.Distance(s.transform.position, f) <= 100f); + var found = new List(); + sw.Restart(); + for (int i = 0; i < 1000; i++) + { + found.Clear(); + Vis.Entities(f, 100f, found, LayerMask.GetMask("Player (Server)"), QueryTriggerInteraction.Ignore); + } + + double visUs = sw.Elapsed.TotalMilliseconds * 1000 / 1000; + int seen = found.Count(s => crowd.Contains(s)); + sw.Restart(); + int byLoop = 0; + for (int i = 0; i < 1000; i++) + { + byLoop = 0; + foreach (BasePlayer s in crowd) + if ((s.transform.position - f).sqrMagnitude <= 100f * 100f) byLoop++; + } + + double loopUs = sw.Elapsed.TotalMilliseconds * 1000 / 1000; + Check("s6.bar.viewers", seen == within && byLoop == within, + $"{within} of 40 within 100 m: sphere query found {seen} in {visUs:0.0} µs, a distance loop over the list {byLoop} in {loopUs:0.0} µs"); + bool sent = CuiHelper.AddUi(crowd[0], full); + Note($"s6.bar AddUi to a player with no connection returns {sent} (sending is not measured here: no client)"); + } + + // ---- Kits' data file (D280) ---- + + private static JObject KitItem(string shortname, int amount, int position, float condition = 0f) => + JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = condition, MaxCondition = condition, Ammo = 0, Position = position, Frequency = -1 }); + + private static JObject SpikeKit(string name, int cooldown, int maxUses, JObject[] main, JObject[] wear, JObject[] belt) => + new JObject + { + ["Name"] = name, ["Description"] = "RunicNPC stage 6 spike (remove after)", ["RequiredPermission"] = "", + ["MaximumUses"] = maxUses, ["RequiredAuth"] = 0, ["Cooldown"] = cooldown, ["Cost"] = 0, ["IsHidden"] = true, + ["CopyPasteFile"] = "", ["KitImage"] = "", + ["MainItems"] = new JArray(main), ["WearItems"] = new JArray(wear), ["BeltItems"] = new JArray(belt) + }; + + private JObject KitsFile() => Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data"); + + private double _reloadSeconds; + private bool _reloaded; + + /// Reloads Kits the way an operator would at the console (the bridge's way on Carbon, W3) and waits for the new instance. + private IEnumerator ReloadKits() + { + Plugin old = K, oldRunic = plugins.Find("RunicNPC"); + int npcsBefore = List(null).Count; + var sw = System.Diagnostics.Stopwatch.StartNew(); + ConsoleSystem.Run(ConsoleSystem.Option.Server, IsCarbon() ? "c.reload" : "oxide.reload", "Kits"); + _reloaded = false; + double kitsAt = -1, runicAt = -1; + while (sw.Elapsed.TotalSeconds < 60) + { + yield return new WaitForSeconds(0.25f); + Plugin now = K, runic = plugins.Find("RunicNPC"); + if (kitsAt < 0 && now != null && now != old && now.IsLoaded) kitsAt = sw.Elapsed.TotalSeconds; + // RunicNPC requires Kits (// Requires: Kits): both frameworks unload it with Kits and load it again. + if (runicAt < 0 && runic != null && runic != oldRunic && runic.IsLoaded) runicAt = sw.Elapsed.TotalSeconds; + // Carbon unloads it only after a fresh compile of RunicNPC; give it 20 s, then call it not reloaded. + if (kitsAt >= 0 && (runicAt >= 0 || sw.Elapsed.TotalSeconds > kitsAt + 20)) break; + } + + _reloaded = kitsAt >= 0; + _reloadSeconds = sw.Elapsed.TotalSeconds; + Note($"s6.kits reload: Kits back at {kitsAt:0.0} s, RunicNPC {(oldRunic == plugins.Find("RunicNPC") ? "NOT reloaded" : $"reloaded with it, back at {runicAt:0.0} s")}; RunicNPC's NPCs {npcsBefore} before, {List(null).Count} after"); + } + + /// Every player's uses of every kit, as Kits says now, for the ids in its player file. + private Dictionary Usage() + { + var usage = new Dictionary(); + JObject players = Interface.Oxide.DataFileSystem.ReadObject("Kits/player_data")?["_players"] as JObject; + if (players == null) return usage; + foreach (JProperty pl in players.Properties().Where(x => ulong.Parse(x.Name) < StandInId - 1000UL || ulong.Parse(x.Name) > StandInId + 1000UL)) + foreach (JProperty kit in ((pl.Value as JObject)?["_usageData"] as JObject ?? new JObject()).Properties()) + usage[pl.Name + "/" + kit.Name] = Convert.ToInt32(K?.Call("GetPlayerKitUses", ulong.Parse(pl.Name), kit.Name) ?? -1); + return usage; + } + + private IEnumerator KitsSpike(Vector3 f) + { + Check("s6.kits.loaded", K != null && K.IsLoaded, $"Kits {K?.Version}"); + JObject file = KitsFile(); + JObject kits = file?["_kits"] as JObject; + Check("s6.kits.read", kits != null, kits != null ? $"{kits.Count} kit(s): {string.Join(", ", kits.Properties().Select(x => x.Name).ToArray())}" : "no _kits"); + if (kits == null) yield break; + int before = kits.Count; + Dictionary realBefore = Usage(); + // Two NPCs a plugin spawned (as an event's are), to see what a Kits reload does to them. + Spawn(OnMesh(f + new Vector3(-60, 0, 30)), "t_roam"); + Spawn(OnMesh(f + new Vector3(-60, 0, 36)), "t_roam"); + yield return new WaitForSeconds(1f); + + // Round 1: a kit with a cooldown and a use limit, so a claim leaves usage behind. + kits[CooldownKit] = SpikeKit(CooldownKit, 3600, 5, new[] { KitItem("bandage", 1, 0) }, new JObject[0], new JObject[0]); + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + Check("s6.kits.notLive", !(K.Call("IsKit", CooldownKit) is bool && (bool)K.Call("IsKit", CooldownKit)), "Kits does not see a kit written to its file until it reloads"); + IEnumerator r = ReloadKits(); + while (r.MoveNext()) yield return r.Current; + Check("s6.kits.reload1", _reloaded, $"{(IsCarbon() ? "c.reload" : "oxide.reload")} Kits: new instance in {_reloadSeconds:0.0} s"); + Check("s6.kits.added", K != null && K.Call("IsKit", CooldownKit) is bool && (bool)K.Call("IsKit", CooldownKit), ""); + + BasePlayer claimer = StandIn(OnMesh(new Vector3(0, 0, 0)), 98); + ulong cid = (ulong)claimer.userID; + // An earlier run's claim is in Kits' player file (saved when it unloaded): clear it first. + K?.Call("SetPlayerKitUses", cid, CooldownKit, 0); + K?.Call("SetPlayerKitCooldown", cid, CooldownKit, 0.0); + object claimed = K?.Call("TryClaimKit", claimer, CooldownKit, false); + int uses = Convert.ToInt32(K?.Call("GetPlayerKitUses", cid, CooldownKit) ?? -1); + double cd = Convert.ToDouble(K?.Call("GetPlayerKitCooldown", cid, CooldownKit) ?? -1.0); + Check("s6.kits.claim", claimed is bool && (bool)claimed && uses == 1 && cd > 3500, $"a player claims it: {claimed ?? "null (no such hook)"}, uses {uses}, cooldown {cd:0} s — in Kits' memory, not yet saved"); + JObject onDisk = Interface.Oxide.DataFileSystem.ReadObject("Kits/player_data")?["_players"]?[cid.ToString()] as JObject; + Note($"s6.kits the claim on disk before the reload: {(onDisk != null ? "yes" : "no")}"); + + // Round 2: the swap's kit, written while a player's usage is only in memory. + file = KitsFile(); + kits = (JObject)file["_kits"]; + kits[SwapKit] = SpikeKit(SwapKit, 0, 0, new[] { KitItem("ammo.pistol", 120, 0) }, new[] { KitItem("hazmatsuit", 1, 0) }, new[] { KitItem("smg.mp5", 1, 0, 100f) }); + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + r = ReloadKits(); + while (r.MoveNext()) yield return r.Current; + Check("s6.kits.reload2", _reloaded, $"second reload: new instance in {_reloadSeconds:0.0} s"); + var names = new List(); + K?.Call("GetKitNames", names); + Check("s6.kits.all", names.Count == before + 2 && names.Contains(SwapKit) && names.Contains(CooldownKit), $"{names.Count} kit(s) now ({before} before): {string.Join(", ", names.ToArray())}"); + int usesAfter = Convert.ToInt32(K?.Call("GetPlayerKitUses", cid, CooldownKit) ?? -1); + double cdAfter = Convert.ToDouble(K?.Call("GetPlayerKitCooldown", cid, CooldownKit) ?? -1.0); + Check("s6.kits.usageKept", usesAfter == uses && cdAfter > cd - _reloadSeconds - 5 && cdAfter <= cd, $"the claim survived the reload: uses {uses}→{usesAfter}, cooldown {cd:0}→{cdAfter:0} s"); + Dictionary realAfter = Usage(); + Check("s6.kits.realUsageKept", realBefore.All(kv => realAfter.ContainsKey(kv.Key) && realAfter[kv.Key] == kv.Value), + realBefore.Count == 0 ? "no real players on file" : string.Join(", ", realBefore.Select(kv => $"{kv.Key} {kv.Value}→{(realAfter.ContainsKey(kv.Key) ? realAfter[kv.Key] : -1)}").ToArray())); + JObject fileAfter = KitsFile(); + Check("s6.kits.fileKept", fileAfter?["_kits"]?[SwapKit] != null && fileAfter?["_kits"]?[CooldownKit] != null, "Kits did not rewrite its file on load"); + Check("s6.kits.ref", Kits != null && Kits == K, "this plugin's [PluginReference] Kits follows the reload"); + } + + private IEnumerator UnkitsSpike() + { + JObject file = KitsFile(); + JObject kits = file?["_kits"] as JObject; + if (kits == null) yield break; + kits.Remove(SwapKit); + kits.Remove(CooldownKit); + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + IEnumerator r = ReloadKits(); + while (r.MoveNext()) yield return r.Current; + bool gone = !(K?.Call("IsKit", SwapKit) is bool && (bool)K.Call("IsKit", SwapKit)); + Check("s6.unkits", _reloaded && gone, $"spike kits removed, reload {_reloadSeconds:0.0} s"); + } + + // ---- a phase's kit swap mid-fight (D275) ---- + + private IEnumerator SwapSpike(Vector3 f) + { + if (!(K?.Call("IsKit", SwapKit) is bool && (bool)K.Call("IsKit", SwapKit))) + { + Check("s6.swap.kit", false, "no " + SwapKit + ": run the kits part first"); + yield break; + } + + Vector3 at, there; + if (!ClearLine(OnMesh(f + new Vector3(-30, 0, -60)), 15f, out at, out there)) + { + Check("s6.swap.spot", false, "no clear 15 m line"); + yield break; + } + + BasePlayer boss = Spawn(at, "t_hunter"); + ScientistNPC sci = SpawnScientist(there); + if (boss == null || sci == null) + { + Check("s6.swap.spawned", false, $"boss {(boss != null ? "up" : "null")}, scientist {(sci != null ? "up" : "null")} at {at}; profiles refused: {Api("RunicNpc_Profiles")?["refused"]?.ToString(Formatting.None)}"); + yield break; + } + + _spikeTarget = sci; + _spikeHits.Clear(); + int t; + for (t = 1; t <= 30; t++) + { + yield return new WaitForSeconds(1f); + if (SpikeHits("pistol.revolver") >= 3 && TargetOf(Describe(boss)) != "none") break; + } + + string target = TargetOf(Describe(boss)); + Check("s6.swap.fighting", SpikeHits("pistol.revolver") >= 3, $"revolver hits {SpikeHits("pistol.revolver")} by {t} s, target {target}, hits {JsonConvert.SerializeObject(_spikeHits)}"); + if (Gone(boss) || Gone(sci)) yield break; + + // The swap, as a phase would make it: strip, give, equip — one frame. + object given = null; + string split = SwapTimed(boss, SwapKit, out given); + Check("s6.swap.given", !(given is string) && Held(boss) == "smg.mp5", $"given={given ?? "null"}, held {Held(boss)}, {split}"); + int rev = SpikeHits("pistol.revolver"); + bool lost = false; + for (t = 1; t <= 20; t++) + { + yield return new WaitForSeconds(1f); + if (TargetOf(Describe(boss)) == "none") lost = true; + if (SpikeHits("smg.mp5") >= 5) break; + } + + Check("s6.swap.keepsTarget", !lost, $"target before {target}, after {TargetOf(Describe(boss))}, lost for a second: {lost}"); + Check("s6.swap.newWeapon", SpikeHits("smg.mp5") > 0 && SpikeHits("pistol.revolver") == rev, $"MP5 hits {SpikeHits("smg.mp5")} in {t} s, revolver hits after the swap {SpikeHits("pistol.revolver") - rev}"); + + // And back, WITHOUT EquipWeapon: does Rust's brain pick the new weapon up by itself? + boss.inventory.Strip(); + K.Call("GiveKit", boss, Kit); + string heldNow = Held(boss); + int mp5 = SpikeHits("smg.mp5"), revBefore = SpikeHits("pistol.revolver"); + string heldLater = heldNow; + for (t = 1; t <= 15; t++) + { + yield return new WaitForSeconds(1f); + heldLater = Held(boss); + if (SpikeHits("pistol.revolver") > revBefore) break; + } + + Note($"s6.swap without EquipWeapon: held {heldNow} at once, {heldLater} after {t} s, revolver hits {SpikeHits("pistol.revolver") - revBefore}, MP5 hits {SpikeHits("smg.mp5") - mp5}"); + + // The same swap twice more, warm: is the first one's cost Kits' first call, or every swap's? + for (int i = 2; i <= 3 && !Gone(boss); i++) + { + Note($"s6.swap #{i} warm: {SwapTimed(boss, i == 2 ? SwapKit : Kit, out given)}, held {Held(boss)}"); + yield return new WaitForSeconds(2f); + } + } + + /// Strip, Kits' GiveKit, EquipWeapon, each timed: "strip a ms, give b ms, equip c ms, total d ms". + private string SwapTimed(BasePlayer npc, string kit, out object given) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + npc.inventory.Strip(); + double strip = sw.Elapsed.TotalMilliseconds; + given = K.Call("GiveKit", npc, kit); + double give = sw.Elapsed.TotalMilliseconds; + ((HumanNPC)npc).EquipWeapon(); + double all = sw.Elapsed.TotalMilliseconds; + return $"strip {strip:0.00} ms, give {give - strip:0.00} ms, equip {all - give:0.00} ms, total {all:0.00} ms"; + } + + // ---- summoned adds (D275) ---- + + private IEnumerator AddsSpike(Vector3 f) + { + Vector3 at, there; + if (!ClearLine(OnMesh(f + new Vector3(60, 0, -40)), 20f, out at, out there)) + { + Check("s6.adds.spot", false, "no clear 20 m line"); + yield break; + } + + BasePlayer boss = Spawn(at, "t_hunter"); + ScientistNPC sci = SpawnScientist(there); + _spikeTarget = sci; + _spikeHits.Clear(); + for (int w = 0; w < 20 && TargetOf(Describe(boss)) == "none"; w++) + yield return new WaitForSeconds(1f); + Check("s6.adds.bossFighting", TargetOf(Describe(boss)) != "none", "target " + TargetOf(Describe(boss))); + + foreach (int count in new[] { 4, 10 }) + { + var adds = new List(); + int offMesh = 0; + var sw = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < count; i++) + { + float a = i * Mathf.PI * 2f / count; + Vector3 ring = boss.transform.position + new Vector3(Mathf.Cos(a), 0, Mathf.Sin(a)) * 6f; + NavMeshHit hit; + if (!SampleNavmesh(ring, 3f, out hit)) + { + offMesh++; + continue; + } + + BasePlayer add = Spawn(hit.position, "t_hunter"); + if (add != null) adds.Add(add); + } + + double ms = sw.Elapsed.TotalMilliseconds; + yield return new WaitForSeconds(2f); + int onMesh = adds.Count(x => { NavMeshHit h; return !Gone(x) && SampleNavmesh(x.transform.position, 0.5f, out h); }); + int fighting = 0; + for (int w = 0; w < 20; w++) + { + fighting = adds.Count(x => !Gone(x) && TargetOf(Describe(x)) != "none"); + if (fighting == adds.Count) break; + yield return new WaitForSeconds(1f); + } + + Check($"s6.adds.{count}", adds.Count == count && onMesh == adds.Count && fighting == adds.Count, + $"{adds.Count}/{count} placed in {ms:0.0} ms ({ms / Math.Max(1, count):0.00} ms each, one frame), {offMesh} ring points off the navmesh, {onMesh} on the mesh after 2 s, {fighting} fighting the boss's target"); + foreach (BasePlayer x in adds) + if (!Gone(x)) Api("RunicNpc_Despawn", Id(x)); + yield return new WaitForSeconds(1f); + } + } + /// A route, as RunicNpc_Routes returns it. private sealed class Route {