diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs
index 514071e..5670545 100644
--- a/plugin/RunicNPC.cs
+++ b/plugin/RunicNPC.cs
@@ -2063,14 +2063,14 @@ namespace Oxide.Plugins
}
///
- /// Rust's CanSeeTarget for a player; for anything else (which Rust answers "seen" without
- /// a ray), a line from its eyes that counts the target's own collider as seen.
+ /// A line from its eyes to the target's centre that counts the target's own collider as seen.
+ /// Not Rust's CanSeeTarget, even for a scientist or one of ours: its rays are unbounded,
+ /// so on open ground they run past the target into the terrain behind it, and an NPC 25 m off
+ /// read as unseen until ours had walked right up to it (stage 5 build). Players are Rust's
+ /// design's to see; this is only for what our own fight takes on.
///
private static bool Los(RunicNpcPlayer 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;
@@ -2335,15 +2335,17 @@ namespace Oxide.Plugins
/// D269: whom it escorts died or left the server; it walks back to its spot.
private void CheckEscort(RunicNpcPlayer npc)
{
+ // Unity's == says a destroyed entity is null, which is exactly the case to catch here.
BaseEntity e = npc.Escort;
- if (e == null)
+ if (ReferenceEquals(e, null))
return;
- BasePlayer p = e as BasePlayer;
- BaseCombatEntity c = e as BaseCombatEntity;
- bool gone = e.IsDestroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected);
+ bool destroyed = e == null || e.IsDestroyed;
+ BasePlayer p = destroyed ? null : e as BasePlayer;
+ BaseCombatEntity c = destroyed ? null : e as BaseCombatEntity;
+ bool gone = destroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected);
if (!gone)
return;
- string who = p != null ? p.displayName : e.ShortPrefabName;
+ string who = p != null ? p.displayName : destroyed ? "its charge" : e.ShortPrefabName;
npc.Escort = null;
NoteDefender(npc);
npc.RnBrain?.Navigator?.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal);
@@ -2491,10 +2493,17 @@ namespace Oxide.Plugins
}
}
- if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket && npc.TargetLos)
+ if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket)
{
- npc.NextRocket = now + 10f;
- if (FireRocket(npc, target))
+ npc.NextRocket = now + 2f;
+ // A real player's line of sight is the one Rust's design shoots on; an NPC (a scientist, one of
+ // ours) or an animal is never in Rust's player memory, so it is ours.
+ BaseCombatEntity aim = target as BaseCombatEntity;
+ BasePlayer human = aim as BasePlayer;
+ bool seen = human != null && !human.IsNpc
+ ? brain.Senses?.Memory != null && brain.Senses.Memory.IsLOS(aim)
+ : aim != null && Los(npc, aim);
+ if (seen && FireRocket(npc, target))
{
npc.KitBusyUntil = now + 1.5f;
return;
@@ -2594,6 +2603,7 @@ namespace Oxide.Plugins
ammo.UseItem(1);
launcher.GetHeldEntity()?.SignalBroadcast(BaseEntity.Signal.Attack, string.Empty);
npc.Rockets++;
+ npc.NextRocket = Time.time + 10f;
timer.Once(1.2f, () =>
{
if (npc != null && !npc.IsDestroyed && !npc.IsDead())
diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs
index 3055ff5..c5175ce 100644
--- a/tools/RunicNpcTest.cs
+++ b/tools/RunicNpcTest.cs
@@ -24,7 +24,9 @@ namespace Oxide.Plugins
///
///
/// rnt.run <group> runs one group: api, hooks, move,
- /// sentry, sleep, cmd (stage 3's commands), api3 (stage 4's calls), place, or all for each in turn.
+ /// sentry, sleep, cmd (stage 3's commands), api3 (stage 4's calls), place,
+ /// s5 (stage 5's behaviour: factions, fights, alarm, allies, escort, guard, tether, turrets,
+ /// PVE and the kit's extras), or all for each in turn.
/// rnt.run place leaves two placements behind for the restart check, and
/// rnt.after checks, after a reload or restart, that every placement is back and nothing
/// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and
@@ -42,8 +44,8 @@ namespace Oxide.Plugins
/// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage.
///
///
- [Info("RunicNpcTest", "Runic Gateway", "0.4.0")]
- [Description("RunicNPC stage 2, 3 and 4 tests. A developer tool: never ship it.")]
+ [Info("RunicNpcTest", "Runic Gateway", "0.5.0")]
+ [Description("RunicNPC stage 2, 3, 4 and 5 tests. A developer tool: never ship it.")]
internal class RunicNpcTest : RustPlugin
{
[PluginReference] private Plugin RunicNPC;
@@ -277,6 +279,7 @@ namespace Oxide.Plugins
all["t_leash"] = leash;
if (withEach)
all["t_each"] = Prof(name: "TEach");
+ Stage5Profiles(all);
return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "no answer" };
}
@@ -325,6 +328,100 @@ namespace Oxide.Plugins
arg.ReplyWith($"rnt: spawned {n} as {Owner}");
}
+ /// A probe: two profiles 15 m apart, described every two seconds for 30 s.
+ [ConsoleCommand("rnt.duel")]
+ private void CmdDuel(ConsoleSystem.Arg arg)
+ {
+ if (arg.Connection != null && !arg.IsAdmin) return;
+ Vector3 f;
+ if (!FindField(300f, 2025, out f)) return;
+ BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50);
+ BasePlayer x = Spawn(OnMesh(f + new Vector3(-140, 0, 0)), arg.GetString(0, "t_red"));
+ BasePlayer y = Spawn(OnMesh(f + new Vector3(-140, 0, 15)), arg.GetString(1, "t_blue"));
+ int n = 0;
+ timer.Repeat(2f, 15, () =>
+ {
+ n++;
+ foreach (BasePlayer p in new[] { x, y })
+ {
+ Dictionary d = p == null || p.IsDestroyed ? null : Describe(p);
+ Puts($"[duel {n}] {(d == null ? "gone" : JsonConvert.SerializeObject(d))} hp={(p == null || p.IsDestroyed ? 0 : p.health):0} held={(p == null || p.IsDestroyed ? "-" : p.GetActiveItem()?.info.shortname ?? "none")}");
+ }
+ if (n == 15) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
+ });
+ arg.ReplyWith("rnt: duel started");
+ }
+
+ /// A probe: a t_kit NPC with a flamethrower and fuel, a stand-in 4 m off, every second for 20 s.
+ [ConsoleCommand("rnt.kitprobe")]
+ private void CmdKitProbe(ConsoleSystem.Arg arg)
+ {
+ if (arg.Connection != null && !arg.IsAdmin) return;
+ Vector3 f;
+ if (!FindField(300f, 2025, out f)) return;
+ Vector3 at = OnMesh(f + new Vector3(-30, 0, -60));
+ BasePlayer flamer = Spawn(at, "t_kit");
+ Give(flamer, "flamethrower", 1);
+ Give(flamer, "lowgradefuel", 50);
+ BasePlayer target = StandIn(OnMesh(at + new Vector3(4, 0, 0)), 63);
+ int n = 0;
+ timer.Repeat(1f, 20, () =>
+ {
+ n++;
+ ScientistNPC s = flamer as ScientistNPC;
+ Item held = flamer == null || flamer.IsDestroyed ? null : flamer.GetActiveItem();
+ FlameThrower tank = held?.GetHeldEntity() as FlameThrower;
+ if (n == 3 && tank != null) tank.ammo = 0;
+ var mem = s?.Brain?.Senses?.Memory;
+ Puts($"[kit {n}] state={s?.Brain?.CurrentState?.StateType} held={held?.info.shortname ?? "none"} ammo={(tank != null ? tank.ammo : -1)} fuel={(flamer == null || flamer.IsDestroyed ? -1 : Count(flamer, "lowgradefuel"))} knows={Knows(flamer, target)} los={(mem != null && mem.IsLOS(target))} hits={_hitsOnStandIns}");
+ if (n == 20) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
+ });
+ arg.ReplyWith("rnt: kit probe started");
+ }
+
+ /// Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks.
+ [ConsoleCommand("rnt.list")]
+ private void CmdList(ConsoleSystem.Arg arg)
+ {
+ if (arg.Connection != null && !arg.IsAdmin) return;
+ string owner = arg.GetString(0, null);
+ var rows = List(owner);
+ arg.ReplyWith(string.Join(Environment.NewLine, rows.Select(d => JsonConvert.SerializeObject(d)).ToArray()) + Environment.NewLine + $"rnt: {rows.Count} NPC(s)");
+ }
+
+ /// A probe: a t_kit rocketeer and a stock scientist 25 m off, described every 2 s for 40 s.
+ [ConsoleCommand("rnt.rocketprobe")]
+ private void CmdRocketProbe(ConsoleSystem.Arg arg)
+ {
+ if (arg.Connection != null && !arg.IsAdmin) return;
+ Vector3 f;
+ if (!FindField(300f, 2025, out f)) return;
+ BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50);
+ Vector3 at = OnMesh(f + new Vector3(-30, 0, -60));
+ BasePlayer r = Spawn(at, "t_kit");
+ Give(r, "rocket.launcher", 1);
+ Give(r, "ammo.rocket.basic", 2);
+ ScientistNPC target = SpawnScientist(at + new Vector3(25, 0, 0));
+ int n = 0;
+ timer.Repeat(2f, 20, () =>
+ {
+ n++;
+ Dictionary d = Gone(r) ? null : Describe(r);
+ string line = "-";
+ if (!Gone(r) && !Gone(target))
+ {
+ RaycastHit hh;
+ line = Physics.Linecast(r.eyes.position, target.CenterPoint(), out hh, 1218519041, QueryTriggerInteraction.Ignore)
+ ? $"hit {hh.collider?.name}/{LayerMask.LayerToName(hh.collider != null ? hh.collider.gameObject.layer : 0)}/{hh.GetEntity()?.ShortPrefabName ?? "none"} at {hh.distance:0.0}"
+ : "clear";
+ line += $" eyes={r.eyes.position.y:0.0} centre={target.CenterPoint().y:0.0}";
+ }
+ Puts($"[rocket {n}] line={line} target={TargetOf(d)} engaged={d?["engaged"]} state={d?["state"]} rest={d?["rest"]} kit={(d != null ? JsonConvert.SerializeObject(d["kitUsed"]) : "-")} held={(Gone(r) ? "-" : r.GetActiveItem()?.info.shortname)} ammo={(Gone(r) ? -1 : Count(r, "ammo.rocket.basic"))} sci={(Gone(target) ? 0f : target.health):0} dist={(Gone(r) || Gone(target) ? 0f : Vector3.Distance(r.transform.position, target.transform.position)):0}");
+ if (n == 20) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
+ });
+ arg.ReplyWith("rnt: rocket probe started");
+ }
+
[ConsoleCommand("rnt.clear")]
private void CmdClear(ConsoleSystem.Arg arg)
{
@@ -351,11 +448,11 @@ namespace Oxide.Plugins
}
Dictionary refused = SetTestProfiles();
- Check("setup.profiles", refused.Count == 1 && refused.ContainsKey("t_bad"), "refused: " + string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()));
+ Check("setup.profiles", refused.Count == 3 && refused.ContainsKey("t_bad") && refused.ContainsKey("t_badfaction") && refused.ContainsKey("t_badturrets"), "refused: " + string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()));
string route = SetLoop(field + new Vector3(60f, 0f, 0f));
Check("setup.route", route == null, route ?? "t_loop set");
- string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "place" } : new[] { group };
+ string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "place" } : new[] { group };
foreach (string g in groups)
{
IEnumerator inner = g == "api" ? Api(field)
@@ -367,6 +464,7 @@ namespace Oxide.Plugins
: g == "api3" ? Api3(field)
: g == "floor" ? Floor(field)
: g == "place" ? Place(field)
+ : g == "s5" ? Stage5()
: null;
if (inner == null)
{
@@ -388,7 +486,7 @@ namespace Oxide.Plugins
private IEnumerator Api(Vector3 field)
{
- Check("api.version", Api("RunicNpc_ApiVersion") == 3, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion"));
+ Check("api.version", Api("RunicNpc_ApiVersion") == 4, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion"));
JObject profiles = Api("RunicNpc_Profiles");
Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]);
Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]);
@@ -1188,6 +1286,586 @@ namespace Oxide.Plugins
Note($"after: {_pass} pass, {_fail} fail");
}
+ // ==== stage 5: behaviour (docs/runicnpc/PLAN.md, D253–D272) ====
+
+ private const string ScientistPrefab = "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab";
+ private const string Zone = "rnt_zone";
+
+ private readonly List _escortEnded = new List();
+
+ private void OnRunicNpcEscortEnded(BasePlayer npc, string profile, string owner) => _escortEnded.Add($"{profile}/{owner}");
+
+ /// The stage 5 test profiles, beside the others; three are refused on purpose.
+ private static void Stage5Profiles(JObject all)
+ {
+ JObject P(string name, Action edit)
+ {
+ JObject p = Prof(name: name);
+ edit(p);
+ return p;
+ }
+
+ all["t_red"] = P("TRed", p => p["faction"] = "red");
+ all["t_blue"] = P("TBlue", p => p["faction"] = "blue");
+ all["t_hunter"] = P("THunter", p =>
+ {
+ p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" });
+ p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 40f, attack = 30f });
+ });
+ all["t_leashed"] = P("TLeashed", p =>
+ {
+ p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" });
+ p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 8f, attack = 30f });
+ });
+ all["t_bully"] = P("TBully", p => p["relations"] = JObject.FromObject(new Dictionary { ["profile:t_roam"] = "hostile" }));
+ all["t_guard"] = P("TGuard", p => p["role"] = "guard");
+ all["t_kit"] = P("TKit", p =>
+ {
+ p["kitUse"] = JObject.FromObject(new { heal = true, grenades = true, melee = true, rockets = true, flamethrower = true });
+ // Hostile to scientists, so the rocketeer's target is an NPC: Rust's player line of sight never sees a stand-in at 25 m.
+ p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" });
+ });
+ all["t_ignore"] = P("TIgnore", p => p["turrets"] = "ignore");
+ all["t_always"] = P("TAlways", p => p["turrets"] = "always");
+ all["t_unkill"] = P("TUnkill", p => p["hurtByPlayers"] = false);
+ all["t_harmless"] = P("THarmless", p => p["hurtsPlayers"] = false);
+ all["t_badfaction"] = P("TBadFaction", p => p["faction"] = "scientists");
+ all["t_badturrets"] = P("TBadTurrets", p => p["turrets"] = "sometimes");
+ }
+
+ private ScientistNPC SpawnScientist(Vector3 at)
+ {
+ ScientistNPC s = GameManager.server.CreateEntity(ScientistPrefab, OnMesh(at)) as ScientistNPC;
+ if (s == null) return null;
+ s.enableSaving = false;
+ s.Spawn();
+ _built.Add(s);
+ return s;
+ }
+
+ private static ulong Id(BaseEntity e) => e != null && e.net != null ? e.net.ID.Value : 0UL;
+
+ private static bool Gone(BaseCombatEntity e) => e == null || e.IsDestroyed || e.IsDead();
+
+ private static bool Knows(BasePlayer npc, BaseEntity who)
+ {
+ ScientistNPC s = npc as ScientistNPC;
+ return s != null && s.Brain != null && s.Brain.Senses != null && s.Brain.Senses.Memory.Targets.Contains(who);
+ }
+
+ ///
+ /// Hands a player to an NPC's own memory, as Rust's senses do when they notice one. Rust's sensing
+ /// of a stand-in is unreliable (it can miss one at 2 m), so the tests that are about what happens
+ /// next start from here: SetKnown asks the NPC's IsTarget, which is what they test.
+ ///
+ private static void Sense(BasePlayer npc, BasePlayer who)
+ {
+ ScientistNPC s = npc as ScientistNPC;
+ if (s?.Brain?.Senses?.Memory != null && who != null)
+ s.Brain.Senses.Memory.SetKnown(who, s, s.Brain.Senses);
+ }
+
+ private static string TargetOf(Dictionary d) => d == null ? "?" : d["target"] as string ?? "none";
+
+ private static void Give(BasePlayer npc, string item, int amount)
+ {
+ Item made = ItemManager.CreateByName(item, amount);
+ if (made != null && !made.MoveToContainer(npc.inventory.containerMain))
+ made.Remove();
+ }
+
+ private static int Count(BasePlayer npc, string item) =>
+ Items(npc).Where(i => i.info.shortname == item).Sum(i => i.amount);
+
+ private static List- Items(BasePlayer npc)
+ {
+ var all = new List
- ();
+ npc.inventory.GetAllItems(all);
+ return all;
+ }
+
+ private static void Teleport(BasePlayer npc, Vector3 to)
+ {
+ ScientistNPC s = npc as ScientistNPC;
+ NavMeshHit hit;
+ if (s?.Brain?.Navigator?.Agent != null && SampleNavmesh(to, 3f, out hit))
+ {
+ s.Brain.Navigator.Stop();
+ s.Brain.Navigator.Agent.Warp(hit.position);
+ s.transform.position = hit.position;
+ }
+ }
+
+ private static void MoveStandIn(BasePlayer p, Vector3 to)
+ {
+ p.transform.position = OnMesh(to);
+ p.SendNetworkUpdateImmediate();
+ }
+
+ /// A spot near with a clear line to a second spot m east: no rock between.
+ private static bool ClearLine(Vector3 near, float range, out Vector3 a, out Vector3 b)
+ {
+ for (int i = 0; i < 40; i++)
+ {
+ a = OnMesh(near + new Vector3((i % 8) * 6f - 21f, 0, (i / 8) * 8f - 16f));
+ b = OnMesh(a + new Vector3(range, 0, 0));
+ NavMeshHit h1, h2;
+ if (!SampleNavmesh(a, 1f, out h1) || !SampleNavmesh(b, 1f, out h2)) continue;
+ if (!Physics.Linecast(a + Vector3.up * 1.6f, b + Vector3.up * 1f, 1218519041, QueryTriggerInteraction.Ignore))
+ return true;
+ }
+
+ a = b = Vector3.zero;
+ return false;
+ }
+
+ private int HorrorLeft() => BaseNetworkable.serverEntities.OfType().Count(c => !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror);
+
+ private IEnumerator Stage5()
+ {
+ Vector3 f;
+ if (!FindField(300f, 2025, out f))
+ {
+ Check("s5.setup.field", false, "no second open field on the navmesh");
+ yield break;
+ }
+
+ int horrorBefore = HorrorLeft();
+ Note($"s5 field {f}, Horror entities before: {horrorBefore}");
+
+ // ---- the faction table (D254, D268) and the profile refusals ----
+ string said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "red", relation = "hostile" } }));
+ Check("s5.factions.refuseSelf", said != null && said.Contains("allied to itself"), said ?? "null");
+ said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "scientists", b = "animals", relation = "hostile" } }));
+ Check("s5.factions.refuseRust", said != null && said.Contains("Rust's own"), said ?? "null");
+ said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "angry" } }));
+ Check("s5.factions.refuseRelation", said != null && said.Contains("not hostile, neutral or allied"), said ?? "null");
+ said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" }, new { a = "blue", b = "red", relation = "allied" } }));
+ Check("s5.factions.refuseTwice", said != null && said.Contains("both ways"), said ?? "null");
+ said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" } }));
+ JArray table = Api("RunicNpc_Factions");
+ Check("s5.factions.set", said == null && table != null && table.Count == 1, (said ?? "set") + " " + table?.ToString(Formatting.None));
+ JObject profiles = Api("RunicNpc_Profiles");
+ Check("s5.profile.refuseFaction", ("" + profiles?["refused"]?["t_badfaction"]).Contains("Rust's own"), "" + profiles?["refused"]?["t_badfaction"]);
+ Check("s5.profile.refuseTurrets", ("" + profiles?["refused"]?["t_badturrets"]).Contains("not default, ignore or always"), "" + profiles?["refused"]?["t_badturrets"]);
+
+ // A stand-in 60 m off keeps every NPC of the field (x ±140) within 160 m and awake (D235), unsensed.
+ BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50);
+
+ // ---- five fights at once, 70 m apart (D231, D255, D263, D268) ----
+ Vector3 a = OnMesh(f + new Vector3(-140, 0, 0));
+ Vector3 b = OnMesh(f + new Vector3(-70, 0, 0));
+ Vector3 c = OnMesh(f + new Vector3(0, 0, 0));
+ Vector3 d = OnMesh(f + new Vector3(70, 0, 0));
+ Vector3 e = OnMesh(f + new Vector3(140, 0, 0));
+
+ BasePlayer red = Spawn(a, "t_red");
+ BasePlayer blue = Spawn(OnMesh(a + new Vector3(0, 0, 15)), "t_blue");
+ BasePlayer hunter = Spawn(b, "t_hunter");
+ ScientistNPC prey = SpawnScientist(b + new Vector3(0, 0, 15));
+ BasePlayer neutral = Spawn(c, "t_roam");
+ ScientistNPC bystander = SpawnScientist(c + new Vector3(0, 0, 15));
+ BasePlayer leashed = Spawn(d, "t_leashed");
+ ScientistNPC far = SpawnScientist(d + new Vector3(0, 0, 25));
+ BasePlayer victim = Spawn(e, "t_roam");
+ BasePlayer bully = Spawn(OnMesh(e + new Vector3(0, 0, 15)), "t_bully");
+ Check("s5.fight.spawned", red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully, "");
+ if (!(red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully)) yield break;
+
+ float farHealth = far.health, bystanderHealth = bystander.health, preyHealth = prey.health;
+ float redHp = red.health, blueHp = blue.health;
+ float factionsAt = -1f, scientistAt = -1f;
+ bool neutralFought = false, leashedFought = false, victimAnswered = false;
+ int marked = 0;
+ for (int t = 1; t <= 70; t++)
+ {
+ yield return new WaitForSeconds(1f);
+ if (factionsAt < 0f && (Gone(red) || Gone(blue))) factionsAt = t;
+ if (scientistAt < 0f && Gone(prey)) scientistAt = t;
+ if (!Gone(neutral) && TargetOf(Describe(neutral)) != "none") neutralFought = true;
+ if (!Gone(leashed) && TargetOf(Describe(leashed)) != "none") leashedFought = true;
+ if (!Gone(victim) && TargetOf(Describe(victim)) != "none") victimAnswered = true;
+ foreach (BasePlayer x in new[] { red, blue, hunter })
+ {
+ Dictionary dx = Gone(x) ? null : Describe(x);
+ if (dx != null) marked = Math.Max(marked, Convert.ToInt32(dx["shotsMarked"]));
+ }
+
+ if (factionsAt > 0f && scientistAt > 0f && victimAnswered && t >= 25) break;
+ }
+
+ float redNow = Gone(red) ? 0f : red.health, blueNow = Gone(blue) ? 0f : blue.health;
+ Check("s5.fight.factions", redNow < redHp && blueNow < blueHp,
+ $"red and blue (hostile in the table) both hit: red {redHp:0}→{redNow:0}, blue {blueHp:0}→{blueNow:0}{(factionsAt > 0f ? $", one died at {factionsAt:0} s" : "")}");
+ Check("s5.fight.marked", marked > 0, $"{marked} shot(s) marked Horror for their length");
+ Check("s5.fight.scientists", scientistAt > 0f || (!Gone(prey) && prey.health < preyHealth * 0.5f),
+ scientistAt > 0f ? $"the hunter killed a stock scientist at {scientistAt:0} s" : $"scientist {preyHealth:0}→{prey?.health:0} hp in 70 s");
+ Check("s5.fight.noFactionNoFight", !neutralFought && !Gone(bystander) && bystander.health >= bystanderHealth, $"t_roam beside a scientist: fought={neutralFought}, scientist {bystander?.health:0}/{bystanderHealth:0} (D255)");
+ Check("s5.fight.leash", !leashedFought && !Gone(far) && far.health >= farHealth, $"chase 8, scientist 25 m off: fought={leashedFought}, scientist {far?.health:0}/{farHealth:0} (D263)");
+ Check("s5.fight.shootBack", victimAnswered, $"t_roam (neutral) shot by a profile hostile to it one-sidedly: answered={victimAnswered} (D268)");
+ Api("RunicNpc_DespawnOwner", Owner);
+ ClearWorld();
+ keeper = StandIn(f + new Vector3(0, 0, 60), 50);
+ yield return new WaitForSeconds(1f);
+
+ // ---- the alarm (D256): allies within 40 m learn the attacker, seen or not ----
+ BasePlayer shooter = StandIn(f + new Vector3(0, 0, -120), 51);
+ BasePlayer b0 = Spawn(a, "t_blue");
+ BasePlayer b1 = Spawn(OnMesh(a + new Vector3(6, 0, 0)), "t_blue");
+ BasePlayer b2 = Spawn(OnMesh(a + new Vector3(-6, 0, 0)), "t_blue");
+ BasePlayer b3 = Spawn(OnMesh(a + new Vector3(0, 0, 60)), "t_blue");
+ BasePlayer r0 = Spawn(OnMesh(a + new Vector3(0, 0, -6)), "t_roam");
+ yield return new WaitForSeconds(2f);
+ b0.Hurt(new HitInfo(shooter, b0, Rust.DamageType.Bullet, 5f));
+ yield return new WaitForSeconds(1f);
+ Check("s5.alarm.near", Knows(b1, shooter) && Knows(b2, shooter), $"b1={Knows(b1, shooter)} b2={Knows(b2, shooter)}");
+ Check("s5.alarm.far", !Knows(b3, shooter), $"60 m off: knows={Knows(b3, shooter)}");
+ Check("s5.alarm.notEnemy", !Knows(r0, shooter), $"a t_roam beside them: knows={Knows(r0, shooter)} (not their ally)");
+ Api("RunicNpc_DespawnOwner", Owner);
+ ClearWorld();
+ keeper = StandIn(f + new Vector3(0, 0, 60), 50);
+ yield return new WaitForSeconds(1f);
+
+ // ---- allies (D257, D270): never targeted, and defended with what they own ----
+ BasePlayer friend = StandIn(OnMesh(b + new Vector3(0, 0, 12)), 52);
+ // The control: the same profile beside it, allied to nobody, sees the same player. (Rust senses
+ // only one of two stand-ins this close, so a second stand-in cannot be the control.)
+ BasePlayer guardian = Spawn(b, "t_roam");
+ BasePlayer control = Spawn(OnMesh(b + new Vector3(2, 0, 0)), "t_roam");
+ said = Api("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString);
+ Check("s5.ally.set", said == null, said ?? "allied to a player");
+ said = Api("RunicNpc_Ally", Id(guardian), "clan", "424242");
+ Check("s5.ally.refuseClan", said != null && said.Contains("no clan"), said ?? "null");
+ Api("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString);
+ yield return new WaitForSeconds(2f);
+ Sense(guardian, friend);
+ Sense(control, friend);
+ yield return new WaitForSeconds(1f);
+ Check("s5.ally.spared", !Knows(guardian, friend), $"the ally 12 m off in sight: target={Knows(guardian, friend)}");
+ Check("s5.ally.controlFights", Knows(control, friend), $"the same profile, allied to nobody, 2 m away: target={Knows(control, friend)}");
+ Api("RunicNpc_Despawn", Id(control));
+ BasePlayer raider = StandIn(OnMesh(b + new Vector3(0, 0, -90)), 54);
+ friend.Hurt(new HitInfo(raider, friend, Rust.DamageType.Bullet, 5f));
+ yield return new WaitForSeconds(1f);
+ Check("s5.ally.defendsPlayer", Knows(guardian, raider), $"a raider 90 m off shot the ally: guardian knows={Knows(guardian, raider)}");
+
+ BaseEntity foundation = GameManager.server.CreateEntity("assets/prefabs/building core/foundation/foundation.prefab", OnMesh(b + new Vector3(8, 0, -8)));
+ if (foundation != null)
+ {
+ foundation.enableSaving = false;
+ foundation.OwnerID = (ulong)friend.userID;
+ if (foundation is StabilityEntity) ((StabilityEntity)foundation).grounded = true;
+ foundation.Spawn();
+ _built.Add(foundation);
+ BasePlayer wrecker = StandIn(OnMesh(b + new Vector3(-90, 0, 0)), 55);
+ ((BaseCombatEntity)foundation).Hurt(new HitInfo(wrecker, foundation, Rust.DamageType.Explosion, 1f));
+ yield return new WaitForSeconds(1f);
+ Check("s5.ally.defendsBuilding", Knows(guardian, wrecker), $"a wrecker 90 m off hit the ally's foundation: guardian knows={Knows(guardian, wrecker)}");
+ }
+ else
+ Check("s5.ally.defendsBuilding", false, "no foundation could be made");
+
+ Api("RunicNpc_DespawnOwner", Owner);
+ ClearWorld();
+ keeper = StandIn(f + new Vector3(0, 0, 60), 50);
+ yield return new WaitForSeconds(1f);
+
+ // ---- escort (D258, D269) ----
+ _escortEnded.Clear();
+ // A stock scientist as the charge: a stand-in has no connection, so it is never "on the server".
+ ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0));
+ BasePlayer escort = Spawn(c, "t_roam");
+ BasePlayer sentry = Spawn(OnMesh(c + new Vector3(-10, 0, 0)), "t_sentry");
+ said = Api("RunicNpc_Escort", Id(sentry), charge);
+ Check("s5.escort.refuseSentry", said != null && said.Contains("sentry"), said ?? "null");
+ said = Api("RunicNpc_Escort", Id(escort), charge);
+ Check("s5.escort.set", said == null, said ?? "escorting");
+ yield return new WaitForSeconds(2f);
+ Vector3 home = escort.transform.position;
+ Teleport(charge, c + new Vector3(0, 0, 35));
+ float closest = float.MaxValue;
+ for (int t = 0; t < 20; t++)
+ {
+ yield return new WaitForSeconds(1f);
+ closest = Mathf.Min(closest, Flat(escort.transform.position, charge.transform.position));
+ }
+
+ Check("s5.escort.follows", closest < 8f, $"its charge moved 35 m; closest {closest:0.0} m in 20 s");
+ Check("s5.escort.spared", !Knows(escort, charge), $"target={Knows(escort, charge)}");
+ charge.Kill();
+ for (int t = 0; t < 20 && _escortEnded.Count == 0; t++)
+ yield return new WaitForSeconds(1f);
+ Dictionary de = Describe(escort);
+ Check("s5.escort.ends", _escortEnded.Count == 1 && de != null && de["escort"] == null, $"hook {_escortEnded.Count}, escort={de?["escort"] ?? "null"}");
+ float back = float.MaxValue;
+ for (int t = 0; t < 45 && back > 4f; t++)
+ {
+ yield return new WaitForSeconds(1f);
+ back = Flat(escort.transform.position, V(Describe(escort)["home"]));
+ }
+
+ Check("s5.escort.home", back <= 4f, $"{back:0.0} m from its spot after its charge was gone (D269)");
+ Api("RunicNpc_DespawnOwner", Owner);
+ ClearWorld();
+ keeper = StandIn(f + new Vector3(0, 0, 60), 50);
+ yield return new WaitForSeconds(1f);
+
+ // ---- the guard role ----
+ BasePlayer guard = Spawn(d, "t_guard");
+ yield return new WaitForSeconds(2f);
+ Vector3 post = V(Describe(guard)["home"]);
+ Teleport(guard, post + new Vector3(10, 0, 0));
+ float off = float.MaxValue;
+ for (int t = 0; t < 25 && off > 2.5f; t++)
+ {
+ yield return new WaitForSeconds(1f);
+ off = Flat(guard.transform.position, post);
+ }
+
+ Check("s5.guard.returns", off <= 2.5f, $"put 10 m off its post: {off:0.0} m from it");
+ // It stops within 2 m of its post; let it arrive before measuring how still it holds.
+ yield return new WaitForSeconds(4f);
+ Vector3 held = guard.transform.position;
+ float drift = 0f;
+ for (int t = 0; t < 12; t++)
+ {
+ yield return new WaitForSeconds(1f);
+ drift = Mathf.Max(drift, Flat(guard.transform.position, held));
+ }
+
+ Check("s5.guard.holds", drift < 1.5f, $"moved {drift:0.0} m in 12 s at its post (a roamer would wander)");
+ Api("RunicNpc_DespawnOwner", Owner);
+ yield return new WaitForSeconds(1f);
+
+ // ---- the tether (D272) ----
+ Plugin zm = plugins.Find("ZoneManager");
+ if (zm == null)
+ Note("s5.tether skipped: ZoneManager is not loaded");
+ else
+ {
+ // Within 160 m of the keeper, or the NPC sleeps (D235) and the test reads 0 m.
+ Vector3 centre = OnMesh(c);
+ zm.Call("CreateOrUpdateZone", Zone, new[] { "radius", "12", "name", "rnt tether" }, centre);
+ BasePlayer tethered = Spawn(centre, "t_roam");
+ said = Api("RunicNpc_Tether", Id(tethered), "rnt_nozone");
+ Check("s5.tether.refuseMissing", said != null && said.Contains("no zone"), said ?? "null");
+ said = Api("RunicNpc_Tether", Id(tethered), Zone);
+ Check("s5.tether.set", said == null, said ?? "held");
+ float widest = 0f;
+ for (int t = 0; t < 30; t++)
+ {
+ yield return new WaitForSeconds(1f);
+ widest = Mathf.Max(widest, Flat(tethered.transform.position, centre));
+ }
+
+ Check("s5.tether.holds", widest <= 13f, $"wander radius 15 in a 12 m zone: widest {widest:0.0} m from its centre in 30 s");
+ Teleport(tethered, centre + new Vector3(25, 0, 0));
+ float inside = float.MaxValue;
+ for (int t = 0; t < 25 && inside > 12f; t++)
+ {
+ yield return new WaitForSeconds(1f);
+ inside = Flat(tethered.transform.position, centre);
+ }
+
+ Check("s5.tether.returns", inside <= 12f, $"put 25 m out: {inside:0.0} m from the centre");
+ string placed = Api("RunicNpc_SetPlacement", "t_tether", JObject.FromObject(new { profile = "t_roam", position = new { centre.x, centre.y, centre.z }, tether = "rnt_nozone" }));
+ Check("s5.tether.placementRefused", placed != null && placed.Contains("no zone"), placed ?? "null");
+ Api("RunicNpc_RemovePlacement", "t_tether");
+ zm.Call("EraseZone", Zone);
+ Api("RunicNpc_DespawnOwner", Owner);
+ }
+
+ // ---- PVE (D261, D271) ----
+ BasePlayer player = StandIn(OnMesh(a + new Vector3(0, 0, 40)), 57);
+ BasePlayer unkill = Spawn(a, "t_unkill");
+ BasePlayer harmless = Spawn(OnMesh(a + new Vector3(4, 0, 0)), "t_harmless");
+ BasePlayer plain = Spawn(OnMesh(a + new Vector3(-4, 0, 0)), "t_roam");
+ object o1 = Interface.CallHook("CanEntityTakeDamage", unkill, new HitInfo(player, unkill, Rust.DamageType.Bullet, 10f));
+ object o2 = Interface.CallHook("CanEntityTakeDamage", plain, new HitInfo(player, plain, Rust.DamageType.Bullet, 10f));
+ object o3 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f));
+ object o4 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f));
+ Check("s5.pve.answers", Equals(o1, false) && Equals(o2, true) && Equals(o3, false) && Equals(o4, true), $"unkillable {o1 ?? "null"}, plain {o2 ?? "null"}, harmless→player {o3 ?? "null"}, plain→player {o4 ?? "null"}");
+ float hp = unkill.health;
+ unkill.Hurt(new HitInfo(player, unkill, Rust.DamageType.Bullet, 30f));
+ float plainHp = plain.health;
+ plain.Hurt(new HitInfo(player, plain, Rust.DamageType.Bullet, 30f));
+ Check("s5.pve.unkillable", Mathf.Approximately(unkill.health, hp) && plain.health < plainHp, $"unkillable {hp:0}→{unkill.health:0}, plain {plainHp:0}→{plain.health:0}");
+ object h1 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f));
+ object h2 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f));
+ Check("s5.pve.harmless", Equals(h1, true) && h2 == null, $"harmless→player {h1 ?? "null"}, plain→player {h2 ?? "null"}");
+ Api("RunicNpc_DespawnOwner", Owner);
+ ClearWorld();
+ keeper = StandIn(f + new Vector3(0, 0, 60), 50);
+ yield return new WaitForSeconds(1f);
+
+ // ---- turrets (D259, D264, D267): the hook, then Outpost's own sentries ----
+ BasePlayer ignoring = Spawn(b, "t_ignore");
+ BasePlayer defaulting = Spawn(OnMesh(b + new Vector3(4, 0, 0)), "t_roam");
+ object i1 = Interface.CallHook("CanBeTargeted", ignoring, (MonoBehaviour)null);
+ object i2 = Interface.CallHook("CanBeTargeted", defaulting, (MonoBehaviour)null);
+ Check("s5.turrets.ignore", Equals(i1, false) && i2 == null, $"ignore {i1 ?? "null"}, default {i2 ?? "null"}");
+ Api("RunicNpc_DespawnOwner", Owner);
+ IEnumerator sentries = Sentries();
+ while (sentries.MoveNext()) yield return sentries.Current;
+ ClearWorld();
+ keeper = StandIn(f + new Vector3(0, 0, 60), 50);
+ yield return new WaitForSeconds(1f);
+
+ // ---- the kit's extras (D260, D265), each NPC 60 m from the next ----
+ IEnumerator kit = KitExtras(f);
+ while (kit.MoveNext()) yield return kit.Current;
+
+ int horrorAfter = HorrorLeft();
+ Check("s5.noHorrorLeft", horrorAfter <= horrorBefore, $"Horror entities: {horrorBefore} before, {horrorAfter} after");
+ Api("RunicNpc_SetFactions", new JArray());
+ }
+
+ ///
+ /// D264 and D267 on Outpost's (or Bandit Camp's) own sentries, and the org lead's condition: they
+ /// must never target Rust's own scientists or bandit guards. Ours are put in front of a sentry:
+ /// one always, one default, and a stock scientist beside them.
+ ///
+ private IEnumerator Sentries()
+ {
+ List turrets = BaseNetworkable.serverEntities.OfType().Where(t => t != null && !t.IsDestroyed).ToList();
+ if (turrets.Count == 0)
+ {
+ Note("s5.sentry skipped: this map has no Outpost or Bandit Camp sentries");
+ yield break;
+ }
+
+ NPCAutoTurret turret = null;
+ Vector3 spot = Vector3.zero;
+ foreach (NPCAutoTurret t in turrets)
+ {
+ Vector3 ahead = t.transform.position + t.transform.forward * 12f;
+ NavMeshHit hit, start;
+ // The approach must be walkable too: ours start 45 m out and are walked in (a sentry by the
+ // water left the Carbon run's NPC unspawned).
+ if (SampleNavmesh(ahead, 4f, out hit) && Mathf.Abs(hit.position.y - t.transform.position.y) < 6f &&
+ SampleNavmesh(t.transform.position + t.transform.forward * 45f, 3f, out start))
+ {
+ turret = t;
+ spot = hit.position;
+ break;
+ }
+ }
+
+ if (turret == null)
+ {
+ Check("s5.sentry.spot", false, $"no navmesh 12 m in front of any of {turrets.Count} sentries");
+ yield break;
+ }
+
+ Note($"s5.sentry: {turret.ShortPrefabName} at {turret.transform.position}, {turrets.Count} on the map");
+ BasePlayer near = StandIn(spot + new Vector3(0, 0, 80), 58);
+ Vector3 side = Vector3.Cross(Vector3.up, turret.transform.forward).normalized;
+ // Spawned 45 m out and walked in: a turret notices only what ENTERS its trigger (stage 5 spike).
+ Vector3 outside = turret.transform.position + turret.transform.forward * 45f;
+ BasePlayer always = Spawn(OnMesh(outside), "t_always");
+ BasePlayer plain = Spawn(OnMesh(outside + side * 4f), "t_roam");
+ ScientistNPC stock = SpawnScientist(outside - side * 4f);
+ yield return new WaitForSeconds(1f);
+ Teleport(always, spot);
+ Teleport(plain, spot + side * 4f);
+ Teleport(stock, spot - side * 4f);
+ float alwaysHp = always != null ? always.health : 0f;
+ var targeted = new HashSet();
+ bool alwaysTargeted = false;
+ int stockTargets = 0;
+ for (int t = 0; t < 120; t++)
+ {
+ yield return new WaitForSeconds(0.25f);
+ foreach (NPCAutoTurret any in turrets)
+ {
+ BaseCombatEntity target = any != null && !any.IsDestroyed ? any.target : null;
+ if (target == null) continue;
+ targeted.Add(target.GetType().Name);
+ if (target == always) alwaysTargeted = true;
+ if (target is BanditGuard || (target is ScientistNPC && target.GetType().Name != "RunicNpcPlayer")) stockTargets++;
+ }
+ }
+
+ if (ReferenceEquals(always, null) || ReferenceEquals(plain, null) || ReferenceEquals(stock, null)) Note($"s5.sentry: spawned always={always != null} default={plain != null} stock={stock != null}");
+ Check("s5.sentry.always", alwaysTargeted && (Gone(always) || always.health < alwaysHp), $"targeted={alwaysTargeted}, health {alwaysHp:0}→{(Gone(always) ? 0f : always.health):0}");
+ Check("s5.sentry.default", Gone(plain) || plain.health >= plain.MaxHealth() - 0.5f, $"a default NPC beside it: {(Gone(plain) ? "dead" : plain.health.ToString("0"))} hp");
+ Check("s5.sentry.noStockTargets", stockTargets == 0 && !Gone(stock), $"samples on a stock scientist or bandit guard: {stockTargets}; targets seen: {string.Join(", ", targeted.ToArray())} (the org lead's condition, D267)");
+ Api("RunicNpc_DespawnOwner", Owner);
+ }
+
+ private IEnumerator KitExtras(Vector3 f)
+ {
+ Vector3[] at = new Vector3[6];
+ for (int i = 0; i < 6; i++)
+ at[i] = OnMesh(f + new Vector3(-150 + i * 60, 0, -60));
+
+ // Heal in a lull, one syringe used up; and a profile that did not opt in never heals.
+ BasePlayer healer = Spawn(at[0], "t_kit");
+ BasePlayer plain = Spawn(OnMesh(at[0] + new Vector3(8, 0, 0)), "t_roam");
+ Give(healer, "syringe.medical", 2);
+ Give(plain, "syringe.medical", 2);
+ yield return new WaitForSeconds(1f);
+ healer.health = healer.MaxHealth() * 0.3f;
+ plain.health = plain.MaxHealth() * 0.3f;
+ float plainHp = plain.health;
+
+ // A grenadier, a rocketeer, a brawler and a flamer, each with a stand-in at its range.
+ BasePlayer grenadier = Spawn(at[1], "t_kit");
+ Give(grenadier, "grenade.f1", 2);
+ BasePlayer gTarget = StandIn(OnMesh(at[1] + new Vector3(12, 0, 0)), 60);
+ Vector3 rAt, rTo;
+ if (!ClearLine(at[2], 25f, out rAt, out rTo)) { rAt = at[2]; rTo = at[2] + new Vector3(25, 0, 0); Note("s5.kit.rocket: no clear 25 m line found near its spot"); }
+ BasePlayer rocketeer = Spawn(rAt, "t_kit");
+ Give(rocketeer, "rocket.launcher", 1);
+ Give(rocketeer, "ammo.rocket.basic", 2);
+ ScientistNPC rTarget = SpawnScientist(rTo);
+ BasePlayer brawler = Spawn(at[3], "t_kit");
+ Give(brawler, "machete", 1);
+ BasePlayer mTarget = StandIn(OnMesh(at[3] + new Vector3(1.5f, 0, 0)), 62);
+ BasePlayer flamer = Spawn(at[4], "t_kit");
+ Give(flamer, "flamethrower", 1);
+ Give(flamer, "lowgradefuel", 50);
+ // An empty tank, so the extra must burn the kit's fuel (D265: only extras are used up).
+ foreach (Item it in Items(flamer))
+ if (it.GetHeldEntity() is FlameThrower) ((FlameThrower)it.GetHeldEntity()).ammo = 0;
+ BasePlayer near = StandIn(OnMesh(at[4] + new Vector3(4, 0, 0)), 63);
+ Sense(flamer, near);
+
+ bool flameHeld = false;
+ for (int t = 0; t < 45; t++)
+ {
+ yield return new WaitForSeconds(1f);
+ if (t % 5 == 0)
+ {
+ if (!Gone(flamer)) Sense(flamer, near);
+ if (!Gone(grenadier)) Sense(grenadier, gTarget);
+ if (!Gone(brawler)) Sense(brawler, mTarget);
+ }
+ Item held = Gone(flamer) ? null : flamer.GetActiveItem();
+ if (held == null || held.info.shortname != "flamethrower") continue;
+ // Its tank emptied while it is held, then one shot: the extra must burn the kit's fuel (D265).
+ FlameThrower tank = held.GetHeldEntity() as FlameThrower;
+ if (!flameHeld && tank != null)
+ {
+ tank.ammo = 0;
+ ((NPCPlayer)flamer).ShotTest(4f);
+ }
+
+ flameHeld = true;
+ }
+
+ Dictionary Used(BasePlayer x) => Gone(x) ? null : Describe(x)?["kitUsed"] as Dictionary;
+ int UsedN(BasePlayer x, string k) => Used(x) != null ? Convert.ToInt32(Used(x)[k]) : -1;
+
+ Check("s5.kit.heal", UsedN(healer, "heals") > 0 && Count(healer, "syringe.medical") < 2, $"heals {UsedN(healer, "heals")}, syringes left {(Gone(healer) ? -1 : Count(healer, "syringe.medical"))}, health {(Gone(healer) ? 0 : healer.health):0}");
+ Check("s5.kit.offByDefault", !Gone(plain) && plain.health <= plainHp + 0.5f && Count(plain, "syringe.medical") == 2, $"t_roam: health {plainHp:0}→{(Gone(plain) ? 0 : plain.health):0}, syringes {(Gone(plain) ? -1 : Count(plain, "syringe.medical"))}");
+ Check("s5.kit.grenade", UsedN(grenadier, "throws") > 0 && Count(grenadier, "grenade.f1") < 2, $"throws {UsedN(grenadier, "throws")}, grenades left {(Gone(grenadier) ? -1 : Count(grenadier, "grenade.f1"))}");
+ Check("s5.kit.rocket", (UsedN(rocketeer, "rockets") > 0 || Gone(rTarget)) && Count(rocketeer, "ammo.rocket.basic") < 2, $"rockets {UsedN(rocketeer, "rockets")}, left {(Gone(rocketeer) ? -1 : Count(rocketeer, "ammo.rocket.basic"))}");
+ Check("s5.kit.melee", UsedN(brawler, "meleeHits") > 0, $"melee swings {UsedN(brawler, "meleeHits")}");
+ Check("s5.kit.flame", flameHeld && !Gone(flamer) && Count(flamer, "lowgradefuel") < 50, $"held={flameHeld}, fuel left {(Gone(flamer) ? -1 : Count(flamer, "lowgradefuel"))}");
+ Api("RunicNpc_DespawnOwner", Owner);
+ }
+
/// A route, as RunicNpc_Routes returns it.
private sealed class Route
{