3 Commits

Author SHA1 Message Date
900ac4e992 test(harness): the escort test walks its charge away from the keeper; rnt.escortprobe (stage 5)
Run alone on each rig, s5 is 52/52 on Oxide and on Carbon, and rnt.run all
was otherwise green on both (stages 2-4 unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 09:16:50 -05:00
4ea50c4fd2 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
2026-10-01 08:39:33 -05:00
21d3c477dc feat: API 4, factions, escort, allies, tether, turrets, kit extras and PVE (stage 5)
Stage 5's behaviour (docs runicnpc/PLAN.md, D253-D272):

- profiles gain faction, relations (exceptions), alertRadius, turrets,
  hurtByPlayers, hurtsPlayers and kitUse, and the guard role; the faction
  table rides in profiles.json, one row per pair, both ways (D254, D268);
- our own sensing and fight beside Rust's design: only the factions a
  profile hunts, within its leash of its anchor (D263), the target made
  Horror for the length of our own shot, and Rust's attack routine;
- shooting back at whoever hurt it (D268), the alert radius (D256), clan,
  team or player allies that are never targeted and are defended with
  what they own (D257), escort as an order that walks home when its
  charge is gone (D269), and a placement's tether through ZoneManager
  (D272);
- turrets: ignore through CanBeTargeted, and always through a Harmony
  patch on the safe-zone sentries for our always NPCs only (D267);
- the kit's extras, each opted into and used up: heal, melee,
  flamethrower, grenades and rockets (D260, D265);
- TruePVE/NextGenPVE answered both ways unless a profile turns a direction
  off, and the opt-out enforced by RunicNPC itself (D261, D271);
- API 4: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether and the
  OnRunicNpcEscortEnded hook; rnpc follow, rnpc.faction and tether=.

Compiled and loaded on both rigs (Oxide and Carbon); the sentry patch
applies on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 07:20:59 -05:00
5 changed files with 2616 additions and 45 deletions

View File

@@ -24,9 +24,11 @@
#
# 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else.
# 2 was stage 2's whole API, PLAN.md §4.
# Current: 3 — stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the
# OnRunicNpcPlacementChanged hook, documented in docs/runicnpc/API.md.
api = 3
# 3 was stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the
# OnRunicNpcPlacementChanged hook.
# Current: 4 — stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5
# fields, a placement's tether, and the OnRunicNpcEscortEnded hook, documented in docs/runicnpc/API.md.
api = 4
# ── Framework floors ─────────────────────────────────────────────────────────
#
@@ -45,6 +47,6 @@ min_carbon_version = "2.0.259"
# `doctor`, which reports a missing one rather than installing it. The PR check
# holds this list and the plugin's `// Requires:` lines equal.
#
# ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6), and a
# profile that asks for one on a server without it is refused when it is saved.
# ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6). A placement
# or an event step that asks for one on a server without it is refused (D272).
requires_plugins = ["Kits"]

File diff suppressed because it is too large Load Diff

View File

@@ -69,7 +69,17 @@ const PLUGIN_TOML = path.join(ROOT, 'plugin.toml')
* That half cannot be read statically; it is the reviewer's to check, which is
* why the reason goes here where the reviewer will see it.
*/
const ANSWERS_DELIBERATELY = Object.create(null)
const ANSWERS_DELIBERATELY = Object.assign(Object.create(null), {
// Stage 5 (D259, D264): turrets: ignore. Answers false for RunicNPC's own NPCs whose profile says
// ignore, so no auto turret, flame turret or shotgun trap targets them; null for everything else.
CanBeTargeted: 'turrets: ignore (D259, D264), our own NPCs only',
// Stage 5 (D261, D271): TruePVE's and NextGenPVE's question. Answers only for one of ours facing a
// real player, with the profile's two PVE checkboxes; null for everything else.
CanEntityTakeDamage: 'PVE allowed both ways, or the profile opt-out (D261, D271), our own NPCs only',
// Stage 5 (D271): cancels damage only when one of ours whose profile says it cannot hurt players
// hits a real player. Its other job, defending an ally or escort (D257, D269), never answers.
OnEntityTakeDamage: 'a harmless profile never hurts a player (D271), our own NPCs only',
})
/** Plugins RunicNPC must require, whatever else it requires (D217). */
const REQUIRED_PLUGINS = ['Kits']

View File

@@ -100,13 +100,13 @@ test('returning null is not good enough — the signature is the rule', () => {
// `return null` today is one edit away from `return true` tomorrow, and the
// edit that breaks it looks harmless in a diff. A void method cannot be turned
// into a veto without changing its signature, which is visible.
const methods = ` private bool CanBeTargeted(BaseCombatEntity entity, AutoTurret turret)
const methods = ` private bool CanLootEntity(BasePlayer player, StorageContainer container)
{
return true;
}`
const problems = check(source({ expected: ['CanBeTargeted'], methods }), toml())
assert.match(problems[0], /CanBeTargeted returns bool, not void/)
const problems = check(source({ expected: ['CanLootEntity'], methods }), toml())
assert.match(problems[0], /CanLootEntity returns bool, not void/)
})
test('a method that is not shaped like a hook is left alone', () => {

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,125 @@ 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");
}
/// <summary>A probe: an escort follows a scientist 35 m, the scientist is killed, then 30 s of what the escort does.</summary>
[ConsoleCommand("rnt.escortprobe")]
private void CmdEscortProbe(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 c = OnMesh(f);
ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0));
BasePlayer escort = Spawn(c, "t_roam");
Api<string>("RunicNpc_Escort", Id(escort), charge);
int n = 0;
timer.Repeat(2f, 30, () =>
{
n++;
if (n == 3) Teleport(charge, c + new Vector3(0, 0, 35));
if (n == 12 && !Gone(charge)) charge.Kill();
Dictionary<string, object> d = Gone(escort) ? null : Describe(escort);
Puts($"[esc {n}] state={d?["state"]} rest={d?["rest"]} escort={d?["escort"] ?? "null"} target={TargetOf(d)} engaged={d?["engaged"]} home={(d == null ? -1f : Flat(escort.transform.position, V(d["home"]))):0.0} ended={_escortEnded.Count}");
if (n == 30) { Api<int>("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
});
arg.ReplyWith("rnt: escort probe started");
}
[ConsoleCommand("rnt.clear")]
private void CmdClear(ConsoleSystem.Arg arg)
{
@@ -351,11 +473,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 +489,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 +511,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 +1311,588 @@ 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;
// East, not north: north brings the escort within sense range of the keeper stand-in, and Rust's
// design then fights it instead of walking home.
Teleport(charge, c + new Vector3(35, 0, 0));
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
{