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.7.2")]
[Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. 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.
///
/// An open field on the navmesh. With , the points every line / 2 m out to
/// ± along x, and 25 m past each, must be on it too: stage 5's five fights stand
/// that far apart. On a 6000 map the first field had room; on the staging drill's maps it is rare (D319).
///
private static bool FindField(float clear, int seed, out Vector3 at, float line = 0f)
{
var rng = new System.Random(seed);
float half = World.Size / 2f - 300f;
// The line is rare on a small map: 5 samples in 20000 have it on the drill's 3500 map (D319).
for (int i = 0; i < (line > 0f ? 20000 : 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);
}
for (int k = -2; k <= 2 && open && line > 0f; k++)
{
Vector3 q = hit.position + new Vector3(k * line / 2f, 0f, 0f);
Vector3 r = q + new Vector3(0f, 0f, 25f);
q.y = TerrainMeta.HeightMap.GetHeight(q);
r.y = TerrainMeta.HeightMap.GetHeight(r);
NavMeshHit h2;
open = SampleNavmesh(q, 3f, out h2) && SampleNavmesh(r, 3f, 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);
Stage7Profiles(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");
}
// ---- cost: stage 9's performance check (D306) ----
///
/// Stage 9's performance check (D306): what 100 of RunicNPC's own NPCs add to the median
/// server frame, idle and fighting, over this session's empty baseline. Stage 1 measured its
/// harness's stand-in NPC; this measures the NPC RunicNPC ships, brain, fight layer and all.
/// Same shape as stage 1's rnh.cost: a baseline, 100 awake but idle (a stand-in 85 m
/// away keeps them from sleeping and is beyond their senses), then 100 shooting 20
/// invulnerable stand-ins 12 m in front of the grid. It replaces RunicNPC's profiles with
/// one, t_cost: back up data/RunicNPC/ first, and stop any site that pushes
/// profiles. rnt.cost [seconds=60] [idle [sentry|asleep]]: idle skips the fight,
/// sentry makes the 100 stand still, and asleep leaves out the keeper, so nobody is
/// within their sleep distance. Each run ends with a second empty baseline, and the bar is
/// measured against the lower of the two.
///
[ConsoleCommand("rnt.cost")]
private void CmdCost(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
_running = ServerMgr.Instance.StartCoroutine(Cost(arg.GetFloat(0, 60f), arg.GetString(1) == "idle", arg.GetString(2) == "sentry", arg.GetString(2) == "asleep"));
arg.ReplyWith("rnt: measuring cost");
}
/// The worst stage 1 measured on either framework (PLAN.md stage 1, the cost table): D306's bar.
private const double IdleBudgetMs = 1.6, FightBudgetMs = 5.2;
private IEnumerator Cost(float window, bool idleOnly = false, bool still = false, bool asleep = false)
{
_pass = _fail = 0;
Note($"cost on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}, " +
$"players {BasePlayer.activePlayerList.Count}, {window:0} s a phase, ai budget {AIThinkManager.framebudgetms} ms");
Vector3 field;
if (!FindField(400f, 606, out field))
{
Check("cost.field", false, "no open field on the navmesh");
_running = null;
yield break;
}
// `rnt.cost idle sentry`: the same NPCs standing still, to tell movement from thinking.
JObject prof = still ? Prof(role: "sentry", name: "TCost") : Prof(radius: 10f, name: "TCost");
prof["ranges"] = JObject.FromObject(new { sense = 50f, loseTarget = 60f, chase = 40f, attack = 40f });
Dictionary refused = Api>("RunicNpc_SetProfiles", new JObject { ["t_cost"] = prof });
if (refused == null || refused.Count > 0)
{
Check("cost.profile", false, refused == null ? "no answer" : string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()));
_running = null;
yield break;
}
var meterHost = new GameObject("RunicNpcTest.FrameMeter");
FrameMeter meter = meterHost.AddComponent();
var medians = new Dictionary();
var npcs = new List();
IEnumerator m = MeasurePhase(meter, window, "baseline", medians);
while (m.MoveNext()) yield return m.Current;
// `rnt.cost idle asleep`: no keeper, so with nobody within 160 m they walk home and sleep (D235).
if (!asleep)
StandIn(OnMesh(field + new Vector3(13f, 0f, 110f)), 90);
for (int i = 0; i < 100; i++)
{
BasePlayer npc = Spawn(OnMesh(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f)), "t_cost");
if (npc != null) npcs.Add(npc);
if (i % 10 == 9) yield return null;
}
Check("cost.spawned", npcs.Count == 100, $"{npcs.Count} of 100");
yield return new WaitForSeconds(10f);
m = MeasurePhase(meter, window, "idle100", medians);
while (m.MoveNext()) yield return m.Current;
// `rnt.cost idle` stops here: a quicker run for finding what idle NPCs cost.
if (!idleOnly)
{
_hitsOnStandIns = 0;
for (int i = 1; i <= 20; i++)
StandIn(OnMesh(field + new Vector3((i - 1) * 1.5f, 0f, -12f)), 90 + i);
yield return new WaitForSeconds(10f);
int hitsBefore = _hitsOnStandIns;
m = MeasurePhase(meter, window, "fight100", medians);
while (m.MoveNext()) yield return m.Current;
Note($"cost.fight100 hits on stand-ins while measured: {_hitsOnStandIns - hitsBefore}");
Check("cost.fighting", _hitsOnStandIns - hitsBefore > 0, $"{_hitsOnStandIns - hitsBefore} hits");
}
Api("RunicNpc_DespawnOwner", Owner);
ClearWorld();
// A second empty window after everything is gone: one baseline at the start can carry a hitch
// (the first run on the Oxide rig did: a 2.4 s frame, and idle read below it). The bar is
// measured against the worse — the lower — of the two, so noise can only make it stricter.
yield return new WaitForSeconds(10f);
m = MeasurePhase(meter, window, "baseline.after", medians);
while (m.MoveNext()) yield return m.Current;
UnityEngine.Object.Destroy(meterHost);
double baseline = Math.Min(medians["baseline"], medians["baseline.after"]);
double idle = medians["idle100"] - baseline;
double fight = idleOnly ? double.NaN : medians["fight100"] - baseline;
Note($"cost baselines {medians["baseline"]:0.00} and {medians["baseline.after"]:0.00} ms; the lower is the bar's");
Check("cost.idle", idle <= IdleBudgetMs, $"{idle:+0.00;-0.00} ms median over the baseline (bar {IdleBudgetMs} ms)");
if (!idleOnly) Check("cost.fight", fight <= FightBudgetMs, $"{fight:+0.00;-0.00} ms median over the baseline (bar {FightBudgetMs} ms)");
Note($"done cost: {_pass} pass, {_fail} fail");
_running = null;
}
private IEnumerator MeasurePhase(FrameMeter meter, float window, string phase, Dictionary medians)
{
int queue0 = AIThinkManager._processQueue.Count;
meter.Begin();
yield return new WaitForSeconds(window);
List ms = meter.End();
if (ms.Count == 0)
{
Check("cost." + phase, false, "no frames");
medians[phase] = double.NaN;
yield break;
}
ms.Sort();
Func pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)];
double mean = ms.Average();
medians[phase] = pct(0.5);
Note($"cost.{phase} frames={ms.Count} p50={pct(0.5):0.00} ms fps={1000.0 / mean:0.0} p95={pct(0.95):0.00} p99={pct(0.99):0.00} " +
$"max={ms[ms.Count - 1]:0.00} aiQueue={queue0}->{AIThinkManager._processQueue.Count}");
}
public class FrameMeter : MonoBehaviour
{
private readonly List _frames = new List(20000);
private bool _recording;
public void Begin()
{
_frames.Clear();
_recording = true;
}
public List End()
{
_recording = false;
return new List(_frames);
}
private void Update()
{
if (_recording)
_frames.Add(Time.unscaledDeltaTime * 1000f);
}
}
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", "s7", "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()
: g == "s7" ? Stage7()
: 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") == 6, "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, 140f))
{
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");
var fighters = new Dictionary
{
{ "red", red }, { "blue", blue }, { "hunter", hunter }, { "prey", prey }, { "neutral", neutral },
{ "bystander", bystander }, { "leashed", leashed }, { "far", far }, { "victim", victim }, { "bully", bully },
};
string[] unspawned = fighters.Where(kv => kv.Value == null).Select(kv => kv.Key).ToArray();
Check("s5.fight.spawned", unspawned.Length == 0, unspawned.Length == 0 ? "" : $"not spawned: {string.Join(", ", unspawned)} (field {f}, map {World.Size})");
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 7: loot (D290–D301) ----
private static JObject LootTable(string start, string crate = null, float? corpseSeconds = null, float hack = 900f) =>
JObject.FromObject(new
{
start,
always = new object[]
{
new { item = "sewingkit", min = 10, max = 20, chance = 1f },
new { item = "tarp", min = 1, max = 1, chance = 0.5f }
},
pool = new
{
pick = 1,
nothing = 50f,
rows = new object[]
{
new { item = "propanetank", min = 5, max = 5, weight = 25f },
new { item = "semibody", min = 1, max = 2, weight = 25f }
}
},
crate,
hackSeconds = hack,
corpseSeconds
});
private static void Stage7Profiles(JObject all)
{
JObject P(string name, JObject loot)
{
JObject p = Prof(name: name);
p["loot"] = loot;
return p;
}
all["t_loot"] = P("TLoot", LootTable("rust"));
all["t_loot100"] = P("TLoot100", LootTable("none"));
all["t_lootkit"] = P("TLootKit", JObject.FromObject(new { start = "kit" }));
all["t_lootnone"] = P("TLootNone", JObject.FromObject(new { start = "none", always = new object[] { new { item = "scrap", min = 7, max = 7, chance = 1f } } }));
all["t_lootcrate"] = P("TLootCrate", LootTable("rust", "military"));
all["t_lootlocked"] = P("TLootLocked", LootTable("rust", "locked", null, 10f));
all["t_lootgone"] = P("TLootGone", LootTable("rust", null, 0f));
all["t_lootfive"] = P("TLootFive", LootTable("rust", null, 5f));
}
private static int CountIn(LootableCorpse c, string item) =>
c == null || c.containers == null ? 0 : c.containers.Sum(x => x.itemList.Where(i => i.info.shortname == item).Sum(i => i.amount));
private static int CountIn(ItemContainer c, string item) => c == null ? 0 : c.itemList.Where(i => i.info.shortname == item).Sum(i => i.amount);
/// Kills n NPCs of a profile in a row, then finds each one's corpse where it died.
private IEnumerator KillMany(Vector3 at, string profile, int n, JObject overrides, List corpses, List spots)
{
BasePlayer shooter = StandIn(OnMesh(at + new Vector3(0, 0, 25)), 81);
var npcs = new List();
for (int i = 0; i < n; i++)
{
BasePlayer npc = null;
Vector3 spot = at + new Vector3((i % 10) * 4f, 0, (i / 10) * 4f);
for (int tryAt = 0; tryAt < 6 && npc == null; tryAt++)
npc = Spawn(OnMesh(spot + new Vector3(tryAt * 1.5f, 0, -tryAt * 1.5f)), profile, overrides);
if (npc != null) npcs.Add(npc);
if (i % 5 == 4) yield return null;
}
yield return new WaitForSeconds(1f);
foreach (BasePlayer npc in npcs)
{
if (Gone(npc)) continue;
spots.Add(npc.transform.position);
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f));
}
yield return new WaitForSeconds(1f);
var taken = new HashSet();
foreach (Vector3 s in spots)
{
var near = new List();
Vis.Entities(s, 2f, near);
NPCPlayerCorpse c = near.Where(x => !taken.Contains(x)).OrderBy(x => (x.transform.position - s).sqrMagnitude).FirstOrDefault();
if (c != null) taken.Add(c);
corpses.Add(c);
}
}
private IEnumerator Stage7()
{
Vector3 f;
if (!FindField(300f, 2028, out f))
{
Check("s7.setup.field", false, "no open field on the navmesh");
yield break;
}
StandIn(f + new Vector3(0, 0, 60), 50);
// ---- refusals, through a spawn's overrides ----
Func refused = json => Spawn(f, "t_loot", JObject.Parse(json)) == null;
Check("s7.refuse.start", refused("{\"loot\":{\"start\":\"some\"}}"), "start rust, kit or none");
Check("s7.refuse.item", refused("{\"loot\":{\"always\":[{\"item\":\"nope.item\",\"chance\":1}]}}"), "an item this Rust has");
Check("s7.refuse.chance", refused("{\"loot\":{\"always\":[{\"item\":\"scrap\",\"chance\":0}]}}"), "chance above 0");
Check("s7.refuse.minMax", refused("{\"loot\":{\"always\":[{\"item\":\"scrap\",\"min\":5,\"max\":2}]}}"), "max at least min");
Check("s7.refuse.pick", refused("{\"loot\":{\"pool\":{\"pick\":3}}}"), "pick no more than the pool's rows");
Check("s7.refuse.crate", refused("{\"loot\":{\"crate\":\"barrel\"}}"), "wooden, military, elite or locked");
Check("s7.refuse.slots", refused("{\"loot\":{\"crate\":\"wooden\",\"always\":[{\"item\":\"scrap\"},{\"item\":\"rope\"},{\"item\":\"gears\"},{\"item\":\"cloth\"},{\"item\":\"wood\"},{\"item\":\"stones\"}],\"pool\":{\"pick\":1}}}"), "6 always + 1 picked > a wooden box's 6 slots");
Check("s7.refuse.hack", refused("{\"loot\":{\"hackSeconds\":0}}"), "hackSeconds 1 to 86400");
Api("RunicNpc_DespawnOwner", Owner);
// ---- 100 kills against the table (D291) ----
var corpses = new List();
var spots = new List();
for (int batch = 0; batch < 5; batch++)
{
IEnumerator k = KillMany(OnMesh(f + new Vector3(-60, 0, -40 + batch * 10)), "t_loot100", 20, null, corpses, new List());
while (k.MoveNext()) yield return k.Current;
}
var found = corpses.Where(c => c != null).ToList();
int scrap = found.Count(c => CountIn(c, "sewingkit") >= 10 && CountIn(c, "sewingkit") <= 20);
int rope = found.Count(c => CountIn(c, "tarp") > 0);
int metal = found.Count(c => CountIn(c, "propanetank") == 5);
int gears = found.Count(c => CountIn(c, "semibody") > 0);
int both = found.Count(c => CountIn(c, "propanetank") > 0 && CountIn(c, "semibody") > 0);
int rustLoot = found.Count(c => c.containers[0].itemList.Any(i => i.info.shortname != "sewingkit" && i.info.shortname != "tarp" && i.info.shortname != "propanetank" && i.info.shortname != "semibody"));
Check("s7.table.corpses", corpses.Count >= 60 && found.Count >= corpses.Count - 2, $"{corpses.Count} of 100 spawned and killed, {found.Count} corpses found");
Check("s7.table.always", scrap == found.Count, $"sewing kits 10-20 (chance 1): in {scrap}/{found.Count}");
Check("s7.table.chance", Math.Abs((double)rope / Math.Max(1, found.Count) - 0.5) <= Math.Sqrt(0.25 / Math.Max(1, found.Count)) * 3.5, $"tarp (chance 0.5): in {rope}/{found.Count}");
// Each row 25%: within 3.5 standard deviations for this many kills.
double sd = Math.Sqrt(0.25 * 0.75 / Math.Max(1, found.Count)) * 3.5;
Func near25 = x => Math.Abs((double)x / Math.Max(1, found.Count) - 0.25) <= sd;
Check("s7.table.poolWeights", near25(metal) && near25(gears),
$"pool pick 1 (nothing 50, propane 25, semi body 25): propane in {metal}, semi body in {gears}, of {found.Count}");
Check("s7.table.poolCap", both == 0, $"both pool rows in one corpse: {both} (pick 1 is the cap)");
Check("s7.table.onlyTable", rustLoot == 0, $"start none: nothing but the table in {found.Count - rustLoot}/{found.Count}");
Api("RunicNpc_DespawnOwner", Owner);
foreach (NPCPlayerCorpse c in found) if (c != null && !c.IsDestroyed) c.Kill();
// ---- the starts (D290, D300) ----
var one = new List();
var at = new List();
IEnumerator k2 = KillMany(OnMesh(f + new Vector3(20, 0, -40)), "t_lootkit", 1, null, one, at);
while (k2.MoveNext()) yield return k2.Current;
NPCPlayerCorpse kit = one.FirstOrDefault();
var dropped = new List();
if (at.Count > 0) Vis.Entities(at[0], 4f, dropped);
Note("s7.start.kit dropped near it: " + string.Join(", ", dropped.Select(d => d.item != null ? d.item.info.shortname + "×" + d.item.amount : "?").ToArray()));
Check("s7.start.kit", kit != null && CountIn(kit, "pistol.revolver") == 1 && CountIn(kit, "ammo.pistol") > 0 && kit.containers.Length > 1 && kit.containers[1].itemList.Count > 0,
$"kit start: {CorpseContents(kit)}");
one.Clear(); at.Clear();
k2 = KillMany(OnMesh(f + new Vector3(30, 0, -40)), "t_lootnone", 1, null, one, at);
while (k2.MoveNext()) yield return k2.Current;
NPCPlayerCorpse none = one.FirstOrDefault();
Check("s7.start.none", none != null && none.containers.Sum(x => x.itemList.Count) == 1 && CountIn(none, "scrap") == 7,
$"nothing, then the table's 7 scrap, clothes off too (D300): {CorpseContents(none)}");
// ---- crates (D292, D297, D301) ----
one.Clear(); at.Clear();
k2 = KillMany(OnMesh(f + new Vector3(40, 0, -40)), "t_lootcrate", 1, null, one, at);
while (k2.MoveNext()) yield return k2.Current;
var crates = new List();
if (at.Count > 0) Vis.Entities(at[0], 4f, crates);
LootContainer mil = crates.FirstOrDefault(x => x.ShortPrefabName == "crate_normal");
Check("s7.crate.military", mil != null && CountIn(mil.inventory, "sewingkit") >= 10 && CountIn(one.FirstOrDefault(), "sewingkit") == 0,
mil != null ? $"a military crate beside it: {Contents(mil.inventory)}; corpse sewing kits {CountIn(one.FirstOrDefault(), "sewingkit")}" : "no crate");
if (mil != null) _built.Add(mil);
one.Clear(); at.Clear();
k2 = KillMany(OnMesh(f + new Vector3(50, 0, -40)), "t_lootlocked", 1, null, one, at);
while (k2.MoveNext()) yield return k2.Current;
crates.Clear();
if (at.Count > 0) Vis.Entities(at[0], 4f, crates);
HackableLockedCrate locked = crates.OfType().FirstOrDefault();
if (locked != null)
{
_built.Add(locked);
Check("s7.crate.lockedNotStarted", !locked.IsBeingHacked() && !locked.IsFullyHacked(), "it waits for a player to start the hack");
locked.StartHacking(); // as RPC_Hack does for a player
var sw = System.Diagnostics.Stopwatch.StartNew();
while (sw.Elapsed.TotalSeconds < 25 && !locked.IsFullyHacked())
yield return new WaitForSeconds(0.5f);
Check("s7.crate.lockedTimer", locked.IsFullyHacked() && sw.Elapsed.TotalSeconds < 15, $"hackSeconds 10: unlocked after {sw.Elapsed.TotalSeconds:0.0} s, holding {Contents(locked.inventory)}");
}
else Check("s7.crate.lockedNotStarted", false, "no locked crate");
// ---- the corpse's time (D294) ----
one.Clear(); at.Clear();
k2 = KillMany(OnMesh(f + new Vector3(60, 0, -40)), "t_lootgone", 1, null, one, at);
while (k2.MoveNext()) yield return k2.Current;
Check("s7.corpse.none", one.FirstOrDefault() == null, "corpseSeconds 0: no corpse a second later");
one.Clear(); at.Clear();
k2 = KillMany(OnMesh(f + new Vector3(35, 0, -30)), "t_lootfive", 1, null, one, at);
while (k2.MoveNext()) yield return k2.Current;
NPCPlayerCorpse five = one.FirstOrDefault();
bool fiveFound = !ReferenceEquals(five, null) && !five.IsDestroyed;
if (five == null)
{
var near10 = new List();
if (at.Count > 0) Vis.Entities(at[0], 10f, near10);
Note($"s7.corpse.seconds: spots {at.Count}, corpses within 10 m: " + string.Join(", ", near10.Select(x => x.ShortPrefabName + " " + Vector3.Distance(x.transform.position, at[0]).ToString("0.0") + " m").ToArray()));
}
yield return new WaitForSeconds(5f);
// A destroyed Unity object compares equal to null, so whether one was found is kept apart.
Check("s7.corpse.seconds", fiveFound && (ReferenceEquals(five, null) || five.IsDestroyed), fiveFound ? $"corpseSeconds 5: found a second after death, gone 6 s after" : "no corpse at all");
// ---- an event's switch (D296) ----
one.Clear(); at.Clear();
k2 = KillMany(OnMesh(f + new Vector3(80, 0, -40)), "t_loot", 1, JObject.Parse("{\"loot\":{\"dropTable\":false}}"), one, at);
while (k2.MoveNext()) yield return k2.Current;
NPCPlayerCorpse off = one.FirstOrDefault();
Check("s7.dropTable.off", off != null && CountIn(off, "sewingkit") == 0, $"dropTable false: no table: {CorpseContents(off)}");
Check("s7.start.rust", off != null && off.containers[0].itemList.Count > 0 && off.containers[1].itemList.Count > 0, $"start rust: Rust's scientist loot and the clothes it wore: {CorpseContents(off)}");
}
// ---- stage 7's spike (D299): the corpse's loot, crates, the locked crate's timer, removing a corpse ----
private int _corpsePopulated;
private NPCPlayerCorpse _lastCorpse;
/// Counts the framework hook for an NPC corpse being filled, for our NPC (stage 7 spike).
private void OnCorpsePopulate(BasePlayer npc, NPCPlayerCorpse corpse)
{
if (npc == null || npc.GetType().Name != "RunicNpcPlayer") return;
_corpsePopulated++;
_lastCorpse = corpse;
}
private static string Contents(ItemContainer c) =>
c == null ? "-" : string.Join(", ", c.itemList.Select(i => $"{i.info.shortname}×{i.amount}").ToArray());
private static string CorpseContents(LootableCorpse c) =>
c == null || c.containers == null ? "-" : string.Join(" | ", c.containers.Select(Contents).ToArray());
/// rnt.spike7 [part,...]: corpse, crates, locked, removal, or all.
[ConsoleCommand("rnt.spike7")]
private void CmdSpike7(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(Spike7(part));
arg.ReplyWith($"rnt: spike7 {part}");
}
private IEnumerator Spike7(string part)
{
_pass = _fail = 0;
Note($"spike7 {part} on {(IsCarbon() ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}");
SetTestProfiles();
Vector3 f;
if (!FindField(300f, 2027, out f))
{
Check("s7.setup.field", false, "no open field on the navmesh");
yield break;
}
string[] parts = part == "all" ? new[] { "corpse", "crates", "locked", "removal" } : part.Split(',');
foreach (string p in parts)
{
StandIn(f + new Vector3(0, 0, 60), 50);
IEnumerator inner = p == "corpse" ? CorpseSpike(f)
: p == "crates" ? CratesSpike(f)
: p == "locked" ? LockedSpike(f)
: p == "removal" ? RemovalSpike(f)
: null;
if (inner == null) { Check("s7.setup.part", false, "no part " + p); continue; }
while (inner.MoveNext()) yield return inner.Current;
Api("RunicNpc_DespawnOwner", Owner);
ClearWorld();
yield return new WaitForSeconds(1f);
}
Note($"done spike7 {part}: {_pass} pass, {_fail} fail");
_running = null;
}
/// Kills one t_roam as a stand-in and returns its corpse (found near where it died).
private IEnumerator KillForCorpse(Vector3 at, Action before, Action got)
{
BasePlayer npc = Spawn(at, "t_roam");
yield return new WaitForSeconds(1f);
if (npc == null) { got(null, 0); yield break; }
before?.Invoke(npc);
BasePlayer shooter = StandIn(OnMesh(at + new Vector3(0, 0, 20)), 80);
_lastCorpse = null;
Vector3 where = npc.transform.position;
var sw = System.Diagnostics.Stopwatch.StartNew();
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f));
double ms = sw.Elapsed.TotalMilliseconds;
yield return new WaitForSeconds(1f);
NPCPlayerCorpse corpse = _lastCorpse;
if (corpse == null)
{
var near = new List();
Vis.Entities(where, 5f, near);
corpse = near.FirstOrDefault();
}
got(corpse, ms);
}
private IEnumerator CorpseSpike(Vector3 f)
{
var applyLoot = typeof(HumanNPC).GetMethod("ApplyLoot", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
var createCorpse = typeof(NPCPlayer).GetMethod("CreateCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
var copyField = typeof(NPCPlayer).GetField("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
?? typeof(HumanNPC).GetField("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
var copyProp = copyField == null ? typeof(NPCPlayer).GetProperty("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) : null;
Note($"s7.corpse ApplyLoot: {(applyLoot == null ? "none" : (applyLoot.IsVirtual ? "virtual" : "not virtual") + " " + (applyLoot.IsFamily ? "protected" : applyLoot.IsPublic ? "public" : "private"))}; " +
$"CreateCorpse virtual: {createCorpse?.IsVirtual}; CopyInventoryToCorpse: {(copyField != null ? "field" : copyProp != null ? "property, settable " + (copyProp.GetSetMethod(true) != null) + ", virtual get " + copyProp.GetGetMethod(true)?.IsVirtual : "none")}");
Check("s7.corpse.applyLootOverridable", applyLoot != null && applyLoot.IsVirtual, "a subclass can fill its own corpse");
// 1. As today: Rust's scientist loot.
NPCPlayerCorpse c1 = null;
double ms1 = 0;
_corpsePopulated = 0;
IEnumerator k = KillForCorpse(OnMesh(f + new Vector3(-40, 0, 0)), null, (c, ms) => { c1 = c; ms1 = ms; });
while (k.MoveNext()) yield return k.Current;
Check("s7.corpse.hook", _corpsePopulated > 0, $"OnCorpsePopulate raised {_corpsePopulated} time(s) for our NPC");
Check("s7.corpse.default", c1 != null, $"Rust's scientist loot: {CorpseContents(c1)}; removal in {(c1 != null ? c1.GetRemovalTime() : -1):0} s; death took {ms1:0.0} ms");
// 2. The kit: Rust copies the inventory when CopyInventoryToCorpse is true.
NPCPlayerCorpse c2 = null;
k = KillForCorpse(OnMesh(f + new Vector3(-30, 0, 0)), npc =>
{
if (copyField != null) copyField.SetValue(npc, true);
else if (copyProp != null && copyProp.GetSetMethod(true) != null) copyProp.SetValue(npc, true, null);
}, (c, ms) => c2 = c);
while (k.MoveNext()) yield return k.Current;
bool kit = c2 != null && c2.containers != null && c2.containers.Any(x => x.itemList.Any(i => i.info.shortname == "pistol.revolver"));
Check("s7.corpse.kit", kit, $"with CopyInventoryToCorpse on: {CorpseContents(c2)}");
// 3. Nothing, then a table added: emptied in the hook's place, then filled.
NPCPlayerCorpse c3 = null;
k = KillForCorpse(OnMesh(f + new Vector3(-20, 0, 0)), null, (c, ms) => c3 = c);
while (k.MoveNext()) yield return k.Current;
if (c3 != null)
{
foreach (ItemContainer cont in c3.containers) cont.Clear();
ItemManager.DoRemoves();
Item scrap = ItemManager.CreateByName("scrap", 150);
bool moved = scrap != null && scrap.MoveToContainer(c3.containers[0]);
Check("s7.corpse.emptyThenFill", moved && c3.containers[0].itemList.Count == 1, $"emptied, then the table's scrap: {CorpseContents(c3)}");
}
else Check("s7.corpse.emptyThenFill", false, "no corpse");
}
private static readonly string[][] Crates =
{
new[] { "wooden box", "assets/bundled/prefabs/radtown/crate_normal_2.prefab" },
new[] { "military crate", "assets/bundled/prefabs/radtown/crate_normal.prefab" },
new[] { "elite crate", "assets/bundled/prefabs/radtown/crate_elite.prefab" },
new[] { "locked crate", "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab" },
};
private IEnumerator CratesSpike(Vector3 f)
{
int i = 0;
foreach (string[] c in Crates)
{
Vector3 at = OnMesh(f + new Vector3(-40 + i * 6, 0, 10));
i++;
BaseEntity made = GameManager.server.CreateEntity(c[1], at);
LootContainer box = made as LootContainer;
if (box == null)
{
Check("s7.crate." + c[0].Replace(" ", ""), false, made == null ? "no entity from " + c[1] : made.GetType().Name + " is not a LootContainer");
if (made != null) made.Kill();
continue;
}
// Before it spawns: no loot of Rust's, never refilled.
box.initialLootSpawn = false;
box.minSecondsBetweenRefresh = -1f;
box.maxSecondsBetweenRefresh = -1f;
box.enableSaving = false;
box.Spawn();
_built.Add(box);
yield return new WaitForSeconds(0.5f);
int before = box.inventory?.itemList.Count ?? -1;
Item scrap = ItemManager.CreateByName("scrap", 200);
bool moved = scrap != null && scrap.MoveToContainer(box.inventory);
yield return new WaitForSeconds(1f);
Check("s7.crate." + c[0].Replace(" ", ""), before == 0 && moved && box.inventory.itemList.Count == 1,
$"{box.GetType().Name} '{box.ShortPrefabName}': {before} item(s) of Rust's at spawn, then {Contents(box.inventory)}; capacity {box.inventory?.capacity}, refreshes {box.shouldRefreshContents}, destroyOnEmpty {box.destroyOnEmpty}");
}
}
private static readonly FieldInfo HackSecondsField = typeof(HackableLockedCrate).GetField("hackSeconds", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
private IEnumerator LockedSpike(Vector3 f)
{
Note($"s7.locked server-wide: requiredHackSeconds {HackableLockedCrate.requiredHackSeconds}, decaySeconds {HackableLockedCrate.decaySeconds}; hackSeconds field {(HackSecondsField != null ? "found" : "MISSING")}");
foreach (float want in new[] { 10f, 20f })
{
Vector3 at = OnMesh(f + new Vector3(20 + want, 0, 10));
var crate = GameManager.server.CreateEntity(Crates[3][1], at) as HackableLockedCrate;
if (crate == null || HackSecondsField == null) { Check("s7.locked." + want, false, "no crate or no field"); yield break; }
crate.initialLootSpawn = false;
crate.enableSaving = false;
crate.Spawn();
_built.Add(crate);
// A per-crate timer: start its count where `want` seconds remain of the server-wide total.
HackSecondsField.SetValue(crate, HackableLockedCrate.requiredHackSeconds - want);
crate.StartHacking();
var sw = System.Diagnostics.Stopwatch.StartNew();
while (sw.Elapsed.TotalSeconds < want + 15 && !crate.IsFullyHacked())
yield return new WaitForSeconds(0.5f);
Check("s7.locked." + want, crate.IsFullyHacked() && Mathf.Abs((float)sw.Elapsed.TotalSeconds - want) < 3f,
$"asked {want} s: unlocked after {sw.Elapsed.TotalSeconds:0.0} s, lootable {crate.IsFullyHacked()}");
}
// Longer than Rust's own: the count starts below zero. Read where it stands after 3 s.
var slow = GameManager.server.CreateEntity(Crates[3][1], OnMesh(f + new Vector3(60, 0, 10))) as HackableLockedCrate;
if (slow != null)
{
slow.initialLootSpawn = false;
slow.enableSaving = false;
slow.Spawn();
_built.Add(slow);
HackSecondsField.SetValue(slow, HackableLockedCrate.requiredHackSeconds - 1800f);
slow.StartHacking();
yield return new WaitForSeconds(3f);
float now = (float)HackSecondsField.GetValue(slow);
Check("s7.locked.longer", !slow.IsFullyHacked() && now < 0f, $"asked 1800 s: count at {now:0} after 3 s (counts up to {HackableLockedCrate.requiredHackSeconds}); the client is sent this count, so its timer shows from a negative start");
}
}
private IEnumerator RemovalSpike(Vector3 f)
{
NPCPlayerCorpse c = null;
IEnumerator k = KillForCorpse(OnMesh(f + new Vector3(-10, 0, 0)), null, (x, ms) => c = x);
while (k.MoveNext()) yield return k.Current;
if (c == null) { Check("s7.removal.timed", false, "no corpse"); yield break; }
float rust = c.GetRemovalTime();
c.ResetRemovalTime(5f);
var sw = System.Diagnostics.Stopwatch.StartNew();
while (sw.Elapsed.TotalSeconds < 15 && c != null && !c.IsDestroyed)
yield return new WaitForSeconds(0.5f);
Check("s7.removal.timed", c == null || c.IsDestroyed, $"Rust's own time {rust:0} s; set to 5 s: gone after {sw.Elapsed.TotalSeconds:0.0} s");
NPCPlayerCorpse c2 = null;
k = KillForCorpse(OnMesh(f + new Vector3(0, 0, 0)), null, (x, ms) => c2 = x);
while (k.MoveNext()) yield return k.Current;
if (c2 != null) c2.Kill();
yield return null;
Check("s7.removal.none", c2 == null || c2.IsDestroyed, "a corpse killed at once is gone");
}
// ---- 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