fix: escort end, line of sight and rockets; test(harness): stage 5 group (stage 5)

What the rigs' stage 5 runs found in the plugin:

- an escort whose charge was destroyed was never released: Unity's ==
  says a destroyed entity is null, so CheckEscort returned before it
  looked. It now tests the reference itself (D269);
- our own fight's line of sight no longer uses Rust's CanSeeTarget for an
  NPC target: its rays are unbounded, so on open ground they run past the
  target into the terrain behind it. A bounded linecast to the target's
  centre, which counts the target's own collider as seen;
- a rocket's line of sight: Rust's sensed one for a real player, ours for
  an NPC or an animal, which are never in Rust's player memory.

tools/RunicNpcTest.cs 0.5.0 adds `rnt.run s5`: 52 checks of the faction
table and its refusals, faction fights, the leash, shooting back, the alarm,
allies spared and defended (players and buildings), escort, the guard,
the tether, PVE, turrets (`ignore` through the hook, `always` on the map's
own sentries with the org lead's condition: no stock scientist or bandit
guard ever targeted), and the kit's extras used up. Also `rnt.duel`,
`rnt.kitprobe`, `rnt.rocketprobe` and `rnt.list`, for looking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-01 08:39:33 -05:00
parent 21d3c477dc
commit 4ea50c4fd2
2 changed files with 707 additions and 19 deletions

View File

@@ -2063,14 +2063,14 @@ namespace Oxide.Plugins
}
/// <summary>
/// Rust's <c>CanSeeTarget</c> 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 <c>CanSeeTarget</c>, 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.
/// </summary>
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
/// <summary>D269: whom it escorts died or left the server; it walks back to its spot.</summary>
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())

View File

@@ -24,7 +24,9 @@ namespace Oxide.Plugins
///
/// <para>
/// <c>rnt.run &lt;group&gt;</c> runs one group: <c>api</c>, <c>hooks</c>, <c>move</c>,
/// <c>sentry</c>, <c>sleep</c>, <c>cmd</c> (stage 3's commands), <c>api3</c> (stage 4's calls), <c>place</c>, or <c>all</c> for each in turn.
/// <c>sentry</c>, <c>sleep</c>, <c>cmd</c> (stage 3's commands), <c>api3</c> (stage 4's calls), <c>place</c>,
/// <c>s5</c> (stage 5's behaviour: factions, fights, alarm, allies, escort, guard, tether, turrets,
/// PVE and the kit's extras), or <c>all</c> for each in turn.
/// <c>rnt.run place</c> leaves two placements behind for the restart check, and
/// <c>rnt.after</c> checks, after a reload or restart, that every placement is back and nothing
/// else is. <c>rnt.spawn [n]</c> 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 <c>IsNpc</c> is false. They take no damage.
/// </para>
/// </summary>
[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<Dictionary<string, string>>("RunicNpc_SetProfiles", all) ?? new Dictionary<string, string> { ["?"] = "no answer" };
}
@@ -325,6 +328,100 @@ namespace Oxide.Plugins
arg.ReplyWith($"rnt: spawned {n} as {Owner}");
}
/// <summary>A probe: two profiles 15 m apart, described every two seconds for 30 s.</summary>
[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<string, object> 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<int>("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
});
arg.ReplyWith("rnt: duel started");
}
/// <summary>A probe: a t_kit NPC with a flamethrower and fuel, a stand-in 4 m off, every second for 20 s.</summary>
[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<int>("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
});
arg.ReplyWith("rnt: kit probe started");
}
/// <summary>Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks.</summary>
[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)");
}
/// <summary>A probe: a t_kit rocketeer and a stock scientist 25 m off, described every 2 s for 40 s.</summary>
[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<string, object> 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<int>("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<string, string> 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<int>("RunicNpc_ApiVersion") == 3, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 4, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
JObject profiles = Api<JObject>("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<string> _escortEnded = new List<string>();
private void OnRunicNpcEscortEnded(BasePlayer npc, string profile, string owner) => _escortEnded.Add($"{profile}/{owner}");
/// <summary>The stage 5 test profiles, beside the others; three are refused on purpose.</summary>
private static void Stage5Profiles(JObject all)
{
JObject P(string name, Action<JObject> 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<string, string> { ["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<string, string> { ["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<string, string> { ["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<string, string> { ["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);
}
/// <summary>
/// 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.
/// </summary>
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<string, object> 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<Item> Items(BasePlayer npc)
{
var all = new List<Item>();
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();
}
/// <summary>A spot near <paramref name="near"/> with a clear line to a second spot <paramref name="range"/> m east: no rock between.</summary>
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<BaseCombatEntity>().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<string>("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<string>("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<string>("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<string>("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<string>("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" } }));
JArray table = Api<JArray>("RunicNpc_Factions");
Check("s5.factions.set", said == null && table != null && table.Count == 1, (said ?? "set") + " " + table?.ToString(Formatting.None));
JObject profiles = Api<JObject>("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<string, object> 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<int>("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<int>("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<string>("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString);
Check("s5.ally.set", said == null, said ?? "allied to a player");
said = Api<string>("RunicNpc_Ally", Id(guardian), "clan", "424242");
Check("s5.ally.refuseClan", said != null && said.Contains("no clan"), said ?? "null");
Api<string>("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<int>("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<int>("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<string>("RunicNpc_Escort", Id(sentry), charge);
Check("s5.escort.refuseSentry", said != null && said.Contains("sentry"), said ?? "null");
said = Api<string>("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<string, object> 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<int>("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<int>("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<string>("RunicNpc_Tether", Id(tethered), "rnt_nozone");
Check("s5.tether.refuseMissing", said != null && said.Contains("no zone"), said ?? "null");
said = Api<string>("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<string>("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<bool>("RunicNpc_RemovePlacement", "t_tether");
zm.Call("EraseZone", Zone);
Api<int>("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<int>("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<int>("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<string>("RunicNpc_SetFactions", new JArray());
}
/// <summary>
/// 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 <c>always</c>, one <c>default</c>, and a stock scientist beside them.
/// </summary>
private IEnumerator Sentries()
{
List<NPCAutoTurret> turrets = BaseNetworkable.serverEntities.OfType<NPCAutoTurret>().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<string>();
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<int>("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<string, object> Used(BasePlayer x) => Gone(x) ? null : Describe(x)?["kitUsed"] as Dictionary<string, object>;
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<int>("RunicNpc_DespawnOwner", Owner);
}
/// <summary>A route, as RunicNpc_Routes returns it.</summary>
private sealed class Route
{