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 Oxide.Game.Rust.Cui; using UnityEngine; using UnityEngine.AI; namespace Oxide.Plugins { /// /// 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: /// /// [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, cmd (stage 3's commands), api3 (stage 4's calls), place, /// s5 (stage 5's behaviour: factions, fights, alarm, allies, escort, guard, tether, turrets, /// PVE and the kit's extras), or all for each in turn. /// rnt.run place leaves two placements behind for the restart check, and /// rnt.after checks, after a reload or restart, that every placement is back and nothing /// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and /// 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.6.0")] [Description("RunicNPC stage 2, 3, 4, 5 tests and the stage 6 spike. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { [PluginReference] private Plugin RunicNPC; [PluginReference] private Plugin Kits; 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 List _changed = 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 OnRunicNpcPlacementChanged(string id, string change, string previous) => _changed.Add(previous != null ? $"{change}:{previous}>{id}" : $"{change}:{id}"); private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info) { // Stage 6's spike: the scientist a boss fights takes no damage, and every hit is tallied by weapon. if (_spikeTarget != null && victim == _spikeTarget && info != null) { string w = info.Weapon?.GetItem()?.info.shortname ?? info.WeaponPrefab?.ShortPrefabName ?? "?"; int n; _spikeHits.TryGetValue(w, out n); _spikeHits[w] = n + 1; info.damageTypes.ScaleAll(0f); return; } 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"); Stage5Profiles(all); Stage6Profiles(all); 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}"); } /// A probe: two profiles 15 m apart, described every two seconds for 30 s. [ConsoleCommand("rnt.duel")] private void CmdDuel(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; Vector3 f; if (!FindField(300f, 2025, out f)) return; BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); BasePlayer x = Spawn(OnMesh(f + new Vector3(-140, 0, 0)), arg.GetString(0, "t_red")); BasePlayer y = Spawn(OnMesh(f + new Vector3(-140, 0, 15)), arg.GetString(1, "t_blue")); int n = 0; timer.Repeat(2f, 15, () => { n++; foreach (BasePlayer p in new[] { x, y }) { Dictionary d = p == null || p.IsDestroyed ? null : Describe(p); Puts($"[duel {n}] {(d == null ? "gone" : JsonConvert.SerializeObject(d))} hp={(p == null || p.IsDestroyed ? 0 : p.health):0} held={(p == null || p.IsDestroyed ? "-" : p.GetActiveItem()?.info.shortname ?? "none")}"); } if (n == 15) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } }); arg.ReplyWith("rnt: duel started"); } /// A probe: a t_kit NPC with a flamethrower and fuel, a stand-in 4 m off, every second for 20 s. [ConsoleCommand("rnt.kitprobe")] private void CmdKitProbe(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; Vector3 f; if (!FindField(300f, 2025, out f)) return; Vector3 at = OnMesh(f + new Vector3(-30, 0, -60)); BasePlayer flamer = Spawn(at, "t_kit"); Give(flamer, "flamethrower", 1); Give(flamer, "lowgradefuel", 50); BasePlayer target = StandIn(OnMesh(at + new Vector3(4, 0, 0)), 63); int n = 0; timer.Repeat(1f, 20, () => { n++; ScientistNPC s = flamer as ScientistNPC; Item held = flamer == null || flamer.IsDestroyed ? null : flamer.GetActiveItem(); FlameThrower tank = held?.GetHeldEntity() as FlameThrower; if (n == 3 && tank != null) tank.ammo = 0; var mem = s?.Brain?.Senses?.Memory; Puts($"[kit {n}] state={s?.Brain?.CurrentState?.StateType} held={held?.info.shortname ?? "none"} ammo={(tank != null ? tank.ammo : -1)} fuel={(flamer == null || flamer.IsDestroyed ? -1 : Count(flamer, "lowgradefuel"))} knows={Knows(flamer, target)} los={(mem != null && mem.IsLOS(target))} hits={_hitsOnStandIns}"); if (n == 20) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } }); arg.ReplyWith("rnt: kit probe started"); } /// /// A probe after the 2026-10-05 Rust update: a stock scientist and one of ours (t_sentry), each with a /// stand-in 10 m off, every 2 s for 20 s; and how far the map's own scientists move in that time. /// [ConsoleCommand("rnt.stockprobe")] private void CmdStockProbe(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; Vector3 f; if (!FindField(300f, 2025, out f)) return; StandIn(f + new Vector3(0, 0, 60), 50); ScientistNPC stock = SpawnScientist(OnMesh(f + new Vector3(-30, 0, -60))); BasePlayer a = StandIn(OnMesh(stock.transform.position + new Vector3(10, 0, 0)), 71); BasePlayer ours = Spawn(OnMesh(f + new Vector3(30, 0, -60)), "t_sentry"); BasePlayer b = ours != null ? StandIn(OnMesh(ours.transform.position + new Vector3(10, 0, 0)), 72) : null; List world = BaseNetworkable.serverEntities.OfType().Where(s => s.GetType().Name != "RunicNpcPlayer" && s != stock).Take(20).ToList(); Dictionary from = world.ToDictionary(s => s, s => s.transform.position); _hitsOnStandIns = 0; int n = 0; timer.Repeat(2f, 10, () => { n++; string Line(ScientistNPC s, BasePlayer t) { if (Gone(s)) return "gone"; var senses = s.Brain?.Senses; return $"state={s.Brain?.CurrentState?.StateType} players={senses?.Memory?.Players.Count} targets={senses?.Memory?.Targets.Count} knows={Knows(s, t)} best={((IAIAttack)s).GetBestTarget()?.ShortPrefabName ?? "none"}"; } Puts($"[stock {n}] stock: {Line(stock, a)} | ours: {Line(ours as ScientistNPC, b)} | hits on stand-ins {_hitsOnStandIns}"); if (n == 10) { var moved = world.Where(s => !Gone(s)).Select(s => Vector3.Distance(s.transform.position, from[s])).ToList(); Puts($"[stock] the map's own scientists: {moved.Count}, moved >1 m in 20 s: {moved.Count(m => m > 1f)}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0} m; ai.think={ConVar.AI.think} ai.move={ConVar.AI.move} ai.ignoreplayers={ConVar.AI.ignoreplayers}"); Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } }); arg.ReplyWith("rnt: stock probe started"); } /// A probe: our monument roamer at the test's monument, and that monument's own scientists, every 4 s for 40 s. [ConsoleCommand("rnt.monprobe")] private void CmdMonProbe(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; Vector3 at; string name; if (!FindMonument(out at, out name)) { arg.ReplyWith("no monument"); return; } StandIn(at + new Vector3(0, 0, 100), 2); BasePlayer mon = Spawn(at, "t_mon"); AIInformationZone zone = AIInformationZone.GetForPoint(at, false); List locals = BaseNetworkable.serverEntities.OfType() .Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, at) < 150f).ToList(); Dictionary from = locals.ToDictionary(s => s, s => s.transform.position); Vector3 monFrom = mon != null ? mon.transform.position : Vector3.zero; Puts($"[mon] {name} at {at}: zone {(zone != null ? zone.name : "none")} movePoints={(zone != null ? zone.paths?.Count ?? -1 : -1)}, ours {(mon != null ? "spawned" : "REFUSED")}, its own scientists within 150 m: {locals.Count}"); int n = 0; timer.Repeat(4f, 10, () => { n++; ScientistNPC s = mon as ScientistNPC; var moved = locals.Where(x => !Gone(x)).Select(x => Vector3.Distance(x.transform.position, from[x])).ToList(); Puts($"[mon {n}] ours: state={s?.Brain?.CurrentState?.StateType} moved={(Gone(mon) ? -1 : Vector3.Distance(mon.transform.position, monFrom)):0.0} dest={(s?.Brain?.Navigator != null ? s.Brain.Navigator.Destination.ToString() : "-")} moving={s?.Brain?.Navigator?.Moving} | locals moved >1 m: {moved.Count(m => m > 1f)}/{moved.Count}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0}"); if (n == 10) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } }); arg.ReplyWith("rnt: monument probe started"); } /// Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks. [ConsoleCommand("rnt.list")] private void CmdList(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; string owner = arg.GetString(0, null); var rows = List(owner); arg.ReplyWith(string.Join(Environment.NewLine, rows.Select(d => JsonConvert.SerializeObject(d)).ToArray()) + Environment.NewLine + $"rnt: {rows.Count} NPC(s)"); } /// A probe: a t_kit rocketeer and a stock scientist 25 m off, described every 2 s for 40 s. [ConsoleCommand("rnt.rocketprobe")] private void CmdRocketProbe(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; Vector3 f; if (!FindField(300f, 2025, out f)) return; BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); Vector3 at = OnMesh(f + new Vector3(-30, 0, -60)); BasePlayer r = Spawn(at, "t_kit"); Give(r, "rocket.launcher", 1); Give(r, "ammo.rocket.basic", 2); ScientistNPC target = SpawnScientist(at + new Vector3(25, 0, 0)); int n = 0; timer.Repeat(2f, 20, () => { n++; Dictionary d = Gone(r) ? null : Describe(r); string line = "-"; if (!Gone(r) && !Gone(target)) { RaycastHit hh; line = Physics.Linecast(r.eyes.position, target.CenterPoint(), out hh, 1218519041, QueryTriggerInteraction.Ignore) ? $"hit {hh.collider?.name}/{LayerMask.LayerToName(hh.collider != null ? hh.collider.gameObject.layer : 0)}/{hh.GetEntity()?.ShortPrefabName ?? "none"} at {hh.distance:0.0}" : "clear"; line += $" eyes={r.eyes.position.y:0.0} centre={target.CenterPoint().y:0.0}"; } Puts($"[rocket {n}] line={line} target={TargetOf(d)} engaged={d?["engaged"]} state={d?["state"]} rest={d?["rest"]} kit={(d != null ? JsonConvert.SerializeObject(d["kitUsed"]) : "-")} held={(Gone(r) ? "-" : r.GetActiveItem()?.info.shortname)} ammo={(Gone(r) ? -1 : Count(r, "ammo.rocket.basic"))} sci={(Gone(target) ? 0f : target.health):0} dist={(Gone(r) || Gone(target) ? 0f : Vector3.Distance(r.transform.position, target.transform.position)):0}"); if (n == 20) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } }); arg.ReplyWith("rnt: rocket probe started"); } /// A probe: an escort follows a scientist 35 m, the scientist is killed, then 30 s of what the escort does. [ConsoleCommand("rnt.escortprobe")] private void CmdEscortProbe(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; Vector3 f; if (!FindField(300f, 2025, out f)) return; BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); Vector3 c = OnMesh(f); ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0)); BasePlayer escort = Spawn(c, "t_roam"); Api("RunicNpc_Escort", Id(escort), charge); int n = 0; timer.Repeat(2f, 30, () => { n++; if (n == 3) Teleport(charge, c + new Vector3(0, 0, 35)); if (n == 12 && !Gone(charge)) charge.Kill(); Dictionary d = Gone(escort) ? null : Describe(escort); Puts($"[esc {n}] state={d?["state"]} rest={d?["rest"]} escort={d?["escort"] ?? "null"} target={TargetOf(d)} engaged={d?["engaged"]} home={(d == null ? -1f : Flat(escort.transform.position, V(d["home"]))):0.0} ended={_escortEnded.Count}"); if (n == 30) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } }); arg.ReplyWith("rnt: escort probe started"); } [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", "t_roam-1", "t_roam-2", "t_gate", "t_sentry-1", "t_site" }) Api("RunicNpc_RemovePlacement", id); Api("RunicNpc_RemoveRoute", "t_loop"); Api("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 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", "s6", "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() : g == "s6" ? Stage6() : null; if (inner == null) { Check("setup.group", false, "no group " + g); continue; } while (inner.MoveNext()) yield return inner.Current; Api("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("RunicNpc_ApiVersion") == 5, "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; List locals = new List(); var localsFrom = new Dictionary(); if (FindMonument(out monument, out monumentName)) { StandIn(monument + new Vector3(0, 0, 100), 2); mon = Spawn(monument, "t_mon"); // The monument's own scientists: a monument roamer is meant to roam as they do. locals = BaseNetworkable.serverEntities.OfType() .Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, monument) < 150f).ToList(); foreach (ScientistNPC s in locals) localsFrom[s] = s.transform.position; } 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) { // Since the 2026-10-05 Rust update, a monument's own scientists can stand still for minutes with no // real player near (stand-ins do not seem to count): then ours standing too is the same behaviour. int localsMoved = locals.Count(s => !Gone(s) && Vector3.Distance(s.transform.position, localsFrom[s]) > 5f); Check("move.monument.roams", monMax > 5f || (locals.Count > 0 && localsMoved == 0), $"{monumentName}: ours furthest {monMax:0.0} m (Rust's own roam); the monument's own scientists moved >5 m: {localsMoved}/{locals.Count}"); } 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) { // On a clear line (no rock between): after the 2026-10-05 Rust update's navmesh rebuild the field's // first spot had one, and the sentry never saw its stand-in. A probe 10 m apart in the open hit 13 times. Vector3 sentryAt, standInAt; if (!ClearLine(field, 12f, out sentryAt, out standInAt)) { sentryAt = field; standInAt = field + new Vector3(12, 0, 0); } BasePlayer sentry = Spawn(sentryAt, "t_sentry"); yield return new WaitForSeconds(1f); Vector3 spot = sentry.transform.position; _hitsOnStandIns = 0; StandIn(standInAt, 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"); } // ---- 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)}"; /// Runs a RunicNPC console command as the server console and returns its answer. 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 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>>("RunicNpc_Placements") ?? new List>()).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>>("RunicNpc_Placements") ?? new List>()).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("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> 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("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> 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> 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 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 old = new HashSet(List("placement:t_roam-1").Select(d => (ulong)d["netId"])); r = Rnpc("rnpc.respawn t_roam-1"); yield return new WaitForSeconds(3f); List> 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>()), 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("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("RunicNpc_Profiles")?["profiles"]?["t_new"]; Check("cmd.profile.set", made != null && (float)made["health"] == 321f && (float)made["ranges"]["sense"] == 44f && made["names"].ToObject().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("RunicNpc_Profiles")?["profiles"]?["t_new"] == null, ""); SetTestProfiles(); IEnumerator floorTest = Floor(field); while (floorTest.MoveNext()) yield return floorTest.Current; } /// /// 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. /// // ---- api3: stage 4's calls (D249) — a placement made from a map point, named as in game ---- private Dictionary Add(object placement) => Api>("RunicNpc_AddPlacement", JObject.FromObject(placement)) ?? new Dictionary { ["error"] = "no answer" }; private static string Said(Dictionary 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 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>>("RunicNpc_Placements") ?? new List>()).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>>("RunicNpc_Placements") ?? new List>()).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("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("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("RunicNpc_RespawnPlacement", "t_roam-1"); Check("api3.respawn", respawned == 2, "respawned " + respawned); Check("api3.respawn.missing", Api("RunicNpc_RespawnPlacement", "t_nothing") == -1, ""); yield return new WaitForSeconds(3f); List> 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("RunicNpc_RemovePlacement", "t_site"); Check("api3.hook.removed", _changed.Contains("removed:t_site"), string.Join(",", _changed.ToArray())); Api("RunicNpc_RemovePlacement", "t_roam-1"); } /// A point of open sea at least 5 m deep, for the under-water refusal. 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> 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 d in List("placement:" + id)) Kill((ulong)d["netId"]); yield return new WaitForSeconds(9f); on = List("placement:" + id); Dictionary 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 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"); } // ==== stage 5: behaviour (docs/runicnpc/PLAN.md, D253–D272) ==== private const string ScientistPrefab = "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab"; private const string Zone = "rnt_zone"; private readonly List _escortEnded = new List(); private void OnRunicNpcEscortEnded(BasePlayer npc, string profile, string owner) => _escortEnded.Add($"{profile}/{owner}"); /// The stage 5 test profiles, beside the others; three are refused on purpose. private static void Stage5Profiles(JObject all) { JObject P(string name, Action edit) { JObject p = Prof(name: name); edit(p); return p; } all["t_red"] = P("TRed", p => p["faction"] = "red"); all["t_blue"] = P("TBlue", p => p["faction"] = "blue"); all["t_hunter"] = P("THunter", p => { p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 40f, attack = 30f }); }); all["t_leashed"] = P("TLeashed", p => { p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 8f, attack = 30f }); }); all["t_bully"] = P("TBully", p => p["relations"] = JObject.FromObject(new Dictionary { ["profile:t_roam"] = "hostile" })); all["t_guard"] = P("TGuard", p => p["role"] = "guard"); all["t_kit"] = P("TKit", p => { p["kitUse"] = JObject.FromObject(new { heal = true, grenades = true, melee = true, rockets = true, flamethrower = true }); // Hostile to scientists, so the rocketeer's target is an NPC: Rust's player line of sight never sees a stand-in at 25 m. p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); }); all["t_ignore"] = P("TIgnore", p => p["turrets"] = "ignore"); all["t_always"] = P("TAlways", p => p["turrets"] = "always"); all["t_unkill"] = P("TUnkill", p => p["hurtByPlayers"] = false); all["t_harmless"] = P("THarmless", p => p["hurtsPlayers"] = false); all["t_badfaction"] = P("TBadFaction", p => p["faction"] = "scientists"); all["t_badturrets"] = P("TBadTurrets", p => p["turrets"] = "sometimes"); } private ScientistNPC SpawnScientist(Vector3 at) { ScientistNPC s = GameManager.server.CreateEntity(ScientistPrefab, OnMesh(at)) as ScientistNPC; if (s == null) return null; s.enableSaving = false; s.Spawn(); _built.Add(s); return s; } private static ulong Id(BaseEntity e) => e != null && e.net != null ? e.net.ID.Value : 0UL; private static bool Gone(BaseCombatEntity e) => e == null || e.IsDestroyed || e.IsDead(); private static bool Knows(BasePlayer npc, BaseEntity who) { ScientistNPC s = npc as ScientistNPC; return s != null && s.Brain != null && s.Brain.Senses != null && s.Brain.Senses.Memory.Targets.Contains(who); } /// /// Hands a player to an NPC's own memory, as Rust's senses do when they notice one. Rust's sensing /// of a stand-in is unreliable (it can miss one at 2 m), so the tests that are about what happens /// next start from here: SetKnown asks the NPC's IsTarget, which is what they test. /// private static void Sense(BasePlayer npc, BasePlayer who) { ScientistNPC s = npc as ScientistNPC; if (s?.Brain?.Senses?.Memory != null && who != null) s.Brain.Senses.Memory.SetKnown(who, s, s.Brain.Senses); } private static string TargetOf(Dictionary d) => d == null ? "?" : d["target"] as string ?? "none"; private static void Give(BasePlayer npc, string item, int amount) { Item made = ItemManager.CreateByName(item, amount); if (made != null && !made.MoveToContainer(npc.inventory.containerMain)) made.Remove(); } private static int Count(BasePlayer npc, string item) => Items(npc).Where(i => i.info.shortname == item).Sum(i => i.amount); private static List Items(BasePlayer npc) { var all = new List(); npc.inventory.GetAllItems(all); return all; } private static void Teleport(BasePlayer npc, Vector3 to) { ScientistNPC s = npc as ScientistNPC; NavMeshHit hit; if (s?.Brain?.Navigator?.Agent != null && SampleNavmesh(to, 3f, out hit)) { s.Brain.Navigator.Stop(); s.Brain.Navigator.Agent.Warp(hit.position); s.transform.position = hit.position; } } private static void MoveStandIn(BasePlayer p, Vector3 to) { p.transform.position = OnMesh(to); p.SendNetworkUpdateImmediate(); } /// A spot near with a clear line to a second spot m east: no rock between. private static bool ClearLine(Vector3 near, float range, out Vector3 a, out Vector3 b) { for (int i = 0; i < 40; i++) { a = OnMesh(near + new Vector3((i % 8) * 6f - 21f, 0, (i / 8) * 8f - 16f)); b = OnMesh(a + new Vector3(range, 0, 0)); NavMeshHit h1, h2; if (!SampleNavmesh(a, 1f, out h1) || !SampleNavmesh(b, 1f, out h2)) continue; if (!Physics.Linecast(a + Vector3.up * 1.6f, b + Vector3.up * 1f, 1218519041, QueryTriggerInteraction.Ignore)) return true; } a = b = Vector3.zero; return false; } private int HorrorLeft() => BaseNetworkable.serverEntities.OfType().Count(c => !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror); private IEnumerator Stage5() { Vector3 f; if (!FindField(300f, 2025, out f)) { Check("s5.setup.field", false, "no second open field on the navmesh"); yield break; } int horrorBefore = HorrorLeft(); Note($"s5 field {f}, Horror entities before: {horrorBefore}"); // ---- the faction table (D254, D268) and the profile refusals ---- string said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "red", relation = "hostile" } })); Check("s5.factions.refuseSelf", said != null && said.Contains("allied to itself"), said ?? "null"); said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "scientists", b = "animals", relation = "hostile" } })); Check("s5.factions.refuseRust", said != null && said.Contains("Rust's own"), said ?? "null"); said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "angry" } })); Check("s5.factions.refuseRelation", said != null && said.Contains("not hostile, neutral or allied"), said ?? "null"); said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" }, new { a = "blue", b = "red", relation = "allied" } })); Check("s5.factions.refuseTwice", said != null && said.Contains("both ways"), said ?? "null"); said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" } })); JArray table = Api("RunicNpc_Factions"); Check("s5.factions.set", said == null && table != null && table.Count == 1, (said ?? "set") + " " + table?.ToString(Formatting.None)); JObject profiles = Api("RunicNpc_Profiles"); Check("s5.profile.refuseFaction", ("" + profiles?["refused"]?["t_badfaction"]).Contains("Rust's own"), "" + profiles?["refused"]?["t_badfaction"]); Check("s5.profile.refuseTurrets", ("" + profiles?["refused"]?["t_badturrets"]).Contains("not default, ignore or always"), "" + profiles?["refused"]?["t_badturrets"]); // A stand-in 60 m off keeps every NPC of the field (x ±140) within 160 m and awake (D235), unsensed. BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); // ---- five fights at once, 70 m apart (D231, D255, D263, D268) ---- Vector3 a = OnMesh(f + new Vector3(-140, 0, 0)); Vector3 b = OnMesh(f + new Vector3(-70, 0, 0)); Vector3 c = OnMesh(f + new Vector3(0, 0, 0)); Vector3 d = OnMesh(f + new Vector3(70, 0, 0)); Vector3 e = OnMesh(f + new Vector3(140, 0, 0)); BasePlayer red = Spawn(a, "t_red"); BasePlayer blue = Spawn(OnMesh(a + new Vector3(0, 0, 15)), "t_blue"); BasePlayer hunter = Spawn(b, "t_hunter"); ScientistNPC prey = SpawnScientist(b + new Vector3(0, 0, 15)); BasePlayer neutral = Spawn(c, "t_roam"); ScientistNPC bystander = SpawnScientist(c + new Vector3(0, 0, 15)); BasePlayer leashed = Spawn(d, "t_leashed"); ScientistNPC far = SpawnScientist(d + new Vector3(0, 0, 25)); BasePlayer victim = Spawn(e, "t_roam"); BasePlayer bully = Spawn(OnMesh(e + new Vector3(0, 0, 15)), "t_bully"); Check("s5.fight.spawned", red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully, ""); if (!(red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully)) yield break; float farHealth = far.health, bystanderHealth = bystander.health, preyHealth = prey.health; float redHp = red.health, blueHp = blue.health; float factionsAt = -1f, scientistAt = -1f; bool neutralFought = false, leashedFought = false, victimAnswered = false; int marked = 0; for (int t = 1; t <= 70; t++) { yield return new WaitForSeconds(1f); if (factionsAt < 0f && (Gone(red) || Gone(blue))) factionsAt = t; if (scientistAt < 0f && Gone(prey)) scientistAt = t; if (!Gone(neutral) && TargetOf(Describe(neutral)) != "none") neutralFought = true; if (!Gone(leashed) && TargetOf(Describe(leashed)) != "none") leashedFought = true; if (!Gone(victim) && TargetOf(Describe(victim)) != "none") victimAnswered = true; foreach (BasePlayer x in new[] { red, blue, hunter }) { Dictionary dx = Gone(x) ? null : Describe(x); if (dx != null) marked = Math.Max(marked, Convert.ToInt32(dx["shotsMarked"])); } if (factionsAt > 0f && scientistAt > 0f && victimAnswered && t >= 25) break; } float redNow = Gone(red) ? 0f : red.health, blueNow = Gone(blue) ? 0f : blue.health; Check("s5.fight.factions", redNow < redHp && blueNow < blueHp, $"red and blue (hostile in the table) both hit: red {redHp:0}→{redNow:0}, blue {blueHp:0}→{blueNow:0}{(factionsAt > 0f ? $", one died at {factionsAt:0} s" : "")}"); Check("s5.fight.marked", marked > 0, $"{marked} shot(s) marked Horror for their length"); Check("s5.fight.scientists", scientistAt > 0f || (!Gone(prey) && prey.health < preyHealth * 0.5f), scientistAt > 0f ? $"the hunter killed a stock scientist at {scientistAt:0} s" : $"scientist {preyHealth:0}→{prey?.health:0} hp in 70 s"); Check("s5.fight.noFactionNoFight", !neutralFought && !Gone(bystander) && bystander.health >= bystanderHealth, $"t_roam beside a scientist: fought={neutralFought}, scientist {bystander?.health:0}/{bystanderHealth:0} (D255)"); Check("s5.fight.leash", !leashedFought && !Gone(far) && far.health >= farHealth, $"chase 8, scientist 25 m off: fought={leashedFought}, scientist {far?.health:0}/{farHealth:0} (D263)"); Check("s5.fight.shootBack", victimAnswered, $"t_roam (neutral) shot by a profile hostile to it one-sidedly: answered={victimAnswered} (D268)"); Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); keeper = StandIn(f + new Vector3(0, 0, 60), 50); yield return new WaitForSeconds(1f); // ---- the alarm (D256): allies within 40 m learn the attacker, seen or not ---- BasePlayer shooter = StandIn(f + new Vector3(0, 0, -120), 51); BasePlayer b0 = Spawn(a, "t_blue"); BasePlayer b1 = Spawn(OnMesh(a + new Vector3(6, 0, 0)), "t_blue"); BasePlayer b2 = Spawn(OnMesh(a + new Vector3(-6, 0, 0)), "t_blue"); BasePlayer b3 = Spawn(OnMesh(a + new Vector3(0, 0, 60)), "t_blue"); BasePlayer r0 = Spawn(OnMesh(a + new Vector3(0, 0, -6)), "t_roam"); yield return new WaitForSeconds(2f); b0.Hurt(new HitInfo(shooter, b0, Rust.DamageType.Bullet, 5f)); yield return new WaitForSeconds(1f); Check("s5.alarm.near", Knows(b1, shooter) && Knows(b2, shooter), $"b1={Knows(b1, shooter)} b2={Knows(b2, shooter)}"); Check("s5.alarm.far", !Knows(b3, shooter), $"60 m off: knows={Knows(b3, shooter)}"); Check("s5.alarm.notEnemy", !Knows(r0, shooter), $"a t_roam beside them: knows={Knows(r0, shooter)} (not their ally)"); Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); keeper = StandIn(f + new Vector3(0, 0, 60), 50); yield return new WaitForSeconds(1f); // ---- allies (D257, D270): never targeted, and defended with what they own ---- BasePlayer friend = StandIn(OnMesh(b + new Vector3(0, 0, 12)), 52); // The control: the same profile beside it, allied to nobody, sees the same player. (Rust senses // only one of two stand-ins this close, so a second stand-in cannot be the control.) BasePlayer guardian = Spawn(b, "t_roam"); BasePlayer control = Spawn(OnMesh(b + new Vector3(2, 0, 0)), "t_roam"); said = Api("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString); Check("s5.ally.set", said == null, said ?? "allied to a player"); said = Api("RunicNpc_Ally", Id(guardian), "clan", "424242"); Check("s5.ally.refuseClan", said != null && said.Contains("no clan"), said ?? "null"); Api("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString); yield return new WaitForSeconds(2f); Sense(guardian, friend); Sense(control, friend); yield return new WaitForSeconds(1f); Check("s5.ally.spared", !Knows(guardian, friend), $"the ally 12 m off in sight: target={Knows(guardian, friend)}"); Check("s5.ally.controlFights", Knows(control, friend), $"the same profile, allied to nobody, 2 m away: target={Knows(control, friend)}"); Api("RunicNpc_Despawn", Id(control)); BasePlayer raider = StandIn(OnMesh(b + new Vector3(0, 0, -90)), 54); friend.Hurt(new HitInfo(raider, friend, Rust.DamageType.Bullet, 5f)); yield return new WaitForSeconds(1f); Check("s5.ally.defendsPlayer", Knows(guardian, raider), $"a raider 90 m off shot the ally: guardian knows={Knows(guardian, raider)}"); BaseEntity foundation = GameManager.server.CreateEntity("assets/prefabs/building core/foundation/foundation.prefab", OnMesh(b + new Vector3(8, 0, -8))); if (foundation != null) { foundation.enableSaving = false; foundation.OwnerID = (ulong)friend.userID; if (foundation is StabilityEntity) ((StabilityEntity)foundation).grounded = true; foundation.Spawn(); _built.Add(foundation); BasePlayer wrecker = StandIn(OnMesh(b + new Vector3(-90, 0, 0)), 55); ((BaseCombatEntity)foundation).Hurt(new HitInfo(wrecker, foundation, Rust.DamageType.Explosion, 1f)); yield return new WaitForSeconds(1f); Check("s5.ally.defendsBuilding", Knows(guardian, wrecker), $"a wrecker 90 m off hit the ally's foundation: guardian knows={Knows(guardian, wrecker)}"); } else Check("s5.ally.defendsBuilding", false, "no foundation could be made"); Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); keeper = StandIn(f + new Vector3(0, 0, 60), 50); yield return new WaitForSeconds(1f); // ---- escort (D258, D269) ---- _escortEnded.Clear(); // A stock scientist as the charge: a stand-in has no connection, so it is never "on the server". ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0)); BasePlayer escort = Spawn(c, "t_roam"); BasePlayer sentry = Spawn(OnMesh(c + new Vector3(-10, 0, 0)), "t_sentry"); said = Api("RunicNpc_Escort", Id(sentry), charge); Check("s5.escort.refuseSentry", said != null && said.Contains("sentry"), said ?? "null"); said = Api("RunicNpc_Escort", Id(escort), charge); Check("s5.escort.set", said == null, said ?? "escorting"); yield return new WaitForSeconds(2f); Vector3 home = escort.transform.position; // East, not north: north brings the escort within sense range of the keeper stand-in, and Rust's // design then fights it instead of walking home. Teleport(charge, c + new Vector3(35, 0, 0)); float closest = float.MaxValue; for (int t = 0; t < 20; t++) { yield return new WaitForSeconds(1f); closest = Mathf.Min(closest, Flat(escort.transform.position, charge.transform.position)); } Check("s5.escort.follows", closest < 8f, $"its charge moved 35 m; closest {closest:0.0} m in 20 s"); Check("s5.escort.spared", !Knows(escort, charge), $"target={Knows(escort, charge)}"); charge.Kill(); for (int t = 0; t < 20 && _escortEnded.Count == 0; t++) yield return new WaitForSeconds(1f); Dictionary de = Describe(escort); Check("s5.escort.ends", _escortEnded.Count == 1 && de != null && de["escort"] == null, $"hook {_escortEnded.Count}, escort={de?["escort"] ?? "null"}"); float back = float.MaxValue; for (int t = 0; t < 45 && back > 4f; t++) { yield return new WaitForSeconds(1f); back = Flat(escort.transform.position, V(Describe(escort)["home"])); } Check("s5.escort.home", back <= 4f, $"{back:0.0} m from its spot after its charge was gone (D269)"); Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); keeper = StandIn(f + new Vector3(0, 0, 60), 50); yield return new WaitForSeconds(1f); // ---- the guard role ---- BasePlayer guard = Spawn(d, "t_guard"); yield return new WaitForSeconds(2f); Vector3 post = V(Describe(guard)["home"]); Teleport(guard, post + new Vector3(10, 0, 0)); float off = float.MaxValue; for (int t = 0; t < 25 && off > 2.5f; t++) { yield return new WaitForSeconds(1f); off = Flat(guard.transform.position, post); } Check("s5.guard.returns", off <= 2.5f, $"put 10 m off its post: {off:0.0} m from it"); // It stops within 2 m of its post; let it arrive before measuring how still it holds. yield return new WaitForSeconds(4f); Vector3 held = guard.transform.position; float drift = 0f; for (int t = 0; t < 12; t++) { yield return new WaitForSeconds(1f); drift = Mathf.Max(drift, Flat(guard.transform.position, held)); } Check("s5.guard.holds", drift < 1.5f, $"moved {drift:0.0} m in 12 s at its post (a roamer would wander)"); Api("RunicNpc_DespawnOwner", Owner); yield return new WaitForSeconds(1f); // ---- the tether (D272) ---- Plugin zm = plugins.Find("ZoneManager"); if (zm == null) Note("s5.tether skipped: ZoneManager is not loaded"); else { // Within 160 m of the keeper, or the NPC sleeps (D235) and the test reads 0 m. Vector3 centre = OnMesh(c); zm.Call("CreateOrUpdateZone", Zone, new[] { "radius", "12", "name", "rnt tether" }, centre); BasePlayer tethered = Spawn(centre, "t_roam"); said = Api("RunicNpc_Tether", Id(tethered), "rnt_nozone"); Check("s5.tether.refuseMissing", said != null && said.Contains("no zone"), said ?? "null"); said = Api("RunicNpc_Tether", Id(tethered), Zone); Check("s5.tether.set", said == null, said ?? "held"); float widest = 0f; for (int t = 0; t < 30; t++) { yield return new WaitForSeconds(1f); widest = Mathf.Max(widest, Flat(tethered.transform.position, centre)); } Check("s5.tether.holds", widest <= 13f, $"wander radius 15 in a 12 m zone: widest {widest:0.0} m from its centre in 30 s"); Teleport(tethered, centre + new Vector3(25, 0, 0)); float inside = float.MaxValue; for (int t = 0; t < 25 && inside > 12f; t++) { yield return new WaitForSeconds(1f); inside = Flat(tethered.transform.position, centre); } Check("s5.tether.returns", inside <= 12f, $"put 25 m out: {inside:0.0} m from the centre"); string placed = Api("RunicNpc_SetPlacement", "t_tether", JObject.FromObject(new { profile = "t_roam", position = new { centre.x, centre.y, centre.z }, tether = "rnt_nozone" })); Check("s5.tether.placementRefused", placed != null && placed.Contains("no zone"), placed ?? "null"); Api("RunicNpc_RemovePlacement", "t_tether"); zm.Call("EraseZone", Zone); Api("RunicNpc_DespawnOwner", Owner); } // ---- PVE (D261, D271) ---- BasePlayer player = StandIn(OnMesh(a + new Vector3(0, 0, 40)), 57); BasePlayer unkill = Spawn(a, "t_unkill"); BasePlayer harmless = Spawn(OnMesh(a + new Vector3(4, 0, 0)), "t_harmless"); BasePlayer plain = Spawn(OnMesh(a + new Vector3(-4, 0, 0)), "t_roam"); object o1 = Interface.CallHook("CanEntityTakeDamage", unkill, new HitInfo(player, unkill, Rust.DamageType.Bullet, 10f)); object o2 = Interface.CallHook("CanEntityTakeDamage", plain, new HitInfo(player, plain, Rust.DamageType.Bullet, 10f)); object o3 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f)); object o4 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f)); Check("s5.pve.answers", Equals(o1, false) && Equals(o2, true) && Equals(o3, false) && Equals(o4, true), $"unkillable {o1 ?? "null"}, plain {o2 ?? "null"}, harmless→player {o3 ?? "null"}, plain→player {o4 ?? "null"}"); float hp = unkill.health; unkill.Hurt(new HitInfo(player, unkill, Rust.DamageType.Bullet, 30f)); float plainHp = plain.health; plain.Hurt(new HitInfo(player, plain, Rust.DamageType.Bullet, 30f)); Check("s5.pve.unkillable", Mathf.Approximately(unkill.health, hp) && plain.health < plainHp, $"unkillable {hp:0}→{unkill.health:0}, plain {plainHp:0}→{plain.health:0}"); object h1 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f)); object h2 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f)); Check("s5.pve.harmless", Equals(h1, true) && h2 == null, $"harmless→player {h1 ?? "null"}, plain→player {h2 ?? "null"}"); Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); keeper = StandIn(f + new Vector3(0, 0, 60), 50); yield return new WaitForSeconds(1f); // ---- turrets (D259, D264, D267): the hook, then Outpost's own sentries ---- BasePlayer ignoring = Spawn(b, "t_ignore"); BasePlayer defaulting = Spawn(OnMesh(b + new Vector3(4, 0, 0)), "t_roam"); object i1 = Interface.CallHook("CanBeTargeted", ignoring, (MonoBehaviour)null); object i2 = Interface.CallHook("CanBeTargeted", defaulting, (MonoBehaviour)null); Check("s5.turrets.ignore", Equals(i1, false) && i2 == null, $"ignore {i1 ?? "null"}, default {i2 ?? "null"}"); Api("RunicNpc_DespawnOwner", Owner); IEnumerator sentries = Sentries(); while (sentries.MoveNext()) yield return sentries.Current; ClearWorld(); keeper = StandIn(f + new Vector3(0, 0, 60), 50); yield return new WaitForSeconds(1f); // ---- the kit's extras (D260, D265), each NPC 60 m from the next ---- IEnumerator kit = KitExtras(f); while (kit.MoveNext()) yield return kit.Current; int horrorAfter = HorrorLeft(); Check("s5.noHorrorLeft", horrorAfter <= horrorBefore, $"Horror entities: {horrorBefore} before, {horrorAfter} after"); Api("RunicNpc_SetFactions", new JArray()); } /// /// D264 and D267 on Outpost's (or Bandit Camp's) own sentries, and the org lead's condition: they /// must never target Rust's own scientists or bandit guards. Ours are put in front of a sentry: /// one always, one default, and a stock scientist beside them. /// private IEnumerator Sentries() { List turrets = BaseNetworkable.serverEntities.OfType().Where(t => t != null && !t.IsDestroyed).ToList(); if (turrets.Count == 0) { Note("s5.sentry skipped: this map has no Outpost or Bandit Camp sentries"); yield break; } NPCAutoTurret turret = null; Vector3 spot = Vector3.zero; foreach (NPCAutoTurret t in turrets) { Vector3 ahead = t.transform.position + t.transform.forward * 12f; NavMeshHit hit, start; // The approach must be walkable too: ours start 45 m out and are walked in (a sentry by the // water left the Carbon run's NPC unspawned). if (SampleNavmesh(ahead, 4f, out hit) && Mathf.Abs(hit.position.y - t.transform.position.y) < 6f && SampleNavmesh(t.transform.position + t.transform.forward * 45f, 3f, out start)) { turret = t; spot = hit.position; break; } } if (turret == null) { Check("s5.sentry.spot", false, $"no navmesh 12 m in front of any of {turrets.Count} sentries"); yield break; } Note($"s5.sentry: {turret.ShortPrefabName} at {turret.transform.position}, {turrets.Count} on the map"); BasePlayer near = StandIn(spot + new Vector3(0, 0, 80), 58); Vector3 side = Vector3.Cross(Vector3.up, turret.transform.forward).normalized; // Spawned 45 m out and walked in: a turret notices only what ENTERS its trigger (stage 5 spike). Vector3 outside = turret.transform.position + turret.transform.forward * 45f; BasePlayer always = Spawn(OnMesh(outside), "t_always"); BasePlayer plain = Spawn(OnMesh(outside + side * 4f), "t_roam"); ScientistNPC stock = SpawnScientist(outside - side * 4f); yield return new WaitForSeconds(1f); Teleport(always, spot); Teleport(plain, spot + side * 4f); Teleport(stock, spot - side * 4f); float alwaysHp = always != null ? always.health : 0f; var targeted = new HashSet(); bool alwaysTargeted = false; int stockTargets = 0; for (int t = 0; t < 120; t++) { yield return new WaitForSeconds(0.25f); foreach (NPCAutoTurret any in turrets) { BaseCombatEntity target = any != null && !any.IsDestroyed ? any.target : null; if (target == null) continue; targeted.Add(target.GetType().Name); if (target == always) alwaysTargeted = true; if (target is BanditGuard || (target is ScientistNPC && target.GetType().Name != "RunicNpcPlayer")) stockTargets++; } } if (ReferenceEquals(always, null) || ReferenceEquals(plain, null) || ReferenceEquals(stock, null)) Note($"s5.sentry: spawned always={always != null} default={plain != null} stock={stock != null}"); Check("s5.sentry.always", alwaysTargeted && (Gone(always) || always.health < alwaysHp), $"targeted={alwaysTargeted}, health {alwaysHp:0}→{(Gone(always) ? 0f : always.health):0}"); Check("s5.sentry.default", Gone(plain) || plain.health >= plain.MaxHealth() - 0.5f, $"a default NPC beside it: {(Gone(plain) ? "dead" : plain.health.ToString("0"))} hp"); Check("s5.sentry.noStockTargets", stockTargets == 0 && !Gone(stock), $"samples on a stock scientist or bandit guard: {stockTargets}; targets seen: {string.Join(", ", targeted.ToArray())} (the org lead's condition, D267)"); Api("RunicNpc_DespawnOwner", Owner); } private IEnumerator KitExtras(Vector3 f) { Vector3[] at = new Vector3[6]; for (int i = 0; i < 6; i++) at[i] = OnMesh(f + new Vector3(-150 + i * 60, 0, -60)); // Heal in a lull, one syringe used up; and a profile that did not opt in never heals. BasePlayer healer = Spawn(at[0], "t_kit"); BasePlayer plain = Spawn(OnMesh(at[0] + new Vector3(8, 0, 0)), "t_roam"); Give(healer, "syringe.medical", 2); Give(plain, "syringe.medical", 2); yield return new WaitForSeconds(1f); healer.health = healer.MaxHealth() * 0.3f; plain.health = plain.MaxHealth() * 0.3f; float plainHp = plain.health; // A grenadier, a rocketeer, a brawler and a flamer, each with a stand-in at its range. BasePlayer grenadier = Spawn(at[1], "t_kit"); Give(grenadier, "grenade.f1", 2); BasePlayer gTarget = StandIn(OnMesh(at[1] + new Vector3(12, 0, 0)), 60); Vector3 rAt, rTo; if (!ClearLine(at[2], 25f, out rAt, out rTo)) { rAt = at[2]; rTo = at[2] + new Vector3(25, 0, 0); Note("s5.kit.rocket: no clear 25 m line found near its spot"); } BasePlayer rocketeer = Spawn(rAt, "t_kit"); Give(rocketeer, "rocket.launcher", 1); Give(rocketeer, "ammo.rocket.basic", 2); ScientistNPC rTarget = SpawnScientist(rTo); BasePlayer brawler = Spawn(at[3], "t_kit"); Give(brawler, "machete", 1); BasePlayer mTarget = StandIn(OnMesh(at[3] + new Vector3(1.5f, 0, 0)), 62); BasePlayer flamer = Spawn(at[4], "t_kit"); Give(flamer, "flamethrower", 1); Give(flamer, "lowgradefuel", 50); // An empty tank, so the extra must burn the kit's fuel (D265: only extras are used up). foreach (Item it in Items(flamer)) if (it.GetHeldEntity() is FlameThrower) ((FlameThrower)it.GetHeldEntity()).ammo = 0; BasePlayer near = StandIn(OnMesh(at[4] + new Vector3(4, 0, 0)), 63); Sense(flamer, near); bool flameHeld = false; for (int t = 0; t < 45; t++) { yield return new WaitForSeconds(1f); if (t % 5 == 0) { if (!Gone(flamer)) Sense(flamer, near); if (!Gone(grenadier)) Sense(grenadier, gTarget); if (!Gone(brawler)) Sense(brawler, mTarget); } Item held = Gone(flamer) ? null : flamer.GetActiveItem(); if (held == null || held.info.shortname != "flamethrower") continue; // Its tank emptied while it is held, then one shot: the extra must burn the kit's fuel (D265). FlameThrower tank = held.GetHeldEntity() as FlameThrower; if (!flameHeld && tank != null) { tank.ammo = 0; ((NPCPlayer)flamer).ShotTest(4f); } flameHeld = true; } Dictionary Used(BasePlayer x) => Gone(x) ? null : Describe(x)?["kitUsed"] as Dictionary; int UsedN(BasePlayer x, string k) => Used(x) != null ? Convert.ToInt32(Used(x)[k]) : -1; Check("s5.kit.heal", UsedN(healer, "heals") > 0 && Count(healer, "syringe.medical") < 2, $"heals {UsedN(healer, "heals")}, syringes left {(Gone(healer) ? -1 : Count(healer, "syringe.medical"))}, health {(Gone(healer) ? 0 : healer.health):0}"); Check("s5.kit.offByDefault", !Gone(plain) && plain.health <= plainHp + 0.5f && Count(plain, "syringe.medical") == 2, $"t_roam: health {plainHp:0}→{(Gone(plain) ? 0 : plain.health):0}, syringes {(Gone(plain) ? -1 : Count(plain, "syringe.medical"))}"); Check("s5.kit.grenade", UsedN(grenadier, "throws") > 0 && Count(grenadier, "grenade.f1") < 2, $"throws {UsedN(grenadier, "throws")}, grenades left {(Gone(grenadier) ? -1 : Count(grenadier, "grenade.f1"))}"); Check("s5.kit.rocket", (UsedN(rocketeer, "rockets") > 0 || Gone(rTarget)) && Count(rocketeer, "ammo.rocket.basic") < 2, $"rockets {UsedN(rocketeer, "rockets")}, left {(Gone(rocketeer) ? -1 : Count(rocketeer, "ammo.rocket.basic"))}"); Check("s5.kit.melee", UsedN(brawler, "meleeHits") > 0, $"melee swings {UsedN(brawler, "meleeHits")}"); Check("s5.kit.flame", flameHeld && !Gone(flamer) && Count(flamer, "lowgradefuel") < 50, $"held={flameHeld}, fuel left {(Gone(flamer) ? -1 : Count(flamer, "lowgradefuel"))}"); Api("RunicNpc_DespawnOwner", Owner); } // ---- stage 6: bosses, passive NPCs and the examples (D273–D289) ---- private readonly List _bossEvents = new List(); private readonly List _used = new List(); private readonly Dictionary _bossContributors = new Dictionary(); private void OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner) => _bossEvents.Add("spawned:" + profile); private void OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at) => _bossEvents.Add($"phase{phase}@{at}"); private void OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) { _bossEvents.Add("died:" + profile); _bossContributors.Clear(); foreach (KeyValuePair kv in contributors) _bossContributors[kv.Key] = kv.Value; } private void OnRunicNpcUsed(BasePlayer npc, string profile, BasePlayer player) => _used.Add(profile + "/" + player.displayName); private static void Stage6Profiles(JObject all) { JObject boss = Prof(name: "TBoss"); boss["health"] = 1000f; boss["damageTaken"] = JObject.FromObject(new { head = 1f, body = 1f, legs = 1f }); boss["boss"] = JObject.Parse(@"{ ""barDistance"": 100, ""announce"": ""chat"", ""spawnLine"": ""{name} rises (rnt)"", ""deathLine"": ""{name} fell to {killer} (rnt)"", ""phases"": [ { ""at"": 0.75, ""damageDealt"": 2, ""line"": ""phase one (rnt)"" }, { ""at"": 0.5, ""adds"": { ""profile"": ""t_roam"", ""count"": 2 }, ""line"": ""adds (rnt)"" }, { ""at"": 0.25, ""kit"": ""rnhrevolver"", ""speed"": 1.5, ""aimCone"": 4, ""ranges"": { ""sense"": 50, ""loseTarget"": 60, ""chase"": 50, ""attack"": 40 } } ]}"); all["t_boss"] = boss; JObject passive = Prof(role: "passive", name: "TPassive"); passive["movement"] = JObject.FromObject(new { mode = "stand", radius = 1f }); passive["use"] = JObject.FromObject(new { mode = "chat", lines = new[] { "Hello {player} (rnt)" } }); all["t_passive"] = passive; JObject window = Prof(role: "passive", name: "TWindow"); window["use"] = JObject.FromObject(new { mode = "window", title = "Notice", text = "Hello {player} (rnt)" }); all["t_passwin"] = window; JObject hurt = Prof(role: "passive", name: "THurt"); hurt["movement"] = JObject.FromObject(new { mode = "stand", radius = 1f }); hurt["use"] = JObject.FromObject(new { mode = "chat", lines = new[] { "Ow (rnt)" } }); hurt["hurtByPlayers"] = true; all["t_passhurt"] = hurt; } /// A stand-in looks at an NPC's chest, as a player about to press E does (PlayerEyes.HeadRay). private static void Look(BasePlayer p, BasePlayer at) { Vector3 dir = (at.CenterPoint() - p.eyes.position).normalized; p.eyes.rotation = Quaternion.LookRotation(dir); } /// E, just pressed, through RunicNPC's own OnPlayerInput. private void PressE(BasePlayer p) { var input = new InputState(); input.current.buttons = (int)BUTTON.USE; input.previous.buttons = 0; RunicNPC?.Call("OnPlayerInput", p, input); } /// A stand-in shoots an NPC in 40-damage hits until its health is below a fraction (its kit's armour soaks some). private static void HurtTo(BasePlayer npc, BasePlayer by, float fraction) { for (int i = 0; i < 200 && !Gone(npc) && npc.health / npc.MaxHealth() >= fraction; i++) npc.Hurt(new HitInfo(by, npc, Rust.DamageType.Bullet, 40f)); } private IEnumerator Stage6() { Vector3 f; if (!FindField(300f, 2026, out f)) { Check("s6.setup.field", false, "no third open field on the navmesh"); yield break; } StandIn(f + new Vector3(0, 0, 60), 50); // ---- refusals, through a spawn's overrides (ValidateProfile on the effective profile) ---- Func refused = (profile, json) => Spawn(f, profile, JObject.Parse(json)) == null; Check("s6.refuse.passiveBoss", refused("t_passive", "{\"boss\":{}}"), "a passive NPC cannot be a boss"); Check("s6.refuse.useOnRoamer", refused("t_roam", "{\"use\":{\"mode\":\"chat\",\"lines\":[\"hi\"]}}"), "only a passive NPC answers E"); Check("s6.refuse.standRoamer", refused("t_roam", "{\"movement\":{\"mode\":\"stand\",\"radius\":1}}"), "stand is a passive NPC's"); Check("s6.refuse.useMode", refused("t_passive", "{\"use\":{\"mode\":\"shout\"}}"), "use.mode chat or window"); Check("s6.refuse.chatNoLines", refused("t_passive", "{\"use\":{\"lines\":[]}}"), "a chat answer has lines"); Check("s6.refuse.phaseAt", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":1.5}]}}"), "at between 0 and 1"); Check("s6.refuse.phaseTwice", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5},{\"at\":0.5}]}}"), "two phases at one fraction"); Check("s6.refuse.addsCount", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"adds\":{\"profile\":\"t_roam\",\"count\":0}}]}}"), "adds 1 to 20"); Check("s6.refuse.addsBoss", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"adds\":{\"profile\":\"t_boss\",\"count\":1}}]}}"), "a boss does not summon bosses"); Check("s6.refuse.phaseKit", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"kit\":\"rnt_nokit\"}]}}"), "a phase's kit must exist"); Check("s6.refuse.speed", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"speed\":9}]}}"), "speed at most 5"); Check("s6.refuse.announce", refused("t_boss", "{\"boss\":{\"announce\":\"shout\"}}"), "chat or popup"); Check("s6.refuse.barDistance", refused("t_boss", "{\"boss\":{\"barDistance\":0}}"), "above 0"); Api("RunicNpc_DespawnOwner", Owner); // ---- passive (D278, D279, D288) ---- Vector3 a = OnMesh(f + new Vector3(-40, 0, 0)); _used.Clear(); int hitsBefore = _hitsOnStandIns; BasePlayer pas = Spawn(a, "t_passive"); BasePlayer talker = StandIn(OnMesh(a + new Vector3(2f, 0, 0)), 61); yield return new WaitForSeconds(1.5f); Dictionary dp = Describe(pas); Check("s6.passive.spawned", pas != null && dp != null && (string)dp["role"] == "passive", dp != null ? "role " + dp["role"] : "not spawned"); if (pas == null) yield break; Sense(pas, talker); yield return new WaitForSeconds(6f); Check("s6.passive.neverFights", _hitsOnStandIns == hitsBefore && !Knows(pas, talker), $"told about a player 2 m off: hits {_hitsOnStandIns - hitsBefore}, still knows it as a target: {Knows(pas, talker)}"); Check("s6.passive.stands", Flat(pas.transform.position, a) < 1.5f, $"{Flat(pas.transform.position, a):0.0} m from its spot after 7.5 s"); float hp = pas.health; pas.Hurt(new HitInfo(talker, pas, Rust.DamageType.Bullet, 50f)); Check("s6.passive.unhurtByDefault", pas.health >= hp - 0.01f, $"health {hp:0} → {pas.health:0} after a player's 50 (D279: off by default)"); Look(talker, pas); PressE(talker); Check("s6.passive.pressE", _used.Count == 1 && _used[0] == "t_passive/RntStandIn61", string.Join(", ", _used.ToArray())); PressE(talker); Check("s6.passive.pressOncePerSecond", _used.Count == 1, $"{_used.Count} answer(s) to two presses at once"); talker.eyes.rotation = Quaternion.LookRotation(Vector3.back); yield return new WaitForSeconds(1.2f); PressE(talker); Check("s6.passive.lookingAway", _used.Count == 1, $"E while looking away: {_used.Count} answer(s)"); BasePlayer win = Spawn(OnMesh(a + new Vector3(0, 0, 10f)), "t_passwin"); BasePlayer reader = StandIn(OnMesh(a + new Vector3(2f, 0, 10f)), 62); yield return new WaitForSeconds(1f); if (win != null && reader != null) { Look(reader, win); PressE(reader); } Check("s6.passive.window", _used.Contains("t_passwin/RntStandIn62"), string.Join(", ", _used.ToArray())); Dictionary dw = Describe(win); Check("s6.passive.wanders", dw != null && (string)dw["movement"] == "wander", dw != null ? "movement " + dw["movement"] : "not spawned"); BasePlayer ph = Spawn(OnMesh(a + new Vector3(0, 0, -10f)), "t_passhurt"); BasePlayer hitter = StandIn(OnMesh(a + new Vector3(2f, 0, -10f)), 63); yield return new WaitForSeconds(1f); if (ph != null && hitter != null) { hitsBefore = _hitsOnStandIns; float before = ph.health; ph.Hurt(new HitInfo(hitter, ph, Rust.DamageType.Bullet, 30f)); float after = ph.health; yield return new WaitForSeconds(5f); Check("s6.passive.hurtable", after < before, $"hurtByPlayers on: health {before:0} → {after:0}"); Check("s6.passive.neverFightsBack", _hitsOnStandIns == hitsBefore && !Knows(ph, hitter) && TargetOf(Describe(ph)) == "none", $"hit by a player: shots back {_hitsOnStandIns - hitsBefore}, knows it {Knows(ph, hitter)}"); } Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); StandIn(f + new Vector3(0, 0, 60), 50); // ---- the boss (D273–D277, D286, D287) ---- _bossEvents.Clear(); _bossContributors.Clear(); Vector3 b = OnMesh(f + new Vector3(40, 0, 0)); BasePlayer shooter = StandIn(OnMesh(b + new Vector3(0, 0, 30f)), 64); BasePlayer boss = Spawn(b, "t_boss"); yield return new WaitForSeconds(1.5f); Check("s6.boss.spawned", boss != null && _bossEvents.Contains("spawned:t_boss"), string.Join(", ", _bossEvents.ToArray())); if (boss == null) yield break; Dictionary db = Describe(boss); Check("s6.boss.listed", db?["boss"] is Dictionary && Convert.ToInt32(((Dictionary)db["boss"])["phase"]) == 0, db != null ? JsonConvert.SerializeObject(db["boss"]) : "-"); ScientistNPC sci = boss as ScientistNPC; float baseSpeed = sci?.Brain?.Navigator != null ? sci.Brain.Navigator.Speed : -1f; float scaleBefore = ((NPCPlayer)boss).damageScale; // Down to 70%: phase one (damage ×2, a line). HurtTo(boss, shooter, 0.7f); yield return null; Check("s6.boss.phase1", _bossEvents.Contains("phase1@0.75") && Mathf.Approximately(((NPCPlayer)boss).damageScale, 2f), $"health {boss.health:0}/1000, events {string.Join(", ", _bossEvents.ToArray())}, damageScale {scaleBefore} → {((NPCPlayer)boss).damageScale}"); // Down to 45%: phase two (two adds of t_roam, one per frame, owned as the boss is). int before2 = List(Owner).Count; HurtTo(boss, shooter, 0.45f); for (int i = 0; i < 10; i++) yield return null; var adds = List(Owner).Where(d => (string)d["profile"] == "t_roam").ToList(); Check("s6.boss.phase2.adds", _bossEvents.Contains("phase2@0.5") && adds.Count == 2 && adds.All(d => Flat(V(d["position"]), boss.transform.position) < 10f), $"{adds.Count} add(s) owned by {Owner}, {string.Join(", ", adds.Select(d => Flat(V(d["position"]), boss.transform.position).ToString("0.0") + " m").ToArray())} from the boss; listed {before2} → {List(Owner).Count}"); // Down to 20%: phase three (kit swap, speed ×1.5, aim and ranges). It is first given another // weapon to hold, so the swap back to the kit's revolver can be seen. boss.inventory.Strip(); Give(boss, "pistol.semiauto", 1); ((HumanNPC)boss).EquipWeapon(); string heldBefore = boss.GetActiveItem()?.info.shortname ?? "none"; HurtTo(boss, shooter, 0.2f); yield return null; float speedNow = sci?.Brain?.Navigator != null ? sci.Brain.Navigator.Speed : -1f; string heldAfter = boss.GetActiveItem()?.info.shortname ?? "none"; Check("s6.boss.phase3", _bossEvents.Contains("phase3@0.25") && heldAfter == "pistol.revolver" && Mathf.Approximately(speedNow, baseSpeed * 1.5f) && Mathf.Approximately(((HumanNPC)boss).aimConeScale, 4f), $"held {heldBefore} → {heldAfter}, speed {baseSpeed:0.0} → {speedNow:0.0}, aimConeScale {((HumanNPC)boss).aimConeScale}, senseRange {sci?.Brain?.SenseRange}"); Check("s6.boss.phase3.ranges", sci?.Brain != null && Mathf.Approximately(sci.Brain.SenseRange, 50f) && Mathf.Approximately(sci.Brain.TargetLostRange, 60f), $"senseRange {sci?.Brain?.SenseRange}, targetLostRange {sci?.Brain?.TargetLostRange}"); db = Describe(boss); Check("s6.boss.listedPhase", db?["boss"] is Dictionary && Convert.ToInt32(((Dictionary)db["boss"])["phase"]) == 3, db != null ? JsonConvert.SerializeObject(db["boss"]) : "-"); // A phase changes the boss's own copy of its profile, never the shared one. BasePlayer second = Spawn(OnMesh(b + new Vector3(8, 0, 0)), "t_boss"); yield return new WaitForSeconds(0.5f); Check("s6.boss.ownProfile", second != null && Mathf.Approximately(((NPCPlayer)second).damageScale, 1f) && Mathf.Approximately(((HumanNPC)second).aimConeScale, 2f), second != null ? $"a second t_boss: damageScale {((NPCPlayer)second).damageScale}, aimConeScale {((HumanNPC)second).aimConeScale}" : "not spawned"); if (second != null) Api("RunicNpc_Despawn", Id(second)); // The kill: the death hook with every player who hurt it; the adds fight on (D287). ulong bossId = Id(boss); boss.Hurt(new HitInfo(shooter, boss, Rust.DamageType.Bullet, 5000f)); yield return new WaitForSeconds(1f); Check("s6.boss.died", _bossEvents.Contains("died:t_boss") && _bossContributors.ContainsKey(StandInId + 64) && _bossContributors[StandInId + 64] > 990f, $"events {string.Join(", ", _bossEvents.ToArray())}, contributors {JsonConvert.SerializeObject(_bossContributors)}"); int addsLeft = List(Owner).Count(d => (string)d["profile"] == "t_roam"); Check("s6.boss.addsFightOn", addsLeft == 2, $"{addsLeft} add(s) alive after the boss died (D287)"); // An event's teardown despawns what its step placed, by net id: the dead boss's id takes its adds with it. int removed = Api("RunicNpc_Despawn", bossId); int addsAfter = List(Owner).Count(d => (string)d["profile"] == "t_roam"); Check("s6.boss.despawnTakesAdds", removed == 2 && addsAfter == 0, $"RunicNpc_Despawn(the dead boss's id) removed {removed}; adds left {addsAfter}"); Check("s6.boss.unlisted", List(Owner).All(d => (string)d["profile"] != "t_boss"), ""); // ---- the examples (D280, D284, D289) ---- Plugin kits = plugins.Find("Kits"); string[] exampleKits = { "rnpc_raider", "rnpc_campguard", "rnpc_sniper", "rnpc_juggernaut" }; string[] missing = exampleKits.Where(k => !(kits?.Call("IsKit", k) is bool && (bool)kits.Call("IsKit", k))).ToArray(); Check("s6.examples.kits", missing.Length == 0, missing.Length == 0 ? "all four in Kits" : "missing: " + string.Join(", ", missing)); JObject kitFile = Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data"); Check("s6.examples.permission", exampleKits.All(k => (string)kitFile?["_kits"]?[k]?["RequiredPermission"] == "kits.runicnpc"), "each needs kits.runicnpc, which nobody is granted"); JObject state = Interface.Oxide.DataFileSystem.ReadObject("RunicNPC/state"); Check("s6.examples.once", state?["examplesWritten"]?.Type == JTokenType.Boolean && (bool)state["examplesWritten"], "state.json: " + state?.ToString(Formatting.None)); JObject items = kitFile?["_kits"]?["rnpc_juggernaut"] as JObject; int itemCount = items == null ? 0 : ((JArray)items["MainItems"]).Count + ((JArray)items["WearItems"]).Count + ((JArray)items["BeltItems"]).Count; Check("s6.examples.items", itemCount == 6, $"rnpc_juggernaut carries {itemCount} of its 6 items (a short name this Rust lacks would be left out)"); } // ---- stage 6's spike (D282): press E, the health bar, Kits' data file, a kit swap mid-fight, adds ---- private const string SwapKit = "rnt6smg"; private const string CooldownKit = "rnt6cd"; private BaseCombatEntity _spikeTarget; private readonly Dictionary _spikeHits = new Dictionary(); private static bool IsCarbon() => AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")); private Plugin K => plugins.Find("Kits"); private int SpikeHits(string weapon) { int n; return _spikeHits.TryGetValue(weapon, out n) ? n : 0; } private static string Held(BasePlayer npc) => Gone(npc) ? "-" : npc.GetActiveItem()?.info.shortname ?? "none"; /// /// rnt.spike6 [part,...]: stage 6's measurements, one part or all in order: /// use, bar, kits, swap, adds, unkits. swap needs the /// kit kits writes; unkits takes both spike kits out of Kits' file again. /// [ConsoleCommand("rnt.spike6")] private void CmdSpike6(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; string part = arg.GetString(0, "all"); if (_running != null) ServerMgr.Instance.StopCoroutine(_running); _running = ServerMgr.Instance.StartCoroutine(Spike6(part)); arg.ReplyWith($"rnt: spike6 {part}"); } private IEnumerator Spike6(string part) { _pass = _fail = 0; Note($"spike6 {part} on {(IsCarbon() ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}, Kits {K?.Version}"); SetTestProfiles(); Vector3 f; if (!FindField(300f, 2025, out f)) { Check("s6.setup.field", false, "no open field on the navmesh"); yield break; } string[] parts = part == "all" ? new[] { "use", "bar", "kits", "swap", "adds", "unkits" } : part.Split(','); foreach (string p in parts) { StandIn(f + new Vector3(0, 0, 60), 50); IEnumerator inner = p == "use" ? UseSpike(f) : p == "bar" ? BarSpike(f) : p == "kits" ? KitsSpike(f) : p == "swap" ? SwapSpike(f) : p == "adds" ? AddsSpike(f) : p == "unkits" ? UnkitsSpike() : null; if (inner == null) { Check("s6.setup.part", false, "no part " + p); continue; } while (inner.MoveNext()) yield return inner.Current; _spikeTarget = null; Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); yield return new WaitForSeconds(1f); } Note($"done spike6 {part}: {_pass} pass, {_fail} fail"); _running = null; } // ---- press E (D278) ---- private IEnumerator UseSpike(Vector3 f) { Vector3 at = OnMesh(f + new Vector3(30, 0, -60)); BasePlayer npc = Spawn(at, "t_sentry"); yield return new WaitForSeconds(1.5f); Check("s6.use.spawned", !Gone(npc), npc != null ? npc.GetType().Name : "null"); if (Gone(npc)) yield break; string cols = string.Join("; ", npc.GetComponentsInChildren(true) .Select(c => $"{c.GetType().Name} '{c.name}' layer={LayerMask.LayerToName(c.gameObject.layer)} enabled={c.enabled} trigger={c.isTrigger}").ToArray()); Note($"s6.use npc layer={LayerMask.LayerToName(npc.gameObject.layer)} colliders: {cols}"); int playerMask = LayerMask.GetMask("Player (Server)"); int broadMask = LayerMask.GetMask("Player (Server)", "Construction", "Deployed", "World", "Terrain", "Default", "Tree"); BasePlayer p = StandIn(at + new Vector3(2f, 0, 0), 61); int hitsPlayerMask = 0, hitsBroad = 0, rays = 0; foreach (float dist in new[] { 1.5f, 2.5f, 3.5f }) { MoveStandIn(p, at + new Vector3(dist, 0, 0)); yield return new WaitForSeconds(0.5f); if (Gone(npc)) break; foreach (KeyValuePair aim in new Dictionary { ["head"] = npc.eyes.position, ["chest"] = npc.CenterPoint(), ["legs"] = npc.transform.position + Vector3.up * 0.4f }) { Vector3 eye = p.eyes.position; Vector3 dir = (aim.Value - eye).normalized; RaycastHit h1, h2; bool a = Physics.Raycast(eye, dir, out h1, 5f, playerMask, QueryTriggerInteraction.Ignore) && h1.GetEntity() == npc; bool b = Physics.Raycast(eye, dir, out h2, 5f, broadMask, QueryTriggerInteraction.Ignore) && h2.GetEntity() == npc; rays++; if (a) hitsPlayerMask++; if (b) hitsBroad++; Note($"s6.use ray {dist} m at {aim.Key}: player-mask {(a ? $"HIT '{h1.collider.name}' at {h1.distance:0.00}" : "miss " + (h1.collider != null ? h1.collider.name : "-"))}, broad {(b ? $"HIT at {h2.distance:0.00}" : "miss " + (h2.collider != null ? $"{h2.collider.name}/{h2.GetEntity()?.ShortPrefabName}" : "-"))}"); } } Check("s6.use.ray", hitsBroad == rays, $"a ray from a player's eyes reaches our NPC: {hitsBroad}/{rays} with the world in the mask, {hitsPlayerMask}/{rays} on Player (Server) alone"); // What OnPlayerInput costs per player tick when E is not pressed, and one use ray when it is. var input = new InputState(); var sw = System.Diagnostics.Stopwatch.StartNew(); int pressed = 0; for (int i = 0; i < 1000000; i++) if (input.WasJustPressed(BUTTON.USE)) pressed++; double perTickNs = sw.Elapsed.TotalMilliseconds * 1e6 / 1000000; sw.Restart(); int found = 0; for (int i = 0; i < 10000; i++) { RaycastHit h; if (Physics.Raycast(p.eyes.position, (npc.CenterPoint() - p.eyes.position).normalized, out h, 3f, broadMask, QueryTriggerInteraction.Ignore)) found++; } double perRayUs = sw.Elapsed.TotalMilliseconds * 1000 / 10000; Check("s6.use.cost", perTickNs < 1000 && perRayUs < 100, $"OnPlayerInput's not-pressed test {perTickNs:0.0} ns a tick; one 3 m use ray {perRayUs:0.00} µs ({found} hit)"); } // ---- the health bar (D274) ---- private IEnumerator BarSpike(Vector3 f) { Type el = typeof(CuiElement); MemberInfo upd = (MemberInfo)el.GetProperty("Update") ?? el.GetField("Update"); Note("s6.bar CuiElement: " + string.Join(", ", el.GetProperties().Select(x => x.Name).Concat(el.GetFields().Select(x => x.Name)).ToArray())); Check("s6.bar.updateMember", upd != null, upd != null ? $"CuiElement.{upd.Name} ({upd.MemberType})" : "no Update: every redraw is destroy + add"); Func> build = (frac, text, fillOnly) => { var c = new CuiElementContainer(); if (!fillOnly) c.Add(new CuiPanel { Image = { Color = "0 0 0 0.6" }, RectTransform = { AnchorMin = "0.35 0.9", AnchorMax = "0.65 0.93" } }, "Hud", "rnpc.bar"); c.Add(new CuiPanel { Image = { Color = "0.75 0.1 0.1 0.9" }, RectTransform = { AnchorMin = "0 0", AnchorMax = $"{frac:0.###} 1" } }, "rnpc.bar", "rnpc.bar.fill"); c.Add(new CuiLabel { Text = { Text = text, FontSize = 12, Align = TextAnchor.MiddleCenter }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, "rnpc.bar", "rnpc.bar.text"); if (fillOnly && upd != null) foreach (CuiElement e in c) { if (upd is PropertyInfo) ((PropertyInfo)upd).SetValue(e, true, null); else ((FieldInfo)upd).SetValue(e, true); } return c; }; string full = CuiHelper.ToJson(build(0.73f, "Juggernaut 1825 / 2500", false)); string fill = CuiHelper.ToJson(build(0.72f, "Juggernaut 1800 / 2500", true)); Note("s6.bar full json: " + full); Note("s6.bar update json: " + fill); var sw = System.Diagnostics.Stopwatch.StartNew(); for (int i = 0; i < 2000; i++) CuiHelper.ToJson(build(i / 2000f, "Juggernaut " + i + " / 2500", false)); double fullUs = sw.Elapsed.TotalMilliseconds * 1000 / 2000; sw.Restart(); for (int i = 0; i < 2000; i++) CuiHelper.ToJson(build(i / 2000f, "Juggernaut " + i + " / 2500", true)); double fillUs = sw.Elapsed.TotalMilliseconds * 1000 / 2000; Check("s6.bar.build", fullUs < 500 && fillUs < 500, $"whole bar {full.Length} B in {fullUs:0.0} µs; update {fill.Length} B in {fillUs:0.0} µs, contains update={fill.Contains("\"update\"")}"); // Who sees it: 40 players scattered over 150 m, the ones within 100 m found by one sphere query. var rng = new System.Random(6); var crowd = new List(); for (int i = 0; i < 40; i++) { float a = (float)(rng.NextDouble() * Math.PI * 2), r = (float)(rng.NextDouble() * 150); BasePlayer s = StandIn(OnMesh(f + new Vector3(Mathf.Cos(a) * r, 0, Mathf.Sin(a) * r)), 70 + i); if (s != null) crowd.Add(s); } yield return new WaitForSeconds(1f); int within = crowd.Count(s => Vector3.Distance(s.transform.position, f) <= 100f); var found = new List(); sw.Restart(); for (int i = 0; i < 1000; i++) { found.Clear(); Vis.Entities(f, 100f, found, LayerMask.GetMask("Player (Server)"), QueryTriggerInteraction.Ignore); } double visUs = sw.Elapsed.TotalMilliseconds * 1000 / 1000; int seen = found.Count(s => crowd.Contains(s)); sw.Restart(); int byLoop = 0; for (int i = 0; i < 1000; i++) { byLoop = 0; foreach (BasePlayer s in crowd) if ((s.transform.position - f).sqrMagnitude <= 100f * 100f) byLoop++; } double loopUs = sw.Elapsed.TotalMilliseconds * 1000 / 1000; Check("s6.bar.viewers", seen == within && byLoop == within, $"{within} of 40 within 100 m: sphere query found {seen} in {visUs:0.0} µs, a distance loop over the list {byLoop} in {loopUs:0.0} µs"); bool sent = CuiHelper.AddUi(crowd[0], full); Note($"s6.bar AddUi to a player with no connection returns {sent} (sending is not measured here: no client)"); } // ---- Kits' data file (D280) ---- private static JObject KitItem(string shortname, int amount, int position, float condition = 0f) => JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = condition, MaxCondition = condition, Ammo = 0, Position = position, Frequency = -1 }); private static JObject SpikeKit(string name, int cooldown, int maxUses, JObject[] main, JObject[] wear, JObject[] belt) => new JObject { ["Name"] = name, ["Description"] = "RunicNPC stage 6 spike (remove after)", ["RequiredPermission"] = "", ["MaximumUses"] = maxUses, ["RequiredAuth"] = 0, ["Cooldown"] = cooldown, ["Cost"] = 0, ["IsHidden"] = true, ["CopyPasteFile"] = "", ["KitImage"] = "", ["MainItems"] = new JArray(main), ["WearItems"] = new JArray(wear), ["BeltItems"] = new JArray(belt) }; private JObject KitsFile() => Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data"); private double _reloadSeconds; private bool _reloaded; /// Reloads Kits the way an operator would at the console (the bridge's way on Carbon, W3) and waits for the new instance. private IEnumerator ReloadKits() { Plugin old = K, oldRunic = plugins.Find("RunicNPC"); int npcsBefore = List(null).Count; var sw = System.Diagnostics.Stopwatch.StartNew(); ConsoleSystem.Run(ConsoleSystem.Option.Server, IsCarbon() ? "c.reload" : "oxide.reload", "Kits"); _reloaded = false; double kitsAt = -1, runicAt = -1; while (sw.Elapsed.TotalSeconds < 60) { yield return new WaitForSeconds(0.25f); Plugin now = K, runic = plugins.Find("RunicNPC"); if (kitsAt < 0 && now != null && now != old && now.IsLoaded) kitsAt = sw.Elapsed.TotalSeconds; // RunicNPC requires Kits (// Requires: Kits): both frameworks unload it with Kits and load it again. if (runicAt < 0 && runic != null && runic != oldRunic && runic.IsLoaded) runicAt = sw.Elapsed.TotalSeconds; // Carbon unloads it only after a fresh compile of RunicNPC; give it 20 s, then call it not reloaded. if (kitsAt >= 0 && (runicAt >= 0 || sw.Elapsed.TotalSeconds > kitsAt + 20)) break; } _reloaded = kitsAt >= 0; _reloadSeconds = sw.Elapsed.TotalSeconds; Note($"s6.kits reload: Kits back at {kitsAt:0.0} s, RunicNPC {(oldRunic == plugins.Find("RunicNPC") ? "NOT reloaded" : $"reloaded with it, back at {runicAt:0.0} s")}; RunicNPC's NPCs {npcsBefore} before, {List(null).Count} after"); } /// Every player's uses of every kit, as Kits says now, for the ids in its player file. private Dictionary Usage() { var usage = new Dictionary(); JObject players = Interface.Oxide.DataFileSystem.ReadObject("Kits/player_data")?["_players"] as JObject; if (players == null) return usage; foreach (JProperty pl in players.Properties().Where(x => ulong.Parse(x.Name) < StandInId - 1000UL || ulong.Parse(x.Name) > StandInId + 1000UL)) foreach (JProperty kit in ((pl.Value as JObject)?["_usageData"] as JObject ?? new JObject()).Properties()) usage[pl.Name + "/" + kit.Name] = Convert.ToInt32(K?.Call("GetPlayerKitUses", ulong.Parse(pl.Name), kit.Name) ?? -1); return usage; } private IEnumerator KitsSpike(Vector3 f) { Check("s6.kits.loaded", K != null && K.IsLoaded, $"Kits {K?.Version}"); JObject file = KitsFile(); JObject kits = file?["_kits"] as JObject; Check("s6.kits.read", kits != null, kits != null ? $"{kits.Count} kit(s): {string.Join(", ", kits.Properties().Select(x => x.Name).ToArray())}" : "no _kits"); if (kits == null) yield break; int before = kits.Count; Dictionary realBefore = Usage(); // Two NPCs a plugin spawned (as an event's are), to see what a Kits reload does to them. Spawn(OnMesh(f + new Vector3(-60, 0, 30)), "t_roam"); Spawn(OnMesh(f + new Vector3(-60, 0, 36)), "t_roam"); yield return new WaitForSeconds(1f); // Round 1: a kit with a cooldown and a use limit, so a claim leaves usage behind. kits[CooldownKit] = SpikeKit(CooldownKit, 3600, 5, new[] { KitItem("bandage", 1, 0) }, new JObject[0], new JObject[0]); Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); Check("s6.kits.notLive", !(K.Call("IsKit", CooldownKit) is bool && (bool)K.Call("IsKit", CooldownKit)), "Kits does not see a kit written to its file until it reloads"); IEnumerator r = ReloadKits(); while (r.MoveNext()) yield return r.Current; Check("s6.kits.reload1", _reloaded, $"{(IsCarbon() ? "c.reload" : "oxide.reload")} Kits: new instance in {_reloadSeconds:0.0} s"); Check("s6.kits.added", K != null && K.Call("IsKit", CooldownKit) is bool && (bool)K.Call("IsKit", CooldownKit), ""); BasePlayer claimer = StandIn(OnMesh(new Vector3(0, 0, 0)), 98); ulong cid = (ulong)claimer.userID; // An earlier run's claim is in Kits' player file (saved when it unloaded): clear it first. K?.Call("SetPlayerKitUses", cid, CooldownKit, 0); K?.Call("SetPlayerKitCooldown", cid, CooldownKit, 0.0); object claimed = K?.Call("TryClaimKit", claimer, CooldownKit, false); int uses = Convert.ToInt32(K?.Call("GetPlayerKitUses", cid, CooldownKit) ?? -1); double cd = Convert.ToDouble(K?.Call("GetPlayerKitCooldown", cid, CooldownKit) ?? -1.0); Check("s6.kits.claim", claimed is bool && (bool)claimed && uses == 1 && cd > 3500, $"a player claims it: {claimed ?? "null (no such hook)"}, uses {uses}, cooldown {cd:0} s — in Kits' memory, not yet saved"); JObject onDisk = Interface.Oxide.DataFileSystem.ReadObject("Kits/player_data")?["_players"]?[cid.ToString()] as JObject; Note($"s6.kits the claim on disk before the reload: {(onDisk != null ? "yes" : "no")}"); // Round 2: the swap's kit, written while a player's usage is only in memory. file = KitsFile(); kits = (JObject)file["_kits"]; kits[SwapKit] = SpikeKit(SwapKit, 0, 0, new[] { KitItem("ammo.pistol", 120, 0) }, new[] { KitItem("hazmatsuit", 1, 0) }, new[] { KitItem("smg.mp5", 1, 0, 100f) }); Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); r = ReloadKits(); while (r.MoveNext()) yield return r.Current; Check("s6.kits.reload2", _reloaded, $"second reload: new instance in {_reloadSeconds:0.0} s"); var names = new List(); K?.Call("GetKitNames", names); Check("s6.kits.all", names.Count == before + 2 && names.Contains(SwapKit) && names.Contains(CooldownKit), $"{names.Count} kit(s) now ({before} before): {string.Join(", ", names.ToArray())}"); int usesAfter = Convert.ToInt32(K?.Call("GetPlayerKitUses", cid, CooldownKit) ?? -1); double cdAfter = Convert.ToDouble(K?.Call("GetPlayerKitCooldown", cid, CooldownKit) ?? -1.0); Check("s6.kits.usageKept", usesAfter == uses && cdAfter > cd - _reloadSeconds - 5 && cdAfter <= cd, $"the claim survived the reload: uses {uses}→{usesAfter}, cooldown {cd:0}→{cdAfter:0} s"); Dictionary realAfter = Usage(); Check("s6.kits.realUsageKept", realBefore.All(kv => realAfter.ContainsKey(kv.Key) && realAfter[kv.Key] == kv.Value), realBefore.Count == 0 ? "no real players on file" : string.Join(", ", realBefore.Select(kv => $"{kv.Key} {kv.Value}→{(realAfter.ContainsKey(kv.Key) ? realAfter[kv.Key] : -1)}").ToArray())); JObject fileAfter = KitsFile(); Check("s6.kits.fileKept", fileAfter?["_kits"]?[SwapKit] != null && fileAfter?["_kits"]?[CooldownKit] != null, "Kits did not rewrite its file on load"); Check("s6.kits.ref", Kits != null && Kits == K, "this plugin's [PluginReference] Kits follows the reload"); } private IEnumerator UnkitsSpike() { JObject file = KitsFile(); JObject kits = file?["_kits"] as JObject; if (kits == null) yield break; kits.Remove(SwapKit); kits.Remove(CooldownKit); Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); IEnumerator r = ReloadKits(); while (r.MoveNext()) yield return r.Current; bool gone = !(K?.Call("IsKit", SwapKit) is bool && (bool)K.Call("IsKit", SwapKit)); Check("s6.unkits", _reloaded && gone, $"spike kits removed, reload {_reloadSeconds:0.0} s"); } // ---- a phase's kit swap mid-fight (D275) ---- private IEnumerator SwapSpike(Vector3 f) { if (!(K?.Call("IsKit", SwapKit) is bool && (bool)K.Call("IsKit", SwapKit))) { Check("s6.swap.kit", false, "no " + SwapKit + ": run the kits part first"); yield break; } Vector3 at, there; if (!ClearLine(OnMesh(f + new Vector3(-30, 0, -60)), 15f, out at, out there)) { Check("s6.swap.spot", false, "no clear 15 m line"); yield break; } BasePlayer boss = Spawn(at, "t_hunter"); ScientistNPC sci = SpawnScientist(there); if (boss == null || sci == null) { Check("s6.swap.spawned", false, $"boss {(boss != null ? "up" : "null")}, scientist {(sci != null ? "up" : "null")} at {at}; profiles refused: {Api("RunicNpc_Profiles")?["refused"]?.ToString(Formatting.None)}"); yield break; } _spikeTarget = sci; _spikeHits.Clear(); int t; for (t = 1; t <= 30; t++) { yield return new WaitForSeconds(1f); if (SpikeHits("pistol.revolver") >= 3 && TargetOf(Describe(boss)) != "none") break; } string target = TargetOf(Describe(boss)); Check("s6.swap.fighting", SpikeHits("pistol.revolver") >= 3, $"revolver hits {SpikeHits("pistol.revolver")} by {t} s, target {target}, hits {JsonConvert.SerializeObject(_spikeHits)}"); if (Gone(boss) || Gone(sci)) yield break; // The swap, as a phase would make it: strip, give, equip — one frame. object given = null; string split = SwapTimed(boss, SwapKit, out given); Check("s6.swap.given", !(given is string) && Held(boss) == "smg.mp5", $"given={given ?? "null"}, held {Held(boss)}, {split}"); int rev = SpikeHits("pistol.revolver"); bool lost = false; for (t = 1; t <= 20; t++) { yield return new WaitForSeconds(1f); if (TargetOf(Describe(boss)) == "none") lost = true; if (SpikeHits("smg.mp5") >= 5) break; } Check("s6.swap.keepsTarget", !lost, $"target before {target}, after {TargetOf(Describe(boss))}, lost for a second: {lost}"); Check("s6.swap.newWeapon", SpikeHits("smg.mp5") > 0 && SpikeHits("pistol.revolver") == rev, $"MP5 hits {SpikeHits("smg.mp5")} in {t} s, revolver hits after the swap {SpikeHits("pistol.revolver") - rev}"); // And back, WITHOUT EquipWeapon: does Rust's brain pick the new weapon up by itself? boss.inventory.Strip(); K.Call("GiveKit", boss, Kit); string heldNow = Held(boss); int mp5 = SpikeHits("smg.mp5"), revBefore = SpikeHits("pistol.revolver"); string heldLater = heldNow; for (t = 1; t <= 15; t++) { yield return new WaitForSeconds(1f); heldLater = Held(boss); if (SpikeHits("pistol.revolver") > revBefore) break; } Note($"s6.swap without EquipWeapon: held {heldNow} at once, {heldLater} after {t} s, revolver hits {SpikeHits("pistol.revolver") - revBefore}, MP5 hits {SpikeHits("smg.mp5") - mp5}"); // The same swap twice more, warm: is the first one's cost Kits' first call, or every swap's? for (int i = 2; i <= 3 && !Gone(boss); i++) { Note($"s6.swap #{i} warm: {SwapTimed(boss, i == 2 ? SwapKit : Kit, out given)}, held {Held(boss)}"); yield return new WaitForSeconds(2f); } } /// Strip, Kits' GiveKit, EquipWeapon, each timed: "strip a ms, give b ms, equip c ms, total d ms". private string SwapTimed(BasePlayer npc, string kit, out object given) { var sw = System.Diagnostics.Stopwatch.StartNew(); npc.inventory.Strip(); double strip = sw.Elapsed.TotalMilliseconds; given = K.Call("GiveKit", npc, kit); double give = sw.Elapsed.TotalMilliseconds; ((HumanNPC)npc).EquipWeapon(); double all = sw.Elapsed.TotalMilliseconds; return $"strip {strip:0.00} ms, give {give - strip:0.00} ms, equip {all - give:0.00} ms, total {all:0.00} ms"; } // ---- summoned adds (D275) ---- private IEnumerator AddsSpike(Vector3 f) { Vector3 at, there; if (!ClearLine(OnMesh(f + new Vector3(60, 0, -40)), 20f, out at, out there)) { Check("s6.adds.spot", false, "no clear 20 m line"); yield break; } BasePlayer boss = Spawn(at, "t_hunter"); ScientistNPC sci = SpawnScientist(there); _spikeTarget = sci; _spikeHits.Clear(); for (int w = 0; w < 20 && TargetOf(Describe(boss)) == "none"; w++) yield return new WaitForSeconds(1f); Check("s6.adds.bossFighting", TargetOf(Describe(boss)) != "none", "target " + TargetOf(Describe(boss))); foreach (int count in new[] { 4, 10 }) { var adds = new List(); int offMesh = 0; var sw = System.Diagnostics.Stopwatch.StartNew(); for (int i = 0; i < count; i++) { float a = i * Mathf.PI * 2f / count; Vector3 ring = boss.transform.position + new Vector3(Mathf.Cos(a), 0, Mathf.Sin(a)) * 6f; NavMeshHit hit; if (!SampleNavmesh(ring, 3f, out hit)) { offMesh++; continue; } BasePlayer add = Spawn(hit.position, "t_hunter"); if (add != null) adds.Add(add); } double ms = sw.Elapsed.TotalMilliseconds; yield return new WaitForSeconds(2f); int onMesh = adds.Count(x => { NavMeshHit h; return !Gone(x) && SampleNavmesh(x.transform.position, 0.5f, out h); }); int fighting = 0; for (int w = 0; w < 20; w++) { fighting = adds.Count(x => !Gone(x) && TargetOf(Describe(x)) != "none"); if (fighting == adds.Count) break; yield return new WaitForSeconds(1f); } Check($"s6.adds.{count}", adds.Count == count && onMesh == adds.Count && fighting == adds.Count, $"{adds.Count}/{count} placed in {ms:0.0} ms ({ms / Math.Max(1, count):0.00} ms each, one frame), {offMesh} ring points off the navmesh, {onMesh} on the mesh after 2 s, {fighting} fighting the boss's target"); foreach (BasePlayer x in adds) if (!Gone(x)) Api("RunicNpc_Despawn", Id(x)); yield return new WaitForSeconds(1f); } } /// 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); } } }