Files
runicnpc-rust/tools/RunicNpcTest.cs
wtclaude 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

1883 lines
108 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
using UnityEngine.AI;
namespace Oxide.Plugins
{
/// <summary>
/// RunicNPC's test harness (docs/runicnpc/PLAN.md §9, stages 2 and 3). NEVER SHIPPED: it lives
/// in tools/ and is loaded on a rig by hand. It drives RunicNPC through its API, its hooks and its
/// console commands (the chat command through a stand-in player), and prints one line per
/// assertion:
///
/// <code>[rnt] &lt;group&gt;.&lt;check&gt;: PASS|FAIL &lt;detail&gt;</code>
///
/// Every line is also kept in <c>data/RunicNpcTest.json</c>, read back over the panel.
///
/// <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>,
/// <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
/// <c>rnt.clear</c> removes everything the harness made.
/// </para>
///
/// <para>
/// It needs the hidden Kits kit <c>rnhrevolver</c> (stage 1's: a revolver, pistol ammo and
/// burlap). It REPLACES RunicNPC's profiles with its own test set, which marks the server
/// managed: back up <c>data/RunicNPC/</c> first and restore it afterwards.
/// </para>
///
/// <para>
/// Targets and wake-ups are <b>stand-ins</b>, stage 1's fake players: <c>player.prefab</c> with
/// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
/// </para>
/// </summary>
[Info("RunicNpcTest", "Runic Gateway", "0.5.0")]
[Description("RunicNPC stage 2, 3, 4 and 5 tests. A developer tool: never ship it.")]
internal class RunicNpcTest : RustPlugin
{
[PluginReference] private Plugin RunicNPC;
private const string Kit = "rnhrevolver";
private const string Owner = "plugin:RunicNpcTest";
private const string PlayerPrefab = "assets/prefabs/player/player.prefab";
private const ulong StandInId = 11400000101UL;
private readonly List<string> _lines = new List<string>();
private readonly HashSet<BasePlayer> _standIns = new HashSet<BasePlayer>();
private readonly List<BaseEntity> _built = new List<BaseEntity>();
private Coroutine _running;
private int _pass, _fail;
// What RunicNPC's hooks told us.
private readonly List<string> _spawned = new List<string>();
private readonly List<string> _died = new List<string>();
private readonly List<string> _health = new List<string>();
private readonly List<string> _despawned = new List<string>();
private readonly List<string> _changed = new List<string>();
private readonly Dictionary<ulong, float> _lastContributors = new Dictionary<ulong, float>();
private int _hitsOnStandIns;
private static readonly MethodInfo LifeStoryStart =
typeof(BasePlayer).GetMethod("LifeStoryStart", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
// ---- output ----
private void Check(string name, bool ok, string detail = "")
{
if (ok) _pass++; else _fail++;
string line = $"{name}: {(ok ? "PASS" : "FAIL")} {detail}";
_lines.Add(line);
Puts("[rnt] " + line);
Interface.Oxide.DataFileSystem.WriteObject(Name, _lines);
}
private void Note(string line)
{
_lines.Add("# " + line);
Puts("[rnt] # " + line);
Interface.Oxide.DataFileSystem.WriteObject(Name, _lines);
}
// ---- lifecycle ----
private void Unload()
{
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
ClearWorld();
}
private void ClearWorld()
{
foreach (BasePlayer p in _standIns.ToList())
if (p != null && !p.IsDestroyed) p.Kill();
_standIns.Clear();
foreach (BaseEntity e in _built)
if (e != null && !e.IsDestroyed) e.Kill();
_built.Clear();
}
// ---- RunicNPC's API, typed ----
private T Api<T>(string call, params object[] args)
{
object r = RunicNPC?.Call(call, args);
return r is T ? (T)r : default(T);
}
private BasePlayer Spawn(Vector3 at, string profile, JObject overrides = null, string owner = Owner) =>
Api<BasePlayer>("RunicNpc_Spawn", at, profile, owner, overrides);
private List<Dictionary<string, object>> List(string owner) =>
Api<List<Dictionary<string, object>>>("RunicNpc_List", owner) ?? new List<Dictionary<string, object>>();
private Dictionary<string, object> Describe(BasePlayer npc) =>
npc == null ? null : List(null).FirstOrDefault(d => (ulong)d["netId"] == npc.net.ID.Value);
private static Vector3 V(object o)
{
var d = (Dictionary<string, object>)o;
return new Vector3(Convert.ToSingle(d["x"]), Convert.ToSingle(d["y"]), Convert.ToSingle(d["z"]));
}
private static float Flat(Vector3 a, Vector3 b) => new Vector2(a.x - b.x, a.z - b.z).magnitude;
// ---- places ----
private static bool SampleNavmesh(Vector3 p, float r, out NavMeshHit hit) =>
Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, r, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas);
private static MonumentInfo[] _monuments;
/// <summary>The map's monuments. (TerrainMeta.Path.Monuments is internal in Rust's own assembly.)</summary>
private static MonumentInfo[] Monuments() => _monuments ?? (_monuments = UnityEngine.Object.FindObjectsOfType<MonumentInfo>());
/// <summary>A flat, dry spot on the navmesh far from every monument; fixed seed, so reruns land in the same field.</summary>
private static bool FindField(float clear, int seed, out Vector3 at)
{
var rng = new System.Random(seed);
float half = World.Size / 2f - 300f;
for (int i = 0; i < 4000; i++)
{
var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half);
p.y = TerrainMeta.HeightMap.GetHeight(p);
if (p.y < TerrainMeta.WaterMap.GetHeight(p) + 1f || TerrainMeta.HeightMap.GetSlope(p) > 12f) continue;
if (Monuments().Any(m => Vector3.Distance(m.transform.position, p) < clear)) continue;
NavMeshHit hit;
if (!SampleNavmesh(p, 2f, out hit)) continue;
// The whole 35 m around it must be walkable too, for the wander and route tests.
bool open = true;
for (int k = 0; k < 8 && open; k++)
{
float a = k * Mathf.PI / 4f;
NavMeshHit h2;
open = SampleNavmesh(hit.position + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * 35f, 2f, out h2);
}
if (!open) continue;
at = hit.position;
return true;
}
at = Vector3.zero;
return false;
}
/// <summary>A monument with an AI zone, where Rust's own roam has move points to follow.</summary>
private static bool FindMonument(out Vector3 at, out string name)
{
foreach (MonumentInfo m in Monuments().OrderBy(m => m.name.Contains("military") ? 0 : 1))
{
if (m == null) continue;
AIInformationZone zone = AIInformationZone.GetForPoint(m.transform.position, false);
if (zone == null) continue;
NavMeshHit hit;
if (!SampleNavmesh(m.transform.position, 20f, out hit)) continue;
at = hit.position;
name = System.IO.Path.GetFileNameWithoutExtension(m.name);
return true;
}
at = Vector3.zero;
name = null;
return false;
}
private BasePlayer StandIn(Vector3 at, int index)
{
BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, at) as BasePlayer;
if (p == null) return null;
ulong id = StandInId + (ulong)index;
p.userID = id;
p.UserIDString = id.ToString();
p.displayName = "RntStandIn" + index;
p.enableSaving = false;
p.Spawn();
p.EndSleeping();
LifeStoryStart?.Invoke(p, null);
_standIns.Add(p);
return p;
}
private void RemoveStandIns()
{
foreach (BasePlayer p in _standIns.ToList())
if (p != null && !p.IsDestroyed) p.Kill();
_standIns.Clear();
}
// ---- hooks: RunicNPC's, counted; and the stand-ins made invulnerable ----
private void OnRunicNpcSpawned(BasePlayer npc, string profile, string owner) => _spawned.Add($"{profile}/{owner}");
private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)
{
_died.Add($"{profile}/{owner}");
_lastContributors.Clear();
foreach (KeyValuePair<ulong, float> kv in contributors) _lastContributors[kv.Key] = kv.Value;
}
private void OnRunicNpcHealth(BasePlayer npc, string profile, float fraction) => _health.Add($"{profile}@{fraction}");
private void OnRunicNpcDespawned(BasePlayer npc, string owner) => _despawned.Add(owner);
private void OnRunicNpcPlacementChanged(string id, string change, string previous) =>
_changed.Add(previous != null ? $"{change}:{previous}>{id}" : $"{change}:{id}");
private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info)
{
BasePlayer p = victim as BasePlayer;
if (p == null || !_standIns.Contains(p) || info == null) return;
if (info.Initiator is BasePlayer && ((BasePlayer)info.Initiator).IsNpc) _hitsOnStandIns++;
info.damageTypes.ScaleAll(0f);
}
// ---- the profiles and routes the tests use ----
private static JObject Prof(string movement = "wander", float radius = 15f, string role = "roamer", string kit = Kit, string name = "TRoam") =>
JObject.FromObject(new
{
names = new[] { name },
kits = new[] { kit },
prefab = "scientistnpc_roam",
role,
movement = new { mode = movement, radius },
health = 250f,
damageDealt = 1f,
damageTaken = new { head = 2f, body = 0.5f, legs = 1f },
aimCone = 2f,
ranges = new { sense = 30f, loseTarget = 40f, chase = 40f, attack = 30f },
visionCone = -0.8f,
sleepDistance = 160f,
healthThresholds = new[] { 0.5f }
});
private Dictionary<string, string> SetTestProfiles(bool withEach = true)
{
var all = new JObject
{
["t_roam"] = Prof(),
["t_sentry"] = Prof(role: "sentry", name: "TSentry"),
["t_mon"] = Prof("monument", name: "TMon"),
["t_route"] = Prof("route:t_loop", name: "TRoute"),
["t_bad"] = Prof(kit: "rnt_nokit", name: "TBad")
};
JObject leash = Prof(radius: 4f, name: "TLeash");
leash["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 10f, attack = 4f });
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" };
}
private string SetLoop(Vector3 centre)
{
var points = new JArray();
foreach (Vector3 o in new[] { new Vector3(12, 0, 12), new Vector3(-12, 0, 12), new Vector3(-12, 0, -12), new Vector3(12, 0, -12) })
{
NavMeshHit hit;
SampleNavmesh(centre + o, 3f, out hit);
points.Add(JObject.FromObject(new { x = hit.position.x, y = hit.position.y, z = hit.position.z }));
}
return Api<string>("RunicNpc_SetRoute", "t_loop", JObject.FromObject(new { points, loop = true }));
}
// ---- commands ----
[ConsoleCommand("rnt.run")]
private void CmdRun(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
string group = arg.GetString(0, "all");
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
_running = ServerMgr.Instance.StartCoroutine(Run(group));
arg.ReplyWith($"rnt: running {group}");
}
[ConsoleCommand("rnt.after")]
private void CmdAfter(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
After();
}
/// <summary>Spawns n plugin-owned NPCs, for the check that a plugin's NPCs die with it.</summary>
[ConsoleCommand("rnt.spawn")]
private void CmdSpawn(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
Vector3 field;
if (!FindField(300f, 2024, out field)) return;
int n = 0;
for (int i = 0; i < arg.GetInt(0, 3); i++)
if (Spawn(field + new Vector3(i * 2f, 0f, -30f), "t_roam") != null) n++;
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)
{
if (arg.Connection != null && !arg.IsAdmin) return;
ClearWorld();
Api<int>("RunicNpc_DespawnOwner", Owner);
foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2", "t_roam-1", "t_roam-2", "t_gate", "t_sentry-1", "t_site" })
Api<bool>("RunicNpc_RemovePlacement", id);
Api<bool>("RunicNpc_RemoveRoute", "t_loop");
Api<bool>("RunicNpc_RemoveRoute", "t_rec");
arg.ReplyWith("rnt: cleared");
}
private IEnumerator Run(string group)
{
_pass = _fail = 0;
Note($"run {group} on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}");
Vector3 field;
if (!FindField(300f, 2024, out field))
{
Check("setup.field", false, "no open field on the navmesh");
yield break;
}
Dictionary<string, string> refused = SetTestProfiles();
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", "s5", "place" } : new[] { group };
foreach (string g in groups)
{
IEnumerator inner = g == "api" ? Api(field)
: g == "hooks" ? Hooks(field)
: g == "move" ? Move(field)
: g == "sentry" ? Sentry(field)
: g == "sleep" ? Sleep(field)
: g == "cmd" ? Cmd(field)
: g == "api3" ? Api3(field)
: g == "floor" ? Floor(field)
: g == "place" ? Place(field)
: g == "s5" ? Stage5()
: null;
if (inner == null)
{
Check("setup.group", false, "no group " + g);
continue;
}
while (inner.MoveNext()) yield return inner.Current;
Api<int>("RunicNpc_DespawnOwner", Owner);
ClearWorld();
yield return new WaitForSeconds(1f);
}
Note($"done {group}: {_pass} pass, {_fail} fail");
_running = null;
}
// ---- api: every call's answer ----
private IEnumerator Api(Vector3 field)
{
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"]);
string warning = Api<string>("RunicNpc_CostWarning", 10);
Check("api.costWarning", warning != null && warning.Contains("NPC(s)"), warning);
BasePlayer npc = Spawn(field, "t_roam");
Check("api.spawn", npc != null, npc != null ? npc.GetType().Name : "null");
if (npc == null) yield break;
yield return new WaitForSeconds(2f);
Check("api.isRunicNpc", Api<bool>("RunicNpc_IsRunicNpc", npc), "");
Check("api.profileOf", Api<string>("RunicNpc_ProfileOf", npc) == "t_roam", Api<string>("RunicNpc_ProfileOf", npc));
Check("api.name", npc.displayName == "TRoam", npc.displayName);
Check("api.health", Mathf.Approximately(npc.health, 250f) && Mathf.Approximately(npc.MaxHealth(), 250f), $"{npc.health}/{npc.MaxHealth()}");
Check("api.notSaved", !npc.enableSaving, "enableSaving=" + npc.enableSaving);
Check("api.isNpc", npc.IsNpc, "IsNpc=" + npc.IsNpc);
HeldEntity held = npc.GetHeldEntity();
Check("api.kit", held != null && held.ShortPrefabName.Contains("revolver"), held != null ? held.ShortPrefabName : "nothing held");
Dictionary<string, object> d = Describe(npc);
Check("api.list", d != null && (string)d["owner"] == Owner && (string)d["movement"] == "wander", d != null ? $"{d["owner"]} {d["movement"]} {d["state"]}" : "not listed");
BasePlayer ov = Spawn(field + new Vector3(4, 0, 0), "t_roam", JObject.FromObject(new { health = 500f, names = new[] { "Ov" } }));
yield return new WaitForSeconds(0.5f);
Check("api.overrides", ov != null && ov.displayName == "Ov" && Mathf.Approximately(ov.MaxHealth(), 500f), ov != null ? $"{ov.displayName} {ov.MaxHealth()}" : "null");
Check("api.refuse.placementOwner", Spawn(field, "t_roam", null, "placement:x") == null, "");
Check("api.refuse.badOwner", Spawn(field, "t_roam", null, "nobody") == null, "");
Check("api.refuse.unknownProfile", Spawn(field, "nope") == null, "");
Check("api.refuse.refusedProfile", Spawn(field, "t_bad") == null, "");
Check("api.refuse.badOverride", Spawn(field, "t_roam", JObject.FromObject(new { health = -5f })) == null, "");
Check("api.refuse.offMesh", Spawn(field + Vector3.up * 40f, "t_roam") == null, "40 m in the air");
int listed = List(Owner).Count;
Check("api.listOwner", listed == 2, "listed " + listed);
Check("api.despawn", Api<int>("RunicNpc_Despawn", ov.net.ID.Value) == 1, "");
Check("api.despawnOwner", Api<int>("RunicNpc_DespawnOwner", Owner) == 1, "");
Check("api.gone", List(Owner).Count == 0, "listed " + List(Owner).Count);
}
// ---- hooks: spawned, health threshold with contributors, damage by body part, died, despawned ----
private IEnumerator Hooks(Vector3 field)
{
_spawned.Clear(); _died.Clear(); _health.Clear(); _despawned.Clear();
BasePlayer shooter = StandIn(field + new Vector3(0, 0, 60), 0);
BasePlayer npc = Spawn(field, "t_roam");
yield return new WaitForSeconds(1f);
Check("hooks.spawned", _spawned.Contains("t_roam/" + Owner), string.Join(",", _spawned.ToArray()));
// 100 bullet damage with no bone: body, scaled 0.5 → 50 (then Rust's own protection).
float before = npc.health;
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 100f));
float body = before - npc.health;
Check("hooks.damageTaken.body", body > 5f && body <= 50.5f, $"took {body:0.0} of 100 (body scale 0.5)");
// To below half: the 0.5 threshold fires, once.
while (npc.health / npc.MaxHealth() > 0.45f)
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 60f));
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 1f));
Check("hooks.health", _health.Count == 1 && _health[0] == "t_roam@0.5", string.Join(",", _health.ToArray()));
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f));
yield return new WaitForSeconds(1f);
Check("hooks.died", _died.Contains("t_roam/" + Owner), string.Join(",", _died.ToArray()));
Check("hooks.contributors", _lastContributors.ContainsKey(StandInId) && _lastContributors[StandInId] > 240f && _lastContributors[StandInId] <= 250.5f,
string.Join(",", _lastContributors.Select(kv => kv.Key + "=" + kv.Value.ToString("0")).ToArray()));
Check("hooks.diedNotDespawned", _despawned.Count == 0, "despawned: " + string.Join(",", _despawned.ToArray()));
Check("hooks.unregisteredOnDeath", List(Owner).Count == 0, "listed " + List(Owner).Count);
BasePlayer second = Spawn(field, "t_roam");
yield return new WaitForSeconds(0.5f);
Api<int>("RunicNpc_Despawn", second.net.ID.Value);
Check("hooks.despawned", _despawned.Count == 1 && _despawned[0] == Owner, string.Join(",", _despawned.ToArray()));
}
// ---- move: wander stays within its radius, a route visits every point, a monument roams, a leash holds ----
private IEnumerator Move(Vector3 field)
{
// A stand-in 100 m off keeps them awake (inside 160 m) without being sensed (30 m).
StandIn(field + new Vector3(0, 0, 100), 1);
BasePlayer wander = Spawn(field, "t_roam");
BasePlayer route = Spawn(field + new Vector3(60, 0, 0), "t_route");
Vector3 monument;
string monumentName;
BasePlayer mon = null;
if (FindMonument(out monument, out monumentName))
{
StandIn(monument + new Vector3(0, 0, 100), 2);
mon = Spawn(monument, "t_mon");
}
Vector3 home = wander.transform.position;
Vector3 monHome = mon != null ? mon.transform.position : Vector3.zero;
float wanderMax = 0f, wanderMoved = 0f, monMax = 0f;
Route loop = JObject.FromObject(Api<JObject>("RunicNpc_Routes")["t_loop"]).ToObject<Route>();
var reached = new bool[loop.points.Count];
var order = new List<int>();
for (int t = 0; t < 90; t++)
{
yield return new WaitForSeconds(1f);
if (wander != null && !wander.IsDestroyed)
{
float d = Flat(wander.transform.position, home);
wanderMax = Mathf.Max(wanderMax, d);
wanderMoved = Mathf.Max(wanderMoved, d);
}
if (route != null && !route.IsDestroyed)
for (int i = 0; i < loop.points.Count; i++)
if (Flat(route.transform.position, loop.points[i].V) < 2.5f)
{
reached[i] = true;
if (order.Count == 0 || order[order.Count - 1] != i) order.Add(i);
}
if (mon != null && !mon.IsDestroyed)
monMax = Mathf.Max(monMax, Flat(mon.transform.position, monHome));
}
Check("move.wander.moves", wanderMoved > 2f, $"furthest {wanderMoved:0.0} m from its spot");
Check("move.wander.radius", wanderMax <= 15f + 4f, $"furthest {wanderMax:0.0} m, radius 15");
Check("move.route.everyPoint", reached.All(x => x), "reached " + string.Join("", reached.Select(x => x ? "1" : "0").ToArray()) + " order " + string.Join(">", order.Select(x => x.ToString()).ToArray()));
bool inOrder = true;
for (int i = 1; i < order.Count; i++)
if (order[i] != (order[i - 1] + 1) % loop.points.Count) inOrder = false;
Check("move.route.inOrder", order.Count >= 3 && inOrder, string.Join(">", order.Select(x => x.ToString()).ToArray()));
if (mon != null)
Check("move.monument.roams", monMax > 5f, $"{monumentName}: furthest {monMax:0.0} m (Rust's own roam)");
else
Check("move.monument.roams", false, "no monument with an AI zone");
Api<int>("RunicNpc_DespawnOwner", Owner);
RemoveStandIns();
// The leash: chase 10 m from home. A stand-in 25 m off draws it out; past 10 m it must turn back.
BasePlayer leash = Spawn(field, "t_leash");
yield return new WaitForSeconds(1f);
Vector3 leashHome = leash.transform.position;
StandIn(leashHome + new Vector3(25, 0, 0), 3);
float leashMax = 0f;
for (int t = 0; t < 40; t++)
{
yield return new WaitForSeconds(1f);
if (leash == null || leash.IsDestroyed) break;
leashMax = Mathf.Max(leashMax, Flat(leash.transform.position, leashHome));
}
Dictionary<string, object> d2 = Describe(leash);
int leashes = d2 != null ? Convert.ToInt32(d2["leashes"]) : -1;
Check("move.chase.pursues", leashMax >= 6f, $"went {leashMax:0.0} m toward a target 25 m off (attack 4, chase 10)");
Check("move.chase.bounded", leashMax <= 10f + 4f, $"furthest {leashMax:0.0} m, chase 10");
Check("move.leash.givesUp", leashes >= 1, $"gave up {leashes} time(s) on a target it could not reach");
}
// ---- sentry: holds its spot, shoots, and may stand off the navmesh ----
private IEnumerator Sentry(Vector3 field)
{
BasePlayer sentry = Spawn(field, "t_sentry");
yield return new WaitForSeconds(1f);
Vector3 spot = sentry.transform.position;
_hitsOnStandIns = 0;
StandIn(spot + new Vector3(12, 0, 0), 4);
float moved = 0f;
for (int t = 0; t < 40; t++)
{
yield return new WaitForSeconds(1f);
moved = Mathf.Max(moved, Flat(sentry.transform.position, spot));
}
Check("sentry.holds", moved < 1f, $"moved {moved:0.00} m in 40 s with a target 12 m off");
Check("sentry.shoots", _hitsOnStandIns > 0, $"{_hitsOnStandIns} hit(s) on the stand-in");
Dictionary<string, object> d = Describe(sentry);
Note("sentry state: " + (d != null ? d["state"] : "?"));
Api<int>("RunicNpc_DespawnOwner", Owner);
RemoveStandIns();
// A player-built floor 4 m up, then a spot truly off the navmesh.
BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", field + new Vector3(0, 4f, 20f));
if (floor == null)
{
Check("sentry.offMesh.floor", false, "the floor prefab made nothing");
yield break;
}
try
{
floor.enableSaving = false;
// An unsupported floor collapses at once under Rust's stability; grounded, it stays up.
StabilityEntity stable = floor as StabilityEntity;
if (stable != null) stable.grounded = true;
floor.Spawn();
}
catch (Exception e)
{
Check("sentry.offMesh.floor", false, e.ToString());
yield break;
}
_built.Add(floor);
// Stage 1 read a new floor as off the navmesh, sampling in the frame it spawned. How long
// does Rust's navmesh take to cover it?
Vector3 top = floor.transform.position + Vector3.up * 0.1f;
float spawnedAt = Time.realtimeSinceStartup;
bool covered = false;
int frames = 0;
while (!covered && Time.realtimeSinceStartup - spawnedAt < 10f)
{
NavMeshHit onTop;
covered = SampleNavmesh(top, 1f, out onTop) && Mathf.Abs(onTop.position.y - top.y) < 0.6f;
if (!covered)
{
frames++;
yield return null;
}
}
if (floor == null || floor.IsDestroyed)
{
Check("sentry.floor", false, "the floor did not stay up");
yield break;
}
Check("sentry.floor.meshCovers", covered, covered
? $"the navmesh covered a new floor 4 m up after {Time.realtimeSinceStartup - spawnedAt:0.00} s ({frames} frames)"
: "not covered within 10 s");
BasePlayer roamer = Spawn(top, "t_roam");
Check("sentry.floor.roamerStandsOnIt", !covered || (roamer != null && Mathf.Abs(roamer.transform.position.y - top.y) < 0.6f),
roamer != null ? $"a roamer placed at y={roamer.transform.position.y:0.00}, floor {top.y:0.00}" : "refused");
if (roamer != null) Api<int>("RunicNpc_Despawn", roamer.net.ID.Value);
// Truly off the navmesh: 40 m up. A roamer is refused; a sentry is placed and stays.
Vector3 air = field + new Vector3(0f, 40f, -20f);
Check("sentry.offMesh.roamerRefused", Spawn(air, "t_roam") == null, "40 m up");
BasePlayer up = Spawn(air, "t_sentry");
Check("sentry.offMesh.sentryPlaced", up != null, "");
if (up != null)
{
StandIn(field + new Vector3(0, 0, 100), 5);
yield return new WaitForSeconds(20f);
bool there = up != null && !up.IsDestroyed && Vector3.Distance(up.transform.position, air) < 1.5f;
Check("sentry.offMesh.stays", there, up != null && !up.IsDestroyed ? $"{Vector3.Distance(up.transform.position, air):0.00} m from its spot after 20 s" : "gone");
}
}
// ---- sleep: walk home past 160 m of any player, then sleep; wake when one comes back ----
private IEnumerator Sleep(Vector3 field)
{
BasePlayer keeper = StandIn(field + new Vector3(0, 0, 100), 6);
BasePlayer npc = Spawn(field, "t_roam", JObject.FromObject(new { movement = new { mode = "wander", radius = 30f } }));
yield return new WaitForSeconds(1f);
Vector3 home = npc.transform.position;
// Let it wander off its spot.
float away = 0f;
for (int t = 0; t < 60 && away < 8f; t++)
{
yield return new WaitForSeconds(1f);
away = Flat(npc.transform.position, home);
}
Check("sleep.awayFirst", away >= 3f, $"{away:0.0} m from home before the players leave");
keeper.Kill();
_standIns.Remove(keeper);
string rest = "";
bool sawGoingHome = false;
float maxStep = 0f;
Vector3 last = npc.transform.position;
for (int t = 0; t < 90; t++)
{
yield return new WaitForSeconds(1f);
maxStep = Mathf.Max(maxStep, Flat(npc.transform.position, last));
last = npc.transform.position;
rest = (string)Describe(npc)["rest"];
if (rest == "GoingHome") sawGoingHome = true;
if (rest == "Asleep") break;
}
float fromHome = Flat(npc.transform.position, home);
Check("sleep.walksHome", sawGoingHome || away < 3f, "saw GoingHome=" + sawGoingHome);
Check("sleep.asleep", rest == "Asleep", "rest=" + rest);
Check("sleep.atHome", fromHome <= 3f, $"{fromHome:0.0} m from home");
Check("sleep.walkedNotTeleported", maxStep < 8f, $"largest step {maxStep:0.0} m per second");
// Still for 10 s asleep.
Vector3 sleptAt = npc.transform.position;
yield return new WaitForSeconds(10f);
Check("sleep.still", Flat(npc.transform.position, sleptAt) < 0.5f, "");
StandIn(field + new Vector3(0, 0, 100), 7);
yield return new WaitForSeconds(4f);
rest = (string)Describe(npc)["rest"];
Check("sleep.wakes", rest == "Awake", "rest=" + rest);
}
// ---- place: each and group respawn, a missing profile waits, a missing route waits ----
private IEnumerator Place(Vector3 field)
{
Vector3 a = field + new Vector3(-40, 0, 0);
Vector3 b = field + new Vector3(-40, 0, 30);
StandIn(field + new Vector3(-40, 0, 110), 8);
Check("place.set.each", Api<string>("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each")) == null, "");
Check("place.set.group", Api<string>("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group")) == null, "");
yield return new WaitForSeconds(4f);
List<Dictionary<string, object>> each = List("placement:t_each");
List<Dictionary<string, object>> group = List("placement:t_group");
Check("place.spawned", each.Count == 3 && group.Count == 3, $"each {each.Count}, group {group.Count}");
// each: kill one; the other two stay, and it alone returns after ~20 s.
var survivors = new HashSet<ulong>(each.Skip(1).Select(x => (ulong)x["netId"]));
Kill((ulong)each[0]["netId"]);
yield return new WaitForSeconds(2f);
Check("place.each.down", List("placement:t_each").Count == 2, "" + List("placement:t_each").Count);
yield return new WaitForSeconds(22f);
List<Dictionary<string, object>> after = List("placement:t_each");
Check("place.each.back", after.Count == 3 && survivors.All(id => after.Any(x => (ulong)x["netId"] == id)), $"{after.Count} alive, survivors kept={survivors.All(id => after.Any(x => (ulong)x["netId"] == id))}");
// group: kill one; nothing returns while two live. Kill the rest; all three return together.
Kill((ulong)group[0]["netId"]);
yield return new WaitForSeconds(24f);
Check("place.group.waitsForAll", List("placement:t_group").Count == 2, "" + List("placement:t_group").Count);
foreach (Dictionary<string, object> x in List("placement:t_group")) Kill((ulong)x["netId"]);
yield return new WaitForSeconds(10f);
Check("place.group.notEarly", List("placement:t_group").Count == 0, "" + List("placement:t_group").Count);
yield return new WaitForSeconds(14f);
Check("place.group.back", List("placement:t_group").Count == 3, "" + List("placement:t_group").Count);
// D237: remove t_each's profile. Its NPCs despawn and the placement waits; restore it and they return.
SetTestProfiles(withEach: false);
yield return new WaitForSeconds(2f);
Dictionary<string, object> row = Row("t_each");
Check("place.missingProfile.waits", List("placement:t_each").Count == 0 && row != null && row["waiting"] != null, row != null ? "" + row["waiting"] : "no row");
SetTestProfiles();
yield return new WaitForSeconds(4f);
Check("place.missingProfile.returns", List("placement:t_each").Count == 3, "" + List("placement:t_each").Count);
JObject waitRoute = Placement("t_roam", a, 1, 20f, "each");
waitRoute["movement"] = JObject.FromObject(new { mode = "route:t_nope", radius = 0f });
Check("place.set.routeOverride", Api<string>("RunicNpc_SetPlacement", "t_waitroute", waitRoute) == null, "");
yield return new WaitForSeconds(2f);
row = Row("t_waitroute");
Check("place.missingRoute.waits", row != null && ("" + row["waiting"]).Contains("route"), row != null ? "" + row["waiting"] : "no row");
Api<bool>("RunicNpc_RemovePlacement", "t_waitroute");
Check("place.remove", Api<bool>("RunicNpc_RemovePlacement", "t_group") && List("placement:t_group").Count == 0, "");
Check("place.refuse.badRespawnMode", Api<string>("RunicNpc_SetPlacement", "t_x", Placement("t_roam", a, 1, 20f, "sometimes")) != null, "");
// Leave two for rnt.after, across a reload or a restart.
Api<bool>("RunicNpc_RemovePlacement", "t_each");
Api<string>("RunicNpc_SetPlacement", "t_keep1", Placement("t_roam", a, 2, 30f, "each"));
Api<string>("RunicNpc_SetPlacement", "t_keep2", Placement("t_sentry", b, 1, 30f, "each"));
Note("left placements t_keep1 (2) and t_keep2 (1) for rnt.after");
}
// ---- cmd: the stage 3 commands, through the server console and the chat path ----
private static string F(float f) => f.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
private static string At(Vector3 v) => $"at={F(v.x)},{F(v.y)},{F(v.z)}";
/// <summary>Runs a RunicNPC console command as the server console and returns its answer.</summary>
private string Rnpc(string command)
{
string reply = ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), command) ?? "";
Note($"> {command} => {reply.Replace(Environment.NewLine, " | ")}");
return reply;
}
private JObject PlacementData(string id)
{
Dictionary<string, object> row = Row(id);
return row != null ? (JObject)row["placement"] : null;
}
private IEnumerator Cmd(Vector3 field)
{
Vector3 a = OnMesh(field + new Vector3(40, 0, -40));
Vector3 b = OnMesh(field + new Vector3(40, 0, -10));
Vector3 air = a + Vector3.up * 40f;
// A stand-in 60-130 m from every spot keeps the NPCs awake (D235) without being sensed.
StandIn(OnMesh(field + new Vector3(60, 0, 90)), 21);
Check("cmd.help", Rnpc("rnpc.help").Contains("rnpc.path record"), "");
Check("cmd.status", Rnpc("rnpc.status").Contains("placements="), "");
// D242 options and D241 names.
string r = Rnpc($"rnpc.place t_roam {At(a)} count=2 respawn=45 mode=group move=wander radius=8");
JObject p1 = PlacementData("t_roam-1");
Check("cmd.place.named", r.Contains("Placed t_roam-1") && p1 != null, "");
Check("cmd.place.options", p1 != null && (int)p1["count"] == 2 && (float)p1["respawn"] == 45f && (string)p1["respawnMode"] == "group" &&
(string)p1["movement"]?["mode"] == "wander" && (float)p1["movement"]?["radius"] == 8f, "" + p1?.ToString(Formatting.None));
Check("cmd.place.costWarning", r.Contains("NPC(s) on this server"), "");
r = Rnpc($"rnpc.place t_roam {At(b)} respawn=20");
Check("cmd.place.nextName", r.Contains("Placed t_roam-2") && PlacementData("t_roam-2") != null, "");
JObject p2 = PlacementData("t_roam-2");
Check("cmd.place.defaults", p2 != null && (int)p2["count"] == 1 && (string)p2["respawnMode"] == "each" && p2["movement"] == null, "" + p2?.ToString(Formatting.None));
// Refusals, each with its reason; none may leave a placement behind.
int before = (Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).Count;
Check("cmd.refuse.unknownProfile", Rnpc($"rnpc.place nope {At(a)}").Contains("no profile"), "");
Check("cmd.refuse.refusedProfile", Rnpc($"rnpc.place t_bad {At(a)}").Contains("refused"), "");
Check("cmd.refuse.unknownOption", Rnpc($"rnpc.place t_roam {At(a)} speed=3").Contains("no option speed="), "");
Check("cmd.refuse.notKeyValue", Rnpc($"rnpc.place t_roam {At(a)} 3").Contains("not key=value"), "");
Check("cmd.refuse.count", Rnpc($"rnpc.place t_roam {At(a)} count=0").Contains("count="), "");
Check("cmd.refuse.twice", Rnpc($"rnpc.place t_roam {At(a)} count=1 count=2").Contains("twice"), "");
Check("cmd.refuse.mode", Rnpc($"rnpc.place t_roam {At(a)} mode=sometimes").Contains("each or group"), "");
Check("cmd.refuse.missingRoute", Rnpc($"rnpc.place t_roam {At(a)} move=route:t_none").Contains("no route 't_none'"), "");
Check("cmd.refuse.sentryMoves", Rnpc($"rnpc.place t_sentry {At(a)} move=wander").Contains("sentry"), "");
Check("cmd.refuse.offMesh", Rnpc($"rnpc.place t_roam {At(air)}").Contains("m above the"), "");
Check("cmd.refuse.noSpot", Rnpc("rnpc.place t_roam").Contains("at=x,y,z"), "");
Check("cmd.refuse.leftNothing", (Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).Count == before, "");
Check("cmd.sentryOffMesh", Rnpc($"rnpc.place t_sentry {At(air)}").Contains("Placed t_sentry-1"), "a sentry may stand 40 m up");
Rnpc("rnpc.remove t_sentry-1");
Check("cmd.refuse.api.offMesh", Api<string>("RunicNpc_SetPlacement", "t_air", Placement("t_roam", air, 1, 20f, "each")) != null, "the API refuses a roamer off the navmesh too");
yield return new WaitForSeconds(4f);
Check("cmd.place.spawned", List("placement:t_roam-1").Count == 2 && List("placement:t_roam-2").Count == 1,
$"t_roam-1 {List("placement:t_roam-1").Count}, t_roam-2 {List("placement:t_roam-2").Count}");
// Rename (D241): the NPC keeps living and changes owner with it.
ulong renamedNpc = List("placement:t_roam-2").Select(d => (ulong)d["netId"]).FirstOrDefault();
r = Rnpc("rnpc.rename t_roam-2 t_gate");
List<Dictionary<string, object>> gate = List("placement:t_gate");
Check("cmd.rename", r.Contains("Renamed") && PlacementData("t_gate") != null && PlacementData("t_roam-2") == null, "");
Check("cmd.rename.keepsNpc", gate.Count == 1 && (ulong)gate[0]["netId"] == renamedNpc && (string)gate[0]["placement"] == "t_gate", "");
Check("cmd.rename.refuseTaken", Rnpc("rnpc.rename t_gate t_roam-1").Contains("taken"), "");
Check("cmd.rename.refuseBadName", Rnpc("rnpc.rename t_gate Bad!Name").Contains("1–40"), "");
Check("cmd.place.nameReused", Rnpc($"rnpc.place t_roam {At(b)}").Contains("Placed t_roam-2"), "the freed number is taken again");
Rnpc("rnpc.remove t_roam-2");
// Recording a route (D240), from the console with at=.
// The hill route a first run recorded (points 12-17 m up): the patrol never left its first point.
// Recorded again, to note what the reachability check now answers.
Rnpc("rnpc.path record t_hill");
foreach (Vector3 h in new[] { new Vector3(70, 0, -40), new Vector3(90, 0, -40), new Vector3(90, 0, -20) })
Rnpc($"rnpc.path point {At(OnMesh(field + h))}");
Rnpc("rnpc.path cancel");
Check("cmd.path.pointFirst", Rnpc($"rnpc.path point {At(a)}").Contains("Nothing is being recorded"), "");
Check("cmd.path.record", Rnpc("rnpc.path record t_rec").Contains("Recording 't_rec'"), "");
Vector3[] pts = { OnMesh(field + new Vector3(-25, 0, -25)), OnMesh(field + new Vector3(-5, 0, -25)), OnMesh(field + new Vector3(-5, 0, -5)), OnMesh(field + new Vector3(-25, 0, -5)) };
Note("route points y: " + string.Join(", ", pts.Select(v => v.y.ToString("0.0")).ToArray()));
Check("cmd.path.point1", Rnpc($"rnpc.path point {At(pts[0])}").Contains("Point 1"), "");
Check("cmd.path.saveTooFew", Rnpc("rnpc.path save").Contains("at least two"), "");
Check("cmd.path.pointOffMesh", Rnpc($"rnpc.path point {At(pts[0] + Vector3.up * 30f)}").Contains("Refused"), "");
Rnpc($"rnpc.path point {At(pts[1])}");
Rnpc($"rnpc.path point {At(pts[3])}");
Check("cmd.path.undo", Rnpc("rnpc.path undo").Contains("2 left"), "");
Rnpc($"rnpc.path point {At(pts[2])}");
Check("cmd.path.saveBadHow", Rnpc("rnpc.path save sideways").Contains("loop|back"), "");
r = Rnpc("rnpc.path save back");
JObject routes = Api<JObject>("RunicNpc_Routes");
JToken rec = routes?["t_rec"];
Check("cmd.path.saved", r.Contains("Saved 't_rec': 3 points") && rec != null && ((JArray)rec["points"]).Count == 3 && !(bool)rec["loop"], "" + rec?.ToString(Formatting.None));
Check("cmd.path.list", Rnpc("rnpc.path list").Contains("t_rec · 3 points"), "");
// A patrol on it.
r = Rnpc($"rnpc.place t_roam {At(pts[0])} move=route:t_rec respawn=20");
Check("cmd.patrol.placed", r.Contains("Placed t_roam-2"), "");
for (int s = 0; s < 4; s++)
{
yield return new WaitForSeconds(3f);
List<Dictionary<string, object>> seen = List("placement:t_roam-2");
Note(seen.Count == 1 ? $"patrol +{3 * (s + 1)} s: {Flat(V(seen[0]["position"]), pts[0]):0.0} m from point 0, state={seen[0]["state"]} rest={seen[0]["rest"]}" : "patrol: " + seen.Count + " listed");
}
List<Dictionary<string, object>> patrol = List("placement:t_roam-2");
Check("cmd.patrol.walks", patrol.Count == 1 && (string)patrol[0]["movement"] == "route:t_rec" && Flat(V(patrol[0]["position"]), pts[0]) > 3f,
patrol.Count == 1 ? $"{patrol[0]["movement"]}, {Flat(V(patrol[0]["position"]), pts[0]):0.0} m from the first point" : "not spawned");
r = Rnpc("rnpc.path delete t_rec");
yield return new WaitForSeconds(2f);
Dictionary<string, object> row = Row("t_roam-2");
Check("cmd.path.deleteWaits", r.Contains("1 placement(s) on it now wait") && row != null && ("" + row["waiting"]).Contains("t_rec") && List("placement:t_roam-2").Count == 0,
row != null ? "" + row["waiting"] : "no row");
Rnpc("rnpc.remove t_roam-2");
// respawn, clear, remove.
HashSet<ulong> old = new HashSet<ulong>(List("placement:t_roam-1").Select(d => (ulong)d["netId"]));
r = Rnpc("rnpc.respawn t_roam-1");
yield return new WaitForSeconds(3f);
List<Dictionary<string, object>> fresh = List("placement:t_roam-1");
Check("cmd.respawn", r.Contains("Respawning 2") && fresh.Count == 2 && fresh.All(d => !old.Contains((ulong)d["netId"])), $"{fresh.Count} alive, fresh={fresh.All(d => !old.Contains((ulong)d["netId"]))}");
r = Rnpc("rnpc.clear placement:t_gate");
Check("cmd.clear", r.Contains("Removed 1") && List("placement:t_gate").Count == 0 && PlacementData("t_gate") != null, "the NPC goes, the placement stays");
Check("cmd.clear.refuseBadOwner", Rnpc("rnpc.clear nobody").Contains("Usage"), "");
r = Rnpc("rnpc.remove t_gate");
Check("cmd.remove", r.Contains("Removed t_gate") && PlacementData("t_gate") == null, "");
Check("cmd.remove.unknown", Rnpc("rnpc.remove t_gate").Contains("no placement"), "");
Check("cmd.near.console", Rnpc("rnpc.near").Contains("server console"), "");
Check("cmd.info.console", Rnpc("rnpc.info").Contains("in game"), "");
// The chat path, as a stand-in player: nothing without a permission, then place where it stands.
BasePlayer admin = StandIn(b, 20);
string uid = admin.UserIDString;
RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "here", "t_roam" });
Check("cmd.chat.needsPermission", PlacementData("t_roam-2") == null, "");
permission.GrantUserPermission(uid, "runicnpc.place", null);
RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "here", "t_roam", "count=1" });
JObject chatPlaced = PlacementData("t_roam-2");
Check("cmd.chat.here", chatPlaced != null && Flat(V(chatPlaced["position"].ToObject<Dictionary<string, object>>()), b) < 1f, "" + chatPlaced?.ToString(Formatting.None));
RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "respawn", "t_roam-2" });
Check("cmd.chat.adminNeedsAdmin", Row("t_roam-2") != null, "respawn is refused with runicnpc.place alone (no error)");
RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "remove", "t_roam-2" });
Check("cmd.chat.remove", PlacementData("t_roam-2") == null, "");
permission.RevokeUserPermission(uid, "runicnpc.place");
admin.Kill();
_standIns.Remove(admin);
// Standalone profile edits (D221): refused while managed, then allowed once the file says it is not.
Check("cmd.profile.managed", Rnpc("rnpc.profile create t_new").Contains("managed"), "");
Check("cmd.profile.show", Rnpc("rnpc.profile show t_roam").Contains("\"names\""), "");
JObject file = Api<JObject>("RunicNpc_Profiles");
file.Remove("refused");
file["managed"] = false;
Interface.Oxide.DataFileSystem.WriteObject("RunicNPC/profiles", file);
Rnpc("rnpc.reload");
Check("cmd.profile.create", Rnpc("rnpc.profile create t_new from=t_roam").Contains("in use"), "");
Rnpc("rnpc.profile set t_new health 321");
Rnpc("rnpc.profile set t_new names Old Jim,Bob");
Rnpc("rnpc.profile set t_new ranges.sense 44");
JToken made = Api<JObject>("RunicNpc_Profiles")?["profiles"]?["t_new"];
Check("cmd.profile.set", made != null && (float)made["health"] == 321f && (float)made["ranges"]["sense"] == 44f &&
made["names"].ToObject<string[]>().SequenceEqual(new[] { "Old Jim", "Bob" }), "" + made?.ToString(Formatting.None));
Check("cmd.profile.set.noField", Rnpc("rnpc.profile set t_new nope 1").Contains("no field"), "");
Check("cmd.profile.set.object", Rnpc("rnpc.profile set t_new ranges 1").Contains("fields of its own"), "");
Check("cmd.profile.set.refusedKit", Rnpc("rnpc.profile set t_new kits rnt_nokit").Contains("cannot spawn yet"), "");
Check("cmd.profile.delete", Rnpc("rnpc.profile delete t_new").Contains("Deleted") && Api<JObject>("RunicNpc_Profiles")?["profiles"]?["t_new"] == null, "");
SetTestProfiles();
IEnumerator floorTest = Floor(field);
while (floorTest.MoveNext()) yield return floorTest.Current;
}
/// <summary>
/// D239: a roamer placed on a player-built floor is told so. The floor is then destroyed under
/// a live NPC (what happens to it is measured, not asserted), and after its death the placement
/// falls back to the nearest navmesh until the floor returns.
/// </summary>
// ---- api3: stage 4's calls (D249) — a placement made from a map point, named as in game ----
private Dictionary<string, object> Add(object placement) =>
Api<Dictionary<string, object>>("RunicNpc_AddPlacement", JObject.FromObject(placement)) ?? new Dictionary<string, object> { ["error"] = "no answer" };
private static string Said(Dictionary<string, object> d) =>
d.ContainsKey("error") ? "error: " + d["error"] : "id " + d["id"] + (d.ContainsKey("position") ? " at " + V(d["position"]) : "");
private IEnumerator Api3(Vector3 field)
{
_changed.Clear();
Vector3 a = OnMesh(field + new Vector3(-40, 0, 40));
StandIn(OnMesh(field + new Vector3(-40, 0, 140)), 31);
// A map point: x and z only. RunicNPC grounds it and names it like rnpc place (D241, D246).
Dictionary<string, object> r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, count = 2, respawn = 30f, respawnMode = "group" });
Check("api3.add.named", !r.ContainsKey("error") && (string)r["id"] == "t_roam-1", Said(r));
Vector3 put = r.ContainsKey("position") ? V(r["position"]) : Vector3.zero;
float ground = TerrainMeta.HeightMap.GetHeight(a);
Check("api3.add.grounded", r.ContainsKey("position") && Mathf.Abs(put.y - ground) < 1.5f, $"y {put.y:0.00}, terrain {ground:0.00}");
Check("api3.add.cost", r.ContainsKey("cost") && ((string)r["cost"]).Contains("NPC(s) on this server"), r.ContainsKey("cost") ? (string)r["cost"] : "");
Check("api3.add.notBuilt", r.ContainsKey("built") && !(bool)r["built"], "");
JObject data = PlacementData("t_roam-1");
Check("api3.add.values", data != null && (int)data["count"] == 2 && (string)data["respawnMode"] == "group", "" + data?.ToString(Formatting.None));
Check("api3.hook.set", _changed.Contains("set:t_roam-1"), string.Join(",", _changed.ToArray()));
r = Add(new { profile = "t_roam", position = new { x = a.x + 6f, z = a.z } });
Check("api3.add.nextName", !r.ContainsKey("error") && (string)r["id"] == "t_roam-2", Said(r));
// Refusals, each with the in-game reason; none leaves a placement behind.
int before = (Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).Count;
float edge = World.Size / 2f + 50f;
r = Add(new { profile = "t_roam", position = new { x = edge, z = 0f } });
Check("api3.refuse.offMap", r.ContainsKey("error") && ((string)r["error"]).Contains("off the map"), Said(r));
r = Add(new { profile = "t_roam", position = new { x = a.x, y = a.y + 40f, z = a.z } });
Check("api3.refuse.offMesh", r.ContainsKey("error") && ((string)r["error"]).Contains("m above the"), Said(r));
r = Add(new { profile = "nope", position = new { x = a.x, z = a.z } });
Check("api3.refuse.unknownProfile", r.ContainsKey("error") && ((string)r["error"]).Contains("no profile"), Said(r));
r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, count = 0 });
Check("api3.refuse.count", r.ContainsKey("error") && ((string)r["error"]).Contains("count"), Said(r));
r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, movement = new { mode = "route:t_none", radius = 0f } });
Check("api3.refuse.missingRoute", r.ContainsKey("error") && ((string)r["error"]).Contains("no route 't_none'"), Said(r));
Vector3 sea;
if (FindWater(out sea))
{
r = Add(new { profile = "t_roam", position = new { x = sea.x, z = sea.z } });
Check("api3.refuse.water", r.ContainsKey("error") && ((string)r["error"]).Contains("under water"), Said(r) + $" at {sea}");
}
else
Note("api3.refuse.water skipped: no deep water found");
Check("api3.refuse.leftNothing", (Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).Count == before, "");
yield return new WaitForSeconds(4f);
Check("api3.spawned", List("placement:t_roam-1").Count == 2, "listed " + List("placement:t_roam-1").Count);
// Rename and respawn, as rnpc rename and rnpc respawn.
_changed.Clear();
string renamed = Api<string>("RunicNpc_RenamePlacement", "t_roam-2", "t_site");
Check("api3.rename", renamed == null && PlacementData("t_site") != null && PlacementData("t_roam-2") == null, renamed ?? "");
Check("api3.hook.renamed", _changed.Contains("renamed:t_roam-2>t_site"), string.Join(",", _changed.ToArray()));
string taken = Api<string>("RunicNpc_RenamePlacement", "t_site", "t_roam-1");
Check("api3.rename.refuseTaken", taken != null && taken.Contains("taken"), taken ?? "null");
ulong first = List("placement:t_roam-1").Select(d => (ulong)d["netId"]).FirstOrDefault();
int respawned = Api<int>("RunicNpc_RespawnPlacement", "t_roam-1");
Check("api3.respawn", respawned == 2, "respawned " + respawned);
Check("api3.respawn.missing", Api<int>("RunicNpc_RespawnPlacement", "t_nothing") == -1, "");
yield return new WaitForSeconds(3f);
List<Dictionary<string, object>> after = List("placement:t_roam-1");
Check("api3.respawn.fresh", after.Count == 2 && after.All(d => (ulong)d["netId"] != first), $"listed {after.Count}");
// The hook for every change made in game too.
_changed.Clear();
Rnpc($"rnpc.place t_roam {At(OnMesh(field + new Vector3(-30, 0, 40)))}");
Rnpc("rnpc.remove t_roam-2");
Check("api3.hook.inGame", _changed.Contains("set:t_roam-2") && _changed.Contains("removed:t_roam-2"), string.Join(",", _changed.ToArray()));
Api<bool>("RunicNpc_RemovePlacement", "t_site");
Check("api3.hook.removed", _changed.Contains("removed:t_site"), string.Join(",", _changed.ToArray()));
Api<bool>("RunicNpc_RemovePlacement", "t_roam-1");
}
/// <summary>A point of open sea at least 5 m deep, for the under-water refusal.</summary>
private static bool FindWater(out Vector3 at)
{
var rng = new System.Random(7);
float half = World.Size / 2f - 20f;
for (int i = 0; i < 4000; i++)
{
var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half);
if (TerrainMeta.WaterMap.GetHeight(p) - TerrainMeta.HeightMap.GetHeight(p) > 5f)
{
at = p;
return true;
}
}
at = Vector3.zero;
return false;
}
private IEnumerator Floor(Vector3 field)
{
// Over dry land: a first run put it over water (terrain −0.25 m), and Rust's navmesh never
// covered it there in 10 s.
Vector3 at = Vector3.zero;
foreach (Vector3 o in new[] { new Vector3(0, 0, 20), new Vector3(-20, 0, 20), new Vector3(20, 0, 0), new Vector3(0, 0, -20), new Vector3(-20, 0, -20) })
{
Vector3 ground = OnMesh(field + o);
if (ground.y > TerrainMeta.WaterMap.GetHeight(ground) + 1f)
{
at = ground + Vector3.up * 4f;
break;
}
}
BaseEntity floor = at == Vector3.zero ? null : BuildFloor(at);
if (floor == null)
{
Check("cmd.floor.built", false, "no floor");
yield break;
}
Vector3 top = floor.transform.position + Vector3.up * 0.1f;
float t0 = Time.realtimeSinceStartup;
NavMeshHit onTop;
bool covered = false;
while (!(covered = SampleNavmesh(top, 1f, out onTop) && Mathf.Abs(onTop.position.y - top.y) < 0.6f) && Time.realtimeSinceStartup - t0 < 10f)
yield return null;
Note($"floor at y={top.y:0.00} over terrain {TerrainMeta.HeightMap.GetHeight(top):0.00}, owner {floor.OwnerID}: " +
(covered ? $"covered after {Time.realtimeSinceStartup - t0:0.00} s" : "NOT covered within 10 s"));
// A route leg an NPC cannot walk is refused: the floor has no stairs, so its top is on the
// navmesh but not reachable from the ground beside it.
Rnpc("rnpc.path record t_up");
Rnpc($"rnpc.path point {At(OnMesh(top + new Vector3(8f, 0f, 0f)))}");
Check("cmd.path.unreachableRefused", Rnpc($"rnpc.path point {At(top)}").Contains("cannot walk here"), "ground to the top of a floor with no stairs");
Rnpc("rnpc.path cancel");
string r = Rnpc($"rnpc.place t_roam {At(top)} respawn=5 move=wander radius=2");
string id = System.Text.RegularExpressions.Regex.Match(r, @"Placed (\S+):").Groups[1].Value;
Check("cmd.floor.warns", r.Contains("player-built structure"), id);
StandIn(OnMesh(field + new Vector3(60, 0, 90)), 22);
yield return new WaitForSeconds(3f);
List<Dictionary<string, object>> on = List("placement:" + id);
Check("cmd.floor.standsOnIt", on.Count == 1 && Mathf.Abs(V(on[0]["position"]).y - top.y) < 0.8f, on.Count == 1 ? $"y={V(on[0]["position"]).y:0.00}, floor {top.y:0.00}" : "not spawned");
// D239 under a live NPC: Rust leaves it standing in the air; RunicNPC puts it on the nearest
// navmesh within its 2 s check.
ulong liveId = on.Count == 1 ? (ulong)on[0]["netId"] : 0UL;
floor.Kill();
_built.Remove(floor);
float downAfter = -1f;
for (int s = 1; s <= 10; s++)
{
yield return new WaitForSeconds(1f);
on = List("placement:" + id);
if (on.Count == 1 && downAfter < 0f && V(on[0]["position"]).y < top.y - 2f)
downAfter = s;
if (s == 1 || s == downAfter || s == 10)
Note(on.Count == 1
? $"floor gone +{s} s: y={V(on[0]["position"]).y:0.00} (floor was {top.y:0.00}) state={on[0]["state"]} health={Convert.ToSingle(on[0]["health"]):0} regrounded={on[0]["regrounded"]}"
: $"floor gone +{s} s: {on.Count} NPC(s) listed");
}
on = List("placement:" + id);
Check("cmd.floor.liveRegrounds", on.Count == 1 && (ulong)on[0]["netId"] == liveId && downAfter > 0f && downAfter <= 4f && Row(id)?["note"] != null,
downAfter > 0f ? $"on the ground {downAfter:0} s after the floor went, the same NPC; note: {Row(id)?["note"]}" : "still in the air after 10 s");
foreach (Dictionary<string, object> d in List("placement:" + id))
Kill((ulong)d["netId"]);
yield return new WaitForSeconds(9f);
on = List("placement:" + id);
Dictionary<string, object> row = Row(id);
Check("cmd.floor.fallsBack", on.Count == 1 && V(on[0]["position"]).y < top.y - 2f && row != null && row["note"] != null,
on.Count == 1 ? $"respawned at y={V(on[0]["position"]).y:0.00}; note: {row?["note"]}" : $"{on.Count} alive; lastError={row?["lastError"]}");
// The floor is rebuilt: the next respawn is back on it, and the note clears.
floor = BuildFloor(at);
yield return new WaitForSeconds(1f);
Rnpc("rnpc.respawn " + id);
yield return new WaitForSeconds(3f);
on = List("placement:" + id);
row = Row(id);
Check("cmd.floor.returns", on.Count == 1 && Mathf.Abs(V(on[0]["position"]).y - top.y) < 0.8f && row?["note"] == null,
on.Count == 1 ? $"y={V(on[0]["position"]).y:0.00}; note: {row?["note"] ?? "-"}" : "not spawned");
Rnpc("rnpc.remove " + id);
Rnpc("rnpc.remove t_roam-1");
}
private BaseEntity BuildFloor(Vector3 at)
{
BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", at);
if (floor == null) return null;
floor.enableSaving = false;
// An unsupported floor collapses at once under Rust's stability; grounded, it stays up.
StabilityEntity stable = floor as StabilityEntity;
if (stable != null) stable.grounded = true;
floor.OwnerID = StandInId;
floor.Spawn();
_built.Add(floor);
return floor;
}
private static Vector3 OnMesh(Vector3 p)
{
p.y = TerrainMeta.HeightMap.GetHeight(p);
NavMeshHit hit;
return SampleNavmesh(p, 3f, out hit) ? hit.position : p;
}
private static JObject Placement(string profile, Vector3 at, int count, float respawn, string mode) =>
JObject.FromObject(new { profile, position = new { x = at.x, y = at.y, z = at.z }, yaw = 0f, count, respawn, respawnMode = mode });
private Dictionary<string, object> Row(string id) =>
(Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).FirstOrDefault(r => (string)r["id"] == id);
private static void Kill(ulong netId)
{
BasePlayer p = BaseNetworkable.serverEntities.Find(new NetworkableId(netId)) as BasePlayer;
if (p != null && !p.IsDead()) p.Hurt(new HitInfo(null, p, Rust.DamageType.Bullet, 10000f));
}
/// <summary>
/// After a reload or restart: every placement is back to its count, every RunicNPC entity in
/// the world is one RunicNPC lists, and no plain scientist stands at a placement's spot (the
/// saved-subclass symptom stage 1 found).
/// </summary>
private void After()
{
_pass = _fail = 0;
List<Dictionary<string, object>> rows = Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>();
foreach (Dictionary<string, object> r in rows)
{
JObject p = (JObject)r["placement"];
Check($"after.placement.{r["id"]}", Convert.ToInt32(r["alive"]) == (int)p["count"], $"{r["alive"]}/{p["count"]} alive, waiting={r["waiting"] ?? "-"}");
}
var listed = new HashSet<ulong>(List(null).Select(d => (ulong)d["netId"]));
List<BasePlayer> world = BaseNetworkable.serverEntities.OfType<BasePlayer>().Where(p => p.GetType().Name == "RunicNpcPlayer").ToList();
Check("after.worldMatchesRegistry", world.Count == listed.Count && world.All(p => listed.Contains(p.net.ID.Value)), $"world {world.Count}, listed {listed.Count}");
int strays = 0;
foreach (Dictionary<string, object> r in rows)
{
Vector3 at = V(((JObject)r["placement"])["position"].ToObject<Dictionary<string, object>>());
strays += BaseNetworkable.serverEntities.OfType<ScientistNPC>().Count(s => s.GetType() == typeof(ScientistNPC) && Vector3.Distance(s.transform.position, at) < 25f);
}
Check("after.noSavedScientists", strays == 0, strays + " plain scientist(s) at placement spots");
Check("after.pluginOwnedGone", List(Owner).Count == 0, List(Owner).Count + " left for " + Owner);
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
{
public List<P> points = new List<P>();
public bool loop;
}
private sealed class P
{
public float x, y, z;
public Vector3 V => new Vector3(x, y, z);
}
}
}