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
{