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