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 { /// /// RunicNPC's stage 2 test harness (docs/runicnpc/PLAN.md §9, stage 2). NEVER SHIPPED: it lives /// in tools/ and is loaded on a rig by hand. It drives RunicNPC only through its API and hooks, /// as any other plugin would, and prints one line per assertion: /// /// [rnt] <group>.<check>: PASS|FAIL <detail> /// /// Every line is also kept in data/RunicNpcTest.json, read back over the panel. /// /// /// rnt.run <group> runs one group: api, hooks, move, /// sentry, sleep, place, or all for each in turn. /// rnt.run place leaves two placements behind for the restart check, and /// rnt.after checks, after a reload or restart, that every placement is back and nothing /// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and /// rnt.clear removes everything the harness made. /// /// /// /// It needs the hidden Kits kit rnhrevolver (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 data/RunicNPC/ first and restore it afterwards. /// /// /// /// Targets and wake-ups are stand-ins, stage 1's fake players: player.prefab with /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// [Info("RunicNpcTest", "Runic Gateway", "0.2.0")] [Description("RunicNPC stage 2 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 _lines = new List(); private readonly HashSet _standIns = new HashSet(); private readonly List _built = new List(); private Coroutine _running; private int _pass, _fail; // What RunicNPC's hooks told us. private readonly List _spawned = new List(); private readonly List _died = new List(); private readonly List _health = new List(); private readonly List _despawned = new List(); private readonly Dictionary _lastContributors = new Dictionary(); 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(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("RunicNpc_Spawn", at, profile, owner, overrides); private List> List(string owner) => Api>>("RunicNpc_List", owner) ?? new List>(); private Dictionary 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)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; /// The map's monuments. (TerrainMeta.Path.Monuments is internal in Rust's own assembly.) private static MonumentInfo[] Monuments() => _monuments ?? (_monuments = UnityEngine.Object.FindObjectsOfType()); /// A flat, dry spot on the navmesh far from every monument; fixed seed, so reruns land in the same field. 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; } /// A monument with an AI zone, where Rust's own roam has move points to follow. 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 contributors) { _died.Add($"{profile}/{owner}"); _lastContributors.Clear(); foreach (KeyValuePair 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 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 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"); return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "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("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(); } /// Spawns n plugin-owned NPCs, for the check that a plugin's NPCs die with it. [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}"); } [ConsoleCommand("rnt.clear")] private void CmdClear(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; ClearWorld(); Api("RunicNpc_DespawnOwner", Owner); foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2" }) Api("RunicNpc_RemovePlacement", id); Api("RunicNpc_RemoveRoute", "t_loop"); 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 refused = SetTestProfiles(); Check("setup.profiles", refused.Count == 1 && refused.ContainsKey("t_bad"), "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", "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 == "place" ? Place(field) : null; if (inner == null) { Check("setup.group", false, "no group " + g); continue; } while (inner.MoveNext()) yield return inner.Current; Api("RunicNpc_DespawnOwner", Owner); RemoveStandIns(); 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("RunicNpc_ApiVersion") == 2, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); JObject profiles = Api("RunicNpc_Profiles"); Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]); string warning = Api("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("RunicNpc_IsRunicNpc", npc), ""); Check("api.profileOf", Api("RunicNpc_ProfileOf", npc) == "t_roam", Api("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 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("RunicNpc_Despawn", ov.net.ID.Value) == 1, ""); Check("api.despawnOwner", Api("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("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("RunicNpc_Routes")["t_loop"]).ToObject(); var reached = new bool[loop.points.Count]; var order = new List(); 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("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 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 d = Describe(sentry); Note("sentry state: " + (d != null ? d["state"] : "?")); Api("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("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("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each")) == null, ""); Check("place.set.group", Api("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group")) == null, ""); yield return new WaitForSeconds(4f); List> each = List("placement:t_each"); List> 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(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> 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 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 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("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("RunicNpc_RemovePlacement", "t_waitroute"); Check("place.remove", Api("RunicNpc_RemovePlacement", "t_group") && List("placement:t_group").Count == 0, ""); Check("place.refuse.badRespawnMode", Api("RunicNpc_SetPlacement", "t_x", Placement("t_roam", a, 1, 20f, "sometimes")) != null, ""); // Leave two for rnt.after, across a reload or a restart. Api("RunicNpc_RemovePlacement", "t_each"); Api("RunicNpc_SetPlacement", "t_keep1", Placement("t_roam", a, 2, 30f, "each")); Api("RunicNpc_SetPlacement", "t_keep2", Placement("t_sentry", b, 1, 30f, "each")); Note("left placements t_keep1 (2) and t_keep2 (1) for rnt.after"); } 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 Row(string id) => (Api>>("RunicNpc_Placements") ?? new List>()).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)); } /// /// 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). /// private void After() { _pass = _fail = 0; List> rows = Api>>("RunicNpc_Placements") ?? new List>(); foreach (Dictionary 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(List(null).Select(d => (ulong)d["netId"])); List world = BaseNetworkable.serverEntities.OfType().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 r in rows) { Vector3 at = V(((JObject)r["placement"])["position"].ToObject>()); strays += BaseNetworkable.serverEntities.OfType().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"); } /// A route, as RunicNpc_Routes returns it. private sealed class Route { public List

points = new List

(); public bool loop; } private sealed class P { public float x, y, z; public Vector3 V => new Vector3(x, y, z); } } }