test(harness): stage 6 spike, rnt.spike6 (D282) #9

Merged
whitlocktech merged 1 commits from spike/stage-6 into edge 2026-10-05 14:26:32 +00:00

View File

@@ -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 <c>IsNpc</c> is false. They take no damage.
/// </para>
/// </summary>
[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<int>("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<string, int> _spikeHits = new Dictionary<string, int>();
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";
/// <summary>
/// <c>rnt.spike6 [part,...]</c>: stage 6's measurements, one part or <c>all</c> in order:
/// <c>use</c>, <c>bar</c>, <c>kits</c>, <c>swap</c>, <c>adds</c>, <c>unkits</c>. <c>swap</c> needs the
/// kit <c>kits</c> writes; <c>unkits</c> takes both spike kits out of Kits' file again.
/// </summary>
[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<int>("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<Collider>(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<string, Vector3> aim in new Dictionary<string, Vector3>
{
["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<float, string, bool, List<CuiElement>> 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<BasePlayer>();
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<BasePlayer>();
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<JObject>("Kits/kits_data");
private double _reloadSeconds;
private bool _reloaded;
/// <summary>Reloads Kits the way an operator would at the console (the bridge's way on Carbon, W3) and waits for the new instance.</summary>
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");
}
/// <summary>Every player's uses of every kit, as Kits says now, for the ids in its player file.</summary>
private Dictionary<string, int> Usage()
{
var usage = new Dictionary<string, int>();
JObject players = Interface.Oxide.DataFileSystem.ReadObject<JObject>("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<string, int> 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<JObject>("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<string>();
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<string, int> 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<JObject>("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);
}
}
/// <summary>Strip, Kits' GiveKit, EquipWeapon, each timed: "strip a ms, give b ms, equip c ms, total d ms".</summary>
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<BasePlayer>();
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<int>("RunicNpc_Despawn", Id(x));
yield return new WaitForSeconds(1f);
}
}
/// <summary>A route, as RunicNpc_Routes returns it.</summary>
private sealed class Route
{