Run alone on each rig, s5 is 52/52 on Oxide and on Carbon, and rnt.run all was otherwise green on both (stages 2-4 unchanged). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
1910 lines
109 KiB
C#
1910 lines
109 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>,
|
||
/// <c>s5</c> (stage 5's behaviour: factions, fights, alarm, allies, escort, guard, tether, turrets,
|
||
/// PVE and the kit's extras), or <c>all</c> for each in turn.
|
||
/// <c>rnt.run place</c> leaves two placements behind for the restart check, and
|
||
/// <c>rnt.after</c> checks, after a reload or restart, that every placement is back and nothing
|
||
/// else is. <c>rnt.spawn [n]</c> spawns plugin-owned NPCs, for the check that they die with this plugin, and
|
||
/// <c>rnt.clear</c> removes everything the harness made.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// It needs the hidden Kits kit <c>rnhrevolver</c> (stage 1's: a revolver, pistol ammo and
|
||
/// burlap). It REPLACES RunicNPC's profiles with its own test set, which marks the server
|
||
/// managed: back up <c>data/RunicNPC/</c> first and restore it afterwards.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Targets and wake-ups are <b>stand-ins</b>, stage 1's fake players: <c>player.prefab</c> with
|
||
/// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
|
||
/// </para>
|
||
/// </summary>
|
||
[Info("RunicNpcTest", "Runic Gateway", "0.5.0")]
|
||
[Description("RunicNPC stage 2, 3, 4 and 5 tests. A developer tool: never ship it.")]
|
||
internal class RunicNpcTest : RustPlugin
|
||
{
|
||
[PluginReference] private Plugin RunicNPC;
|
||
|
||
private const string Kit = "rnhrevolver";
|
||
private const string Owner = "plugin:RunicNpcTest";
|
||
private const string PlayerPrefab = "assets/prefabs/player/player.prefab";
|
||
private const ulong StandInId = 11400000101UL;
|
||
|
||
private readonly List<string> _lines = new List<string>();
|
||
private readonly HashSet<BasePlayer> _standIns = new HashSet<BasePlayer>();
|
||
private readonly List<BaseEntity> _built = new List<BaseEntity>();
|
||
private Coroutine _running;
|
||
private int _pass, _fail;
|
||
|
||
// What RunicNPC's hooks told us.
|
||
private readonly List<string> _spawned = new List<string>();
|
||
private readonly List<string> _died = new List<string>();
|
||
private readonly List<string> _health = new List<string>();
|
||
private readonly List<string> _despawned = new List<string>();
|
||
private readonly List<string> _changed = new List<string>();
|
||
private readonly Dictionary<ulong, float> _lastContributors = new Dictionary<ulong, float>();
|
||
private int _hitsOnStandIns;
|
||
|
||
private static readonly MethodInfo LifeStoryStart =
|
||
typeof(BasePlayer).GetMethod("LifeStoryStart", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||
|
||
// ---- output ----
|
||
|
||
private void Check(string name, bool ok, string detail = "")
|
||
{
|
||
if (ok) _pass++; else _fail++;
|
||
string line = $"{name}: {(ok ? "PASS" : "FAIL")} {detail}";
|
||
_lines.Add(line);
|
||
Puts("[rnt] " + line);
|
||
Interface.Oxide.DataFileSystem.WriteObject(Name, _lines);
|
||
}
|
||
|
||
private void Note(string line)
|
||
{
|
||
_lines.Add("# " + line);
|
||
Puts("[rnt] # " + line);
|
||
Interface.Oxide.DataFileSystem.WriteObject(Name, _lines);
|
||
}
|
||
|
||
// ---- lifecycle ----
|
||
|
||
private void Unload()
|
||
{
|
||
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
|
||
ClearWorld();
|
||
}
|
||
|
||
private void ClearWorld()
|
||
{
|
||
foreach (BasePlayer p in _standIns.ToList())
|
||
if (p != null && !p.IsDestroyed) p.Kill();
|
||
_standIns.Clear();
|
||
foreach (BaseEntity e in _built)
|
||
if (e != null && !e.IsDestroyed) e.Kill();
|
||
_built.Clear();
|
||
}
|
||
|
||
// ---- RunicNPC's API, typed ----
|
||
|
||
private T Api<T>(string call, params object[] args)
|
||
{
|
||
object r = RunicNPC?.Call(call, args);
|
||
return r is T ? (T)r : default(T);
|
||
}
|
||
|
||
private BasePlayer Spawn(Vector3 at, string profile, JObject overrides = null, string owner = Owner) =>
|
||
Api<BasePlayer>("RunicNpc_Spawn", at, profile, owner, overrides);
|
||
|
||
private List<Dictionary<string, object>> List(string owner) =>
|
||
Api<List<Dictionary<string, object>>>("RunicNpc_List", owner) ?? new List<Dictionary<string, object>>();
|
||
|
||
private Dictionary<string, object> Describe(BasePlayer npc) =>
|
||
npc == null ? null : List(null).FirstOrDefault(d => (ulong)d["netId"] == npc.net.ID.Value);
|
||
|
||
private static Vector3 V(object o)
|
||
{
|
||
var d = (Dictionary<string, object>)o;
|
||
return new Vector3(Convert.ToSingle(d["x"]), Convert.ToSingle(d["y"]), Convert.ToSingle(d["z"]));
|
||
}
|
||
|
||
private static float Flat(Vector3 a, Vector3 b) => new Vector2(a.x - b.x, a.z - b.z).magnitude;
|
||
|
||
// ---- places ----
|
||
|
||
private static bool SampleNavmesh(Vector3 p, float r, out NavMeshHit hit) =>
|
||
Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, r, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas);
|
||
|
||
private static MonumentInfo[] _monuments;
|
||
|
||
/// <summary>The map's monuments. (TerrainMeta.Path.Monuments is internal in Rust's own assembly.)</summary>
|
||
private static MonumentInfo[] Monuments() => _monuments ?? (_monuments = UnityEngine.Object.FindObjectsOfType<MonumentInfo>());
|
||
|
||
/// <summary>A flat, dry spot on the navmesh far from every monument; fixed seed, so reruns land in the same field.</summary>
|
||
private static bool FindField(float clear, int seed, out Vector3 at)
|
||
{
|
||
var rng = new System.Random(seed);
|
||
float half = World.Size / 2f - 300f;
|
||
for (int i = 0; i < 4000; i++)
|
||
{
|
||
var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half);
|
||
p.y = TerrainMeta.HeightMap.GetHeight(p);
|
||
if (p.y < TerrainMeta.WaterMap.GetHeight(p) + 1f || TerrainMeta.HeightMap.GetSlope(p) > 12f) continue;
|
||
if (Monuments().Any(m => Vector3.Distance(m.transform.position, p) < clear)) continue;
|
||
NavMeshHit hit;
|
||
if (!SampleNavmesh(p, 2f, out hit)) continue;
|
||
// The whole 35 m around it must be walkable too, for the wander and route tests.
|
||
bool open = true;
|
||
for (int k = 0; k < 8 && open; k++)
|
||
{
|
||
float a = k * Mathf.PI / 4f;
|
||
NavMeshHit h2;
|
||
open = SampleNavmesh(hit.position + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * 35f, 2f, out h2);
|
||
}
|
||
|
||
if (!open) continue;
|
||
at = hit.position;
|
||
return true;
|
||
}
|
||
|
||
at = Vector3.zero;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>A monument with an AI zone, where Rust's own roam has move points to follow.</summary>
|
||
private static bool FindMonument(out Vector3 at, out string name)
|
||
{
|
||
foreach (MonumentInfo m in Monuments().OrderBy(m => m.name.Contains("military") ? 0 : 1))
|
||
{
|
||
if (m == null) continue;
|
||
AIInformationZone zone = AIInformationZone.GetForPoint(m.transform.position, false);
|
||
if (zone == null) continue;
|
||
NavMeshHit hit;
|
||
if (!SampleNavmesh(m.transform.position, 20f, out hit)) continue;
|
||
at = hit.position;
|
||
name = System.IO.Path.GetFileNameWithoutExtension(m.name);
|
||
return true;
|
||
}
|
||
|
||
at = Vector3.zero;
|
||
name = null;
|
||
return false;
|
||
}
|
||
|
||
private BasePlayer StandIn(Vector3 at, int index)
|
||
{
|
||
BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, at) as BasePlayer;
|
||
if (p == null) return null;
|
||
ulong id = StandInId + (ulong)index;
|
||
p.userID = id;
|
||
p.UserIDString = id.ToString();
|
||
p.displayName = "RntStandIn" + index;
|
||
p.enableSaving = false;
|
||
p.Spawn();
|
||
p.EndSleeping();
|
||
LifeStoryStart?.Invoke(p, null);
|
||
_standIns.Add(p);
|
||
return p;
|
||
}
|
||
|
||
private void RemoveStandIns()
|
||
{
|
||
foreach (BasePlayer p in _standIns.ToList())
|
||
if (p != null && !p.IsDestroyed) p.Kill();
|
||
_standIns.Clear();
|
||
}
|
||
|
||
// ---- hooks: RunicNPC's, counted; and the stand-ins made invulnerable ----
|
||
|
||
private void OnRunicNpcSpawned(BasePlayer npc, string profile, string owner) => _spawned.Add($"{profile}/{owner}");
|
||
|
||
private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)
|
||
{
|
||
_died.Add($"{profile}/{owner}");
|
||
_lastContributors.Clear();
|
||
foreach (KeyValuePair<ulong, float> kv in contributors) _lastContributors[kv.Key] = kv.Value;
|
||
}
|
||
|
||
private void OnRunicNpcHealth(BasePlayer npc, string profile, float fraction) => _health.Add($"{profile}@{fraction}");
|
||
|
||
private void OnRunicNpcDespawned(BasePlayer npc, string owner) => _despawned.Add(owner);
|
||
|
||
private void OnRunicNpcPlacementChanged(string id, string change, string previous) =>
|
||
_changed.Add(previous != null ? $"{change}:{previous}>{id}" : $"{change}:{id}");
|
||
|
||
private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info)
|
||
{
|
||
BasePlayer p = victim as BasePlayer;
|
||
if (p == null || !_standIns.Contains(p) || info == null) return;
|
||
if (info.Initiator is BasePlayer && ((BasePlayer)info.Initiator).IsNpc) _hitsOnStandIns++;
|
||
info.damageTypes.ScaleAll(0f);
|
||
}
|
||
|
||
// ---- the profiles and routes the tests use ----
|
||
|
||
private static JObject Prof(string movement = "wander", float radius = 15f, string role = "roamer", string kit = Kit, string name = "TRoam") =>
|
||
JObject.FromObject(new
|
||
{
|
||
names = new[] { name },
|
||
kits = new[] { kit },
|
||
prefab = "scientistnpc_roam",
|
||
role,
|
||
movement = new { mode = movement, radius },
|
||
health = 250f,
|
||
damageDealt = 1f,
|
||
damageTaken = new { head = 2f, body = 0.5f, legs = 1f },
|
||
aimCone = 2f,
|
||
ranges = new { sense = 30f, loseTarget = 40f, chase = 40f, attack = 30f },
|
||
visionCone = -0.8f,
|
||
sleepDistance = 160f,
|
||
healthThresholds = new[] { 0.5f }
|
||
});
|
||
|
||
private Dictionary<string, string> SetTestProfiles(bool withEach = true)
|
||
{
|
||
var all = new JObject
|
||
{
|
||
["t_roam"] = Prof(),
|
||
["t_sentry"] = Prof(role: "sentry", name: "TSentry"),
|
||
["t_mon"] = Prof("monument", name: "TMon"),
|
||
["t_route"] = Prof("route:t_loop", name: "TRoute"),
|
||
["t_bad"] = Prof(kit: "rnt_nokit", name: "TBad")
|
||
};
|
||
JObject leash = Prof(radius: 4f, name: "TLeash");
|
||
leash["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 10f, attack = 4f });
|
||
all["t_leash"] = leash;
|
||
if (withEach)
|
||
all["t_each"] = Prof(name: "TEach");
|
||
Stage5Profiles(all);
|
||
return Api<Dictionary<string, string>>("RunicNpc_SetProfiles", all) ?? new Dictionary<string, string> { ["?"] = "no answer" };
|
||
}
|
||
|
||
private string SetLoop(Vector3 centre)
|
||
{
|
||
var points = new JArray();
|
||
foreach (Vector3 o in new[] { new Vector3(12, 0, 12), new Vector3(-12, 0, 12), new Vector3(-12, 0, -12), new Vector3(12, 0, -12) })
|
||
{
|
||
NavMeshHit hit;
|
||
SampleNavmesh(centre + o, 3f, out hit);
|
||
points.Add(JObject.FromObject(new { x = hit.position.x, y = hit.position.y, z = hit.position.z }));
|
||
}
|
||
|
||
return Api<string>("RunicNpc_SetRoute", "t_loop", JObject.FromObject(new { points, loop = true }));
|
||
}
|
||
|
||
// ---- commands ----
|
||
|
||
[ConsoleCommand("rnt.run")]
|
||
private void CmdRun(ConsoleSystem.Arg arg)
|
||
{
|
||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||
string group = arg.GetString(0, "all");
|
||
if (_running != null) ServerMgr.Instance.StopCoroutine(_running);
|
||
_running = ServerMgr.Instance.StartCoroutine(Run(group));
|
||
arg.ReplyWith($"rnt: running {group}");
|
||
}
|
||
|
||
[ConsoleCommand("rnt.after")]
|
||
private void CmdAfter(ConsoleSystem.Arg arg)
|
||
{
|
||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||
After();
|
||
}
|
||
|
||
/// <summary>Spawns n plugin-owned NPCs, for the check that a plugin's NPCs die with it.</summary>
|
||
[ConsoleCommand("rnt.spawn")]
|
||
private void CmdSpawn(ConsoleSystem.Arg arg)
|
||
{
|
||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||
Vector3 field;
|
||
if (!FindField(300f, 2024, out field)) return;
|
||
int n = 0;
|
||
for (int i = 0; i < arg.GetInt(0, 3); i++)
|
||
if (Spawn(field + new Vector3(i * 2f, 0f, -30f), "t_roam") != null) n++;
|
||
arg.ReplyWith($"rnt: spawned {n} as {Owner}");
|
||
}
|
||
|
||
/// <summary>A probe: two profiles 15 m apart, described every two seconds for 30 s.</summary>
|
||
[ConsoleCommand("rnt.duel")]
|
||
private void CmdDuel(ConsoleSystem.Arg arg)
|
||
{
|
||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||
Vector3 f;
|
||
if (!FindField(300f, 2025, out f)) return;
|
||
BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50);
|
||
BasePlayer x = Spawn(OnMesh(f + new Vector3(-140, 0, 0)), arg.GetString(0, "t_red"));
|
||
BasePlayer y = Spawn(OnMesh(f + new Vector3(-140, 0, 15)), arg.GetString(1, "t_blue"));
|
||
int n = 0;
|
||
timer.Repeat(2f, 15, () =>
|
||
{
|
||
n++;
|
||
foreach (BasePlayer p in new[] { x, y })
|
||
{
|
||
Dictionary<string, object> d = p == null || p.IsDestroyed ? null : Describe(p);
|
||
Puts($"[duel {n}] {(d == null ? "gone" : JsonConvert.SerializeObject(d))} hp={(p == null || p.IsDestroyed ? 0 : p.health):0} held={(p == null || p.IsDestroyed ? "-" : p.GetActiveItem()?.info.shortname ?? "none")}");
|
||
}
|
||
if (n == 15) { Api<int>("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
|
||
});
|
||
arg.ReplyWith("rnt: duel started");
|
||
}
|
||
|
||
/// <summary>A probe: a t_kit NPC with a flamethrower and fuel, a stand-in 4 m off, every second for 20 s.</summary>
|
||
[ConsoleCommand("rnt.kitprobe")]
|
||
private void CmdKitProbe(ConsoleSystem.Arg arg)
|
||
{
|
||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||
Vector3 f;
|
||
if (!FindField(300f, 2025, out f)) return;
|
||
Vector3 at = OnMesh(f + new Vector3(-30, 0, -60));
|
||
BasePlayer flamer = Spawn(at, "t_kit");
|
||
Give(flamer, "flamethrower", 1);
|
||
Give(flamer, "lowgradefuel", 50);
|
||
BasePlayer target = StandIn(OnMesh(at + new Vector3(4, 0, 0)), 63);
|
||
int n = 0;
|
||
timer.Repeat(1f, 20, () =>
|
||
{
|
||
n++;
|
||
ScientistNPC s = flamer as ScientistNPC;
|
||
Item held = flamer == null || flamer.IsDestroyed ? null : flamer.GetActiveItem();
|
||
FlameThrower tank = held?.GetHeldEntity() as FlameThrower;
|
||
if (n == 3 && tank != null) tank.ammo = 0;
|
||
var mem = s?.Brain?.Senses?.Memory;
|
||
Puts($"[kit {n}] state={s?.Brain?.CurrentState?.StateType} held={held?.info.shortname ?? "none"} ammo={(tank != null ? tank.ammo : -1)} fuel={(flamer == null || flamer.IsDestroyed ? -1 : Count(flamer, "lowgradefuel"))} knows={Knows(flamer, target)} los={(mem != null && mem.IsLOS(target))} hits={_hitsOnStandIns}");
|
||
if (n == 20) { Api<int>("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
|
||
});
|
||
arg.ReplyWith("rnt: kit probe started");
|
||
}
|
||
|
||
/// <summary>Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks.</summary>
|
||
[ConsoleCommand("rnt.list")]
|
||
private void CmdList(ConsoleSystem.Arg arg)
|
||
{
|
||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||
string owner = arg.GetString(0, null);
|
||
var rows = List(owner);
|
||
arg.ReplyWith(string.Join(Environment.NewLine, rows.Select(d => JsonConvert.SerializeObject(d)).ToArray()) + Environment.NewLine + $"rnt: {rows.Count} NPC(s)");
|
||
}
|
||
|
||
/// <summary>A probe: a t_kit rocketeer and a stock scientist 25 m off, described every 2 s for 40 s.</summary>
|
||
[ConsoleCommand("rnt.rocketprobe")]
|
||
private void CmdRocketProbe(ConsoleSystem.Arg arg)
|
||
{
|
||
if (arg.Connection != null && !arg.IsAdmin) return;
|
||
Vector3 f;
|
||
if (!FindField(300f, 2025, out f)) return;
|
||
BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50);
|
||
Vector3 at = OnMesh(f + new Vector3(-30, 0, -60));
|
||
BasePlayer r = Spawn(at, "t_kit");
|
||
Give(r, "rocket.launcher", 1);
|
||
Give(r, "ammo.rocket.basic", 2);
|
||
ScientistNPC target = SpawnScientist(at + new Vector3(25, 0, 0));
|
||
int n = 0;
|
||
timer.Repeat(2f, 20, () =>
|
||
{
|
||
n++;
|
||
Dictionary<string, object> d = Gone(r) ? null : Describe(r);
|
||
string line = "-";
|
||
if (!Gone(r) && !Gone(target))
|
||
{
|
||
RaycastHit hh;
|
||
line = Physics.Linecast(r.eyes.position, target.CenterPoint(), out hh, 1218519041, QueryTriggerInteraction.Ignore)
|
||
? $"hit {hh.collider?.name}/{LayerMask.LayerToName(hh.collider != null ? hh.collider.gameObject.layer : 0)}/{hh.GetEntity()?.ShortPrefabName ?? "none"} at {hh.distance:0.0}"
|
||
: "clear";
|
||
line += $" eyes={r.eyes.position.y:0.0} centre={target.CenterPoint().y:0.0}";
|
||
}
|
||
Puts($"[rocket {n}] line={line} target={TargetOf(d)} engaged={d?["engaged"]} state={d?["state"]} rest={d?["rest"]} kit={(d != null ? JsonConvert.SerializeObject(d["kitUsed"]) : "-")} held={(Gone(r) ? "-" : r.GetActiveItem()?.info.shortname)} ammo={(Gone(r) ? -1 : Count(r, "ammo.rocket.basic"))} sci={(Gone(target) ? 0f : target.health):0} dist={(Gone(r) || Gone(target) ? 0f : Vector3.Distance(r.transform.position, target.transform.position)):0}");
|
||
if (n == 20) { Api<int>("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
|
||
});
|
||
arg.ReplyWith("rnt: rocket probe started");
|
||
}
|
||
|
||
/// <summary>A probe: an escort follows a scientist 35 m, the scientist is killed, then 30 s of what the escort does.</summary>
|
||
[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<string>("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<string, object> 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<int>("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<int>("RunicNpc_DespawnOwner", Owner);
|
||
foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2", "t_roam-1", "t_roam-2", "t_gate", "t_sentry-1", "t_site" })
|
||
Api<bool>("RunicNpc_RemovePlacement", id);
|
||
Api<bool>("RunicNpc_RemoveRoute", "t_loop");
|
||
Api<bool>("RunicNpc_RemoveRoute", "t_rec");
|
||
arg.ReplyWith("rnt: cleared");
|
||
}
|
||
|
||
private IEnumerator Run(string group)
|
||
{
|
||
_pass = _fail = 0;
|
||
Note($"run {group} on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}");
|
||
|
||
Vector3 field;
|
||
if (!FindField(300f, 2024, out field))
|
||
{
|
||
Check("setup.field", false, "no open field on the navmesh");
|
||
yield break;
|
||
}
|
||
|
||
Dictionary<string, string> refused = SetTestProfiles();
|
||
Check("setup.profiles", refused.Count == 3 && refused.ContainsKey("t_bad") && refused.ContainsKey("t_badfaction") && refused.ContainsKey("t_badturrets"), "refused: " + string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()));
|
||
string route = SetLoop(field + new Vector3(60f, 0f, 0f));
|
||
Check("setup.route", route == null, route ?? "t_loop set");
|
||
|
||
string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "place" } : new[] { group };
|
||
foreach (string g in groups)
|
||
{
|
||
IEnumerator inner = g == "api" ? Api(field)
|
||
: g == "hooks" ? Hooks(field)
|
||
: g == "move" ? Move(field)
|
||
: g == "sentry" ? Sentry(field)
|
||
: g == "sleep" ? Sleep(field)
|
||
: g == "cmd" ? Cmd(field)
|
||
: g == "api3" ? Api3(field)
|
||
: g == "floor" ? Floor(field)
|
||
: g == "place" ? Place(field)
|
||
: g == "s5" ? Stage5()
|
||
: null;
|
||
if (inner == null)
|
||
{
|
||
Check("setup.group", false, "no group " + g);
|
||
continue;
|
||
}
|
||
|
||
while (inner.MoveNext()) yield return inner.Current;
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
ClearWorld();
|
||
yield return new WaitForSeconds(1f);
|
||
}
|
||
|
||
Note($"done {group}: {_pass} pass, {_fail} fail");
|
||
_running = null;
|
||
}
|
||
|
||
// ---- api: every call's answer ----
|
||
|
||
private IEnumerator Api(Vector3 field)
|
||
{
|
||
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 4, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
|
||
JObject profiles = Api<JObject>("RunicNpc_Profiles");
|
||
Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]);
|
||
Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]);
|
||
string warning = Api<string>("RunicNpc_CostWarning", 10);
|
||
Check("api.costWarning", warning != null && warning.Contains("NPC(s)"), warning);
|
||
|
||
BasePlayer npc = Spawn(field, "t_roam");
|
||
Check("api.spawn", npc != null, npc != null ? npc.GetType().Name : "null");
|
||
if (npc == null) yield break;
|
||
yield return new WaitForSeconds(2f);
|
||
|
||
Check("api.isRunicNpc", Api<bool>("RunicNpc_IsRunicNpc", npc), "");
|
||
Check("api.profileOf", Api<string>("RunicNpc_ProfileOf", npc) == "t_roam", Api<string>("RunicNpc_ProfileOf", npc));
|
||
Check("api.name", npc.displayName == "TRoam", npc.displayName);
|
||
Check("api.health", Mathf.Approximately(npc.health, 250f) && Mathf.Approximately(npc.MaxHealth(), 250f), $"{npc.health}/{npc.MaxHealth()}");
|
||
Check("api.notSaved", !npc.enableSaving, "enableSaving=" + npc.enableSaving);
|
||
Check("api.isNpc", npc.IsNpc, "IsNpc=" + npc.IsNpc);
|
||
HeldEntity held = npc.GetHeldEntity();
|
||
Check("api.kit", held != null && held.ShortPrefabName.Contains("revolver"), held != null ? held.ShortPrefabName : "nothing held");
|
||
Dictionary<string, object> d = Describe(npc);
|
||
Check("api.list", d != null && (string)d["owner"] == Owner && (string)d["movement"] == "wander", d != null ? $"{d["owner"]} {d["movement"]} {d["state"]}" : "not listed");
|
||
|
||
BasePlayer ov = Spawn(field + new Vector3(4, 0, 0), "t_roam", JObject.FromObject(new { health = 500f, names = new[] { "Ov" } }));
|
||
yield return new WaitForSeconds(0.5f);
|
||
Check("api.overrides", ov != null && ov.displayName == "Ov" && Mathf.Approximately(ov.MaxHealth(), 500f), ov != null ? $"{ov.displayName} {ov.MaxHealth()}" : "null");
|
||
|
||
Check("api.refuse.placementOwner", Spawn(field, "t_roam", null, "placement:x") == null, "");
|
||
Check("api.refuse.badOwner", Spawn(field, "t_roam", null, "nobody") == null, "");
|
||
Check("api.refuse.unknownProfile", Spawn(field, "nope") == null, "");
|
||
Check("api.refuse.refusedProfile", Spawn(field, "t_bad") == null, "");
|
||
Check("api.refuse.badOverride", Spawn(field, "t_roam", JObject.FromObject(new { health = -5f })) == null, "");
|
||
Check("api.refuse.offMesh", Spawn(field + Vector3.up * 40f, "t_roam") == null, "40 m in the air");
|
||
|
||
int listed = List(Owner).Count;
|
||
Check("api.listOwner", listed == 2, "listed " + listed);
|
||
Check("api.despawn", Api<int>("RunicNpc_Despawn", ov.net.ID.Value) == 1, "");
|
||
Check("api.despawnOwner", Api<int>("RunicNpc_DespawnOwner", Owner) == 1, "");
|
||
Check("api.gone", List(Owner).Count == 0, "listed " + List(Owner).Count);
|
||
}
|
||
|
||
// ---- hooks: spawned, health threshold with contributors, damage by body part, died, despawned ----
|
||
|
||
private IEnumerator Hooks(Vector3 field)
|
||
{
|
||
_spawned.Clear(); _died.Clear(); _health.Clear(); _despawned.Clear();
|
||
BasePlayer shooter = StandIn(field + new Vector3(0, 0, 60), 0);
|
||
BasePlayer npc = Spawn(field, "t_roam");
|
||
yield return new WaitForSeconds(1f);
|
||
Check("hooks.spawned", _spawned.Contains("t_roam/" + Owner), string.Join(",", _spawned.ToArray()));
|
||
|
||
// 100 bullet damage with no bone: body, scaled 0.5 → 50 (then Rust's own protection).
|
||
float before = npc.health;
|
||
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 100f));
|
||
float body = before - npc.health;
|
||
Check("hooks.damageTaken.body", body > 5f && body <= 50.5f, $"took {body:0.0} of 100 (body scale 0.5)");
|
||
|
||
// To below half: the 0.5 threshold fires, once.
|
||
while (npc.health / npc.MaxHealth() > 0.45f)
|
||
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 60f));
|
||
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 1f));
|
||
Check("hooks.health", _health.Count == 1 && _health[0] == "t_roam@0.5", string.Join(",", _health.ToArray()));
|
||
|
||
npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f));
|
||
yield return new WaitForSeconds(1f);
|
||
Check("hooks.died", _died.Contains("t_roam/" + Owner), string.Join(",", _died.ToArray()));
|
||
Check("hooks.contributors", _lastContributors.ContainsKey(StandInId) && _lastContributors[StandInId] > 240f && _lastContributors[StandInId] <= 250.5f,
|
||
string.Join(",", _lastContributors.Select(kv => kv.Key + "=" + kv.Value.ToString("0")).ToArray()));
|
||
Check("hooks.diedNotDespawned", _despawned.Count == 0, "despawned: " + string.Join(",", _despawned.ToArray()));
|
||
Check("hooks.unregisteredOnDeath", List(Owner).Count == 0, "listed " + List(Owner).Count);
|
||
|
||
BasePlayer second = Spawn(field, "t_roam");
|
||
yield return new WaitForSeconds(0.5f);
|
||
Api<int>("RunicNpc_Despawn", second.net.ID.Value);
|
||
Check("hooks.despawned", _despawned.Count == 1 && _despawned[0] == Owner, string.Join(",", _despawned.ToArray()));
|
||
}
|
||
|
||
// ---- move: wander stays within its radius, a route visits every point, a monument roams, a leash holds ----
|
||
|
||
private IEnumerator Move(Vector3 field)
|
||
{
|
||
// A stand-in 100 m off keeps them awake (inside 160 m) without being sensed (30 m).
|
||
StandIn(field + new Vector3(0, 0, 100), 1);
|
||
|
||
BasePlayer wander = Spawn(field, "t_roam");
|
||
BasePlayer route = Spawn(field + new Vector3(60, 0, 0), "t_route");
|
||
Vector3 monument;
|
||
string monumentName;
|
||
BasePlayer mon = null;
|
||
if (FindMonument(out monument, out monumentName))
|
||
{
|
||
StandIn(monument + new Vector3(0, 0, 100), 2);
|
||
mon = Spawn(monument, "t_mon");
|
||
}
|
||
|
||
Vector3 home = wander.transform.position;
|
||
Vector3 monHome = mon != null ? mon.transform.position : Vector3.zero;
|
||
float wanderMax = 0f, wanderMoved = 0f, monMax = 0f;
|
||
Route loop = JObject.FromObject(Api<JObject>("RunicNpc_Routes")["t_loop"]).ToObject<Route>();
|
||
var reached = new bool[loop.points.Count];
|
||
var order = new List<int>();
|
||
|
||
for (int t = 0; t < 90; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
if (wander != null && !wander.IsDestroyed)
|
||
{
|
||
float d = Flat(wander.transform.position, home);
|
||
wanderMax = Mathf.Max(wanderMax, d);
|
||
wanderMoved = Mathf.Max(wanderMoved, d);
|
||
}
|
||
|
||
if (route != null && !route.IsDestroyed)
|
||
for (int i = 0; i < loop.points.Count; i++)
|
||
if (Flat(route.transform.position, loop.points[i].V) < 2.5f)
|
||
{
|
||
reached[i] = true;
|
||
if (order.Count == 0 || order[order.Count - 1] != i) order.Add(i);
|
||
}
|
||
|
||
if (mon != null && !mon.IsDestroyed)
|
||
monMax = Mathf.Max(monMax, Flat(mon.transform.position, monHome));
|
||
}
|
||
|
||
Check("move.wander.moves", wanderMoved > 2f, $"furthest {wanderMoved:0.0} m from its spot");
|
||
Check("move.wander.radius", wanderMax <= 15f + 4f, $"furthest {wanderMax:0.0} m, radius 15");
|
||
Check("move.route.everyPoint", reached.All(x => x), "reached " + string.Join("", reached.Select(x => x ? "1" : "0").ToArray()) + " order " + string.Join(">", order.Select(x => x.ToString()).ToArray()));
|
||
bool inOrder = true;
|
||
for (int i = 1; i < order.Count; i++)
|
||
if (order[i] != (order[i - 1] + 1) % loop.points.Count) inOrder = false;
|
||
Check("move.route.inOrder", order.Count >= 3 && inOrder, string.Join(">", order.Select(x => x.ToString()).ToArray()));
|
||
if (mon != null)
|
||
Check("move.monument.roams", monMax > 5f, $"{monumentName}: furthest {monMax:0.0} m (Rust's own roam)");
|
||
else
|
||
Check("move.monument.roams", false, "no monument with an AI zone");
|
||
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
RemoveStandIns();
|
||
|
||
// The leash: chase 10 m from home. A stand-in 25 m off draws it out; past 10 m it must turn back.
|
||
BasePlayer leash = Spawn(field, "t_leash");
|
||
yield return new WaitForSeconds(1f);
|
||
Vector3 leashHome = leash.transform.position;
|
||
StandIn(leashHome + new Vector3(25, 0, 0), 3);
|
||
float leashMax = 0f;
|
||
for (int t = 0; t < 40; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
if (leash == null || leash.IsDestroyed) break;
|
||
leashMax = Mathf.Max(leashMax, Flat(leash.transform.position, leashHome));
|
||
}
|
||
|
||
Dictionary<string, object> d2 = Describe(leash);
|
||
int leashes = d2 != null ? Convert.ToInt32(d2["leashes"]) : -1;
|
||
Check("move.chase.pursues", leashMax >= 6f, $"went {leashMax:0.0} m toward a target 25 m off (attack 4, chase 10)");
|
||
Check("move.chase.bounded", leashMax <= 10f + 4f, $"furthest {leashMax:0.0} m, chase 10");
|
||
Check("move.leash.givesUp", leashes >= 1, $"gave up {leashes} time(s) on a target it could not reach");
|
||
}
|
||
|
||
// ---- sentry: holds its spot, shoots, and may stand off the navmesh ----
|
||
|
||
private IEnumerator Sentry(Vector3 field)
|
||
{
|
||
BasePlayer sentry = Spawn(field, "t_sentry");
|
||
yield return new WaitForSeconds(1f);
|
||
Vector3 spot = sentry.transform.position;
|
||
_hitsOnStandIns = 0;
|
||
StandIn(spot + new Vector3(12, 0, 0), 4);
|
||
float moved = 0f;
|
||
for (int t = 0; t < 40; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
moved = Mathf.Max(moved, Flat(sentry.transform.position, spot));
|
||
}
|
||
|
||
Check("sentry.holds", moved < 1f, $"moved {moved:0.00} m in 40 s with a target 12 m off");
|
||
Check("sentry.shoots", _hitsOnStandIns > 0, $"{_hitsOnStandIns} hit(s) on the stand-in");
|
||
Dictionary<string, object> d = Describe(sentry);
|
||
Note("sentry state: " + (d != null ? d["state"] : "?"));
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
RemoveStandIns();
|
||
|
||
// A player-built floor 4 m up, then a spot truly off the navmesh.
|
||
BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", field + new Vector3(0, 4f, 20f));
|
||
if (floor == null)
|
||
{
|
||
Check("sentry.offMesh.floor", false, "the floor prefab made nothing");
|
||
yield break;
|
||
}
|
||
|
||
try
|
||
{
|
||
floor.enableSaving = false;
|
||
// An unsupported floor collapses at once under Rust's stability; grounded, it stays up.
|
||
StabilityEntity stable = floor as StabilityEntity;
|
||
if (stable != null) stable.grounded = true;
|
||
floor.Spawn();
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Check("sentry.offMesh.floor", false, e.ToString());
|
||
yield break;
|
||
}
|
||
|
||
_built.Add(floor);
|
||
|
||
// Stage 1 read a new floor as off the navmesh, sampling in the frame it spawned. How long
|
||
// does Rust's navmesh take to cover it?
|
||
Vector3 top = floor.transform.position + Vector3.up * 0.1f;
|
||
float spawnedAt = Time.realtimeSinceStartup;
|
||
bool covered = false;
|
||
int frames = 0;
|
||
while (!covered && Time.realtimeSinceStartup - spawnedAt < 10f)
|
||
{
|
||
NavMeshHit onTop;
|
||
covered = SampleNavmesh(top, 1f, out onTop) && Mathf.Abs(onTop.position.y - top.y) < 0.6f;
|
||
if (!covered)
|
||
{
|
||
frames++;
|
||
yield return null;
|
||
}
|
||
}
|
||
|
||
if (floor == null || floor.IsDestroyed)
|
||
{
|
||
Check("sentry.floor", false, "the floor did not stay up");
|
||
yield break;
|
||
}
|
||
|
||
Check("sentry.floor.meshCovers", covered, covered
|
||
? $"the navmesh covered a new floor 4 m up after {Time.realtimeSinceStartup - spawnedAt:0.00} s ({frames} frames)"
|
||
: "not covered within 10 s");
|
||
BasePlayer roamer = Spawn(top, "t_roam");
|
||
Check("sentry.floor.roamerStandsOnIt", !covered || (roamer != null && Mathf.Abs(roamer.transform.position.y - top.y) < 0.6f),
|
||
roamer != null ? $"a roamer placed at y={roamer.transform.position.y:0.00}, floor {top.y:0.00}" : "refused");
|
||
if (roamer != null) Api<int>("RunicNpc_Despawn", roamer.net.ID.Value);
|
||
|
||
// Truly off the navmesh: 40 m up. A roamer is refused; a sentry is placed and stays.
|
||
Vector3 air = field + new Vector3(0f, 40f, -20f);
|
||
Check("sentry.offMesh.roamerRefused", Spawn(air, "t_roam") == null, "40 m up");
|
||
BasePlayer up = Spawn(air, "t_sentry");
|
||
Check("sentry.offMesh.sentryPlaced", up != null, "");
|
||
if (up != null)
|
||
{
|
||
StandIn(field + new Vector3(0, 0, 100), 5);
|
||
yield return new WaitForSeconds(20f);
|
||
bool there = up != null && !up.IsDestroyed && Vector3.Distance(up.transform.position, air) < 1.5f;
|
||
Check("sentry.offMesh.stays", there, up != null && !up.IsDestroyed ? $"{Vector3.Distance(up.transform.position, air):0.00} m from its spot after 20 s" : "gone");
|
||
}
|
||
}
|
||
|
||
// ---- sleep: walk home past 160 m of any player, then sleep; wake when one comes back ----
|
||
|
||
private IEnumerator Sleep(Vector3 field)
|
||
{
|
||
BasePlayer keeper = StandIn(field + new Vector3(0, 0, 100), 6);
|
||
BasePlayer npc = Spawn(field, "t_roam", JObject.FromObject(new { movement = new { mode = "wander", radius = 30f } }));
|
||
yield return new WaitForSeconds(1f);
|
||
Vector3 home = npc.transform.position;
|
||
|
||
// Let it wander off its spot.
|
||
float away = 0f;
|
||
for (int t = 0; t < 60 && away < 8f; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
away = Flat(npc.transform.position, home);
|
||
}
|
||
|
||
Check("sleep.awayFirst", away >= 3f, $"{away:0.0} m from home before the players leave");
|
||
keeper.Kill();
|
||
_standIns.Remove(keeper);
|
||
|
||
string rest = "";
|
||
bool sawGoingHome = false;
|
||
float maxStep = 0f;
|
||
Vector3 last = npc.transform.position;
|
||
for (int t = 0; t < 90; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
maxStep = Mathf.Max(maxStep, Flat(npc.transform.position, last));
|
||
last = npc.transform.position;
|
||
rest = (string)Describe(npc)["rest"];
|
||
if (rest == "GoingHome") sawGoingHome = true;
|
||
if (rest == "Asleep") break;
|
||
}
|
||
|
||
float fromHome = Flat(npc.transform.position, home);
|
||
Check("sleep.walksHome", sawGoingHome || away < 3f, "saw GoingHome=" + sawGoingHome);
|
||
Check("sleep.asleep", rest == "Asleep", "rest=" + rest);
|
||
Check("sleep.atHome", fromHome <= 3f, $"{fromHome:0.0} m from home");
|
||
Check("sleep.walkedNotTeleported", maxStep < 8f, $"largest step {maxStep:0.0} m per second");
|
||
|
||
// Still for 10 s asleep.
|
||
Vector3 sleptAt = npc.transform.position;
|
||
yield return new WaitForSeconds(10f);
|
||
Check("sleep.still", Flat(npc.transform.position, sleptAt) < 0.5f, "");
|
||
|
||
StandIn(field + new Vector3(0, 0, 100), 7);
|
||
yield return new WaitForSeconds(4f);
|
||
rest = (string)Describe(npc)["rest"];
|
||
Check("sleep.wakes", rest == "Awake", "rest=" + rest);
|
||
}
|
||
|
||
// ---- place: each and group respawn, a missing profile waits, a missing route waits ----
|
||
|
||
private IEnumerator Place(Vector3 field)
|
||
{
|
||
Vector3 a = field + new Vector3(-40, 0, 0);
|
||
Vector3 b = field + new Vector3(-40, 0, 30);
|
||
StandIn(field + new Vector3(-40, 0, 110), 8);
|
||
Check("place.set.each", Api<string>("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each")) == null, "");
|
||
Check("place.set.group", Api<string>("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group")) == null, "");
|
||
yield return new WaitForSeconds(4f);
|
||
|
||
List<Dictionary<string, object>> each = List("placement:t_each");
|
||
List<Dictionary<string, object>> group = List("placement:t_group");
|
||
Check("place.spawned", each.Count == 3 && group.Count == 3, $"each {each.Count}, group {group.Count}");
|
||
|
||
// each: kill one; the other two stay, and it alone returns after ~20 s.
|
||
var survivors = new HashSet<ulong>(each.Skip(1).Select(x => (ulong)x["netId"]));
|
||
Kill((ulong)each[0]["netId"]);
|
||
yield return new WaitForSeconds(2f);
|
||
Check("place.each.down", List("placement:t_each").Count == 2, "" + List("placement:t_each").Count);
|
||
yield return new WaitForSeconds(22f);
|
||
List<Dictionary<string, object>> after = List("placement:t_each");
|
||
Check("place.each.back", after.Count == 3 && survivors.All(id => after.Any(x => (ulong)x["netId"] == id)), $"{after.Count} alive, survivors kept={survivors.All(id => after.Any(x => (ulong)x["netId"] == id))}");
|
||
|
||
// group: kill one; nothing returns while two live. Kill the rest; all three return together.
|
||
Kill((ulong)group[0]["netId"]);
|
||
yield return new WaitForSeconds(24f);
|
||
Check("place.group.waitsForAll", List("placement:t_group").Count == 2, "" + List("placement:t_group").Count);
|
||
foreach (Dictionary<string, object> x in List("placement:t_group")) Kill((ulong)x["netId"]);
|
||
yield return new WaitForSeconds(10f);
|
||
Check("place.group.notEarly", List("placement:t_group").Count == 0, "" + List("placement:t_group").Count);
|
||
yield return new WaitForSeconds(14f);
|
||
Check("place.group.back", List("placement:t_group").Count == 3, "" + List("placement:t_group").Count);
|
||
|
||
// D237: remove t_each's profile. Its NPCs despawn and the placement waits; restore it and they return.
|
||
SetTestProfiles(withEach: false);
|
||
yield return new WaitForSeconds(2f);
|
||
Dictionary<string, object> row = Row("t_each");
|
||
Check("place.missingProfile.waits", List("placement:t_each").Count == 0 && row != null && row["waiting"] != null, row != null ? "" + row["waiting"] : "no row");
|
||
SetTestProfiles();
|
||
yield return new WaitForSeconds(4f);
|
||
Check("place.missingProfile.returns", List("placement:t_each").Count == 3, "" + List("placement:t_each").Count);
|
||
|
||
JObject waitRoute = Placement("t_roam", a, 1, 20f, "each");
|
||
waitRoute["movement"] = JObject.FromObject(new { mode = "route:t_nope", radius = 0f });
|
||
Check("place.set.routeOverride", Api<string>("RunicNpc_SetPlacement", "t_waitroute", waitRoute) == null, "");
|
||
yield return new WaitForSeconds(2f);
|
||
row = Row("t_waitroute");
|
||
Check("place.missingRoute.waits", row != null && ("" + row["waiting"]).Contains("route"), row != null ? "" + row["waiting"] : "no row");
|
||
Api<bool>("RunicNpc_RemovePlacement", "t_waitroute");
|
||
|
||
Check("place.remove", Api<bool>("RunicNpc_RemovePlacement", "t_group") && List("placement:t_group").Count == 0, "");
|
||
Check("place.refuse.badRespawnMode", Api<string>("RunicNpc_SetPlacement", "t_x", Placement("t_roam", a, 1, 20f, "sometimes")) != null, "");
|
||
|
||
// Leave two for rnt.after, across a reload or a restart.
|
||
Api<bool>("RunicNpc_RemovePlacement", "t_each");
|
||
Api<string>("RunicNpc_SetPlacement", "t_keep1", Placement("t_roam", a, 2, 30f, "each"));
|
||
Api<string>("RunicNpc_SetPlacement", "t_keep2", Placement("t_sentry", b, 1, 30f, "each"));
|
||
Note("left placements t_keep1 (2) and t_keep2 (1) for rnt.after");
|
||
}
|
||
|
||
// ---- cmd: the stage 3 commands, through the server console and the chat path ----
|
||
|
||
private static string F(float f) => f.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
|
||
|
||
private static string At(Vector3 v) => $"at={F(v.x)},{F(v.y)},{F(v.z)}";
|
||
|
||
/// <summary>Runs a RunicNPC console command as the server console and returns its answer.</summary>
|
||
private string Rnpc(string command)
|
||
{
|
||
string reply = ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), command) ?? "";
|
||
Note($"> {command} => {reply.Replace(Environment.NewLine, " | ")}");
|
||
return reply;
|
||
}
|
||
|
||
private JObject PlacementData(string id)
|
||
{
|
||
Dictionary<string, object> row = Row(id);
|
||
return row != null ? (JObject)row["placement"] : null;
|
||
}
|
||
|
||
private IEnumerator Cmd(Vector3 field)
|
||
{
|
||
Vector3 a = OnMesh(field + new Vector3(40, 0, -40));
|
||
Vector3 b = OnMesh(field + new Vector3(40, 0, -10));
|
||
Vector3 air = a + Vector3.up * 40f;
|
||
|
||
// A stand-in 60-130 m from every spot keeps the NPCs awake (D235) without being sensed.
|
||
StandIn(OnMesh(field + new Vector3(60, 0, 90)), 21);
|
||
|
||
Check("cmd.help", Rnpc("rnpc.help").Contains("rnpc.path record"), "");
|
||
Check("cmd.status", Rnpc("rnpc.status").Contains("placements="), "");
|
||
|
||
// D242 options and D241 names.
|
||
string r = Rnpc($"rnpc.place t_roam {At(a)} count=2 respawn=45 mode=group move=wander radius=8");
|
||
JObject p1 = PlacementData("t_roam-1");
|
||
Check("cmd.place.named", r.Contains("Placed t_roam-1") && p1 != null, "");
|
||
Check("cmd.place.options", p1 != null && (int)p1["count"] == 2 && (float)p1["respawn"] == 45f && (string)p1["respawnMode"] == "group" &&
|
||
(string)p1["movement"]?["mode"] == "wander" && (float)p1["movement"]?["radius"] == 8f, "" + p1?.ToString(Formatting.None));
|
||
Check("cmd.place.costWarning", r.Contains("NPC(s) on this server"), "");
|
||
r = Rnpc($"rnpc.place t_roam {At(b)} respawn=20");
|
||
Check("cmd.place.nextName", r.Contains("Placed t_roam-2") && PlacementData("t_roam-2") != null, "");
|
||
JObject p2 = PlacementData("t_roam-2");
|
||
Check("cmd.place.defaults", p2 != null && (int)p2["count"] == 1 && (string)p2["respawnMode"] == "each" && p2["movement"] == null, "" + p2?.ToString(Formatting.None));
|
||
|
||
// Refusals, each with its reason; none may leave a placement behind.
|
||
int before = (Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).Count;
|
||
Check("cmd.refuse.unknownProfile", Rnpc($"rnpc.place nope {At(a)}").Contains("no profile"), "");
|
||
Check("cmd.refuse.refusedProfile", Rnpc($"rnpc.place t_bad {At(a)}").Contains("refused"), "");
|
||
Check("cmd.refuse.unknownOption", Rnpc($"rnpc.place t_roam {At(a)} speed=3").Contains("no option speed="), "");
|
||
Check("cmd.refuse.notKeyValue", Rnpc($"rnpc.place t_roam {At(a)} 3").Contains("not key=value"), "");
|
||
Check("cmd.refuse.count", Rnpc($"rnpc.place t_roam {At(a)} count=0").Contains("count="), "");
|
||
Check("cmd.refuse.twice", Rnpc($"rnpc.place t_roam {At(a)} count=1 count=2").Contains("twice"), "");
|
||
Check("cmd.refuse.mode", Rnpc($"rnpc.place t_roam {At(a)} mode=sometimes").Contains("each or group"), "");
|
||
Check("cmd.refuse.missingRoute", Rnpc($"rnpc.place t_roam {At(a)} move=route:t_none").Contains("no route 't_none'"), "");
|
||
Check("cmd.refuse.sentryMoves", Rnpc($"rnpc.place t_sentry {At(a)} move=wander").Contains("sentry"), "");
|
||
Check("cmd.refuse.offMesh", Rnpc($"rnpc.place t_roam {At(air)}").Contains("m above the"), "");
|
||
Check("cmd.refuse.noSpot", Rnpc("rnpc.place t_roam").Contains("at=x,y,z"), "");
|
||
Check("cmd.refuse.leftNothing", (Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).Count == before, "");
|
||
Check("cmd.sentryOffMesh", Rnpc($"rnpc.place t_sentry {At(air)}").Contains("Placed t_sentry-1"), "a sentry may stand 40 m up");
|
||
Rnpc("rnpc.remove t_sentry-1");
|
||
Check("cmd.refuse.api.offMesh", Api<string>("RunicNpc_SetPlacement", "t_air", Placement("t_roam", air, 1, 20f, "each")) != null, "the API refuses a roamer off the navmesh too");
|
||
|
||
yield return new WaitForSeconds(4f);
|
||
Check("cmd.place.spawned", List("placement:t_roam-1").Count == 2 && List("placement:t_roam-2").Count == 1,
|
||
$"t_roam-1 {List("placement:t_roam-1").Count}, t_roam-2 {List("placement:t_roam-2").Count}");
|
||
|
||
// Rename (D241): the NPC keeps living and changes owner with it.
|
||
ulong renamedNpc = List("placement:t_roam-2").Select(d => (ulong)d["netId"]).FirstOrDefault();
|
||
r = Rnpc("rnpc.rename t_roam-2 t_gate");
|
||
List<Dictionary<string, object>> gate = List("placement:t_gate");
|
||
Check("cmd.rename", r.Contains("Renamed") && PlacementData("t_gate") != null && PlacementData("t_roam-2") == null, "");
|
||
Check("cmd.rename.keepsNpc", gate.Count == 1 && (ulong)gate[0]["netId"] == renamedNpc && (string)gate[0]["placement"] == "t_gate", "");
|
||
Check("cmd.rename.refuseTaken", Rnpc("rnpc.rename t_gate t_roam-1").Contains("taken"), "");
|
||
Check("cmd.rename.refuseBadName", Rnpc("rnpc.rename t_gate Bad!Name").Contains("1–40"), "");
|
||
Check("cmd.place.nameReused", Rnpc($"rnpc.place t_roam {At(b)}").Contains("Placed t_roam-2"), "the freed number is taken again");
|
||
Rnpc("rnpc.remove t_roam-2");
|
||
|
||
// Recording a route (D240), from the console with at=.
|
||
// The hill route a first run recorded (points 12-17 m up): the patrol never left its first point.
|
||
// Recorded again, to note what the reachability check now answers.
|
||
Rnpc("rnpc.path record t_hill");
|
||
foreach (Vector3 h in new[] { new Vector3(70, 0, -40), new Vector3(90, 0, -40), new Vector3(90, 0, -20) })
|
||
Rnpc($"rnpc.path point {At(OnMesh(field + h))}");
|
||
Rnpc("rnpc.path cancel");
|
||
|
||
Check("cmd.path.pointFirst", Rnpc($"rnpc.path point {At(a)}").Contains("Nothing is being recorded"), "");
|
||
Check("cmd.path.record", Rnpc("rnpc.path record t_rec").Contains("Recording 't_rec'"), "");
|
||
Vector3[] pts = { OnMesh(field + new Vector3(-25, 0, -25)), OnMesh(field + new Vector3(-5, 0, -25)), OnMesh(field + new Vector3(-5, 0, -5)), OnMesh(field + new Vector3(-25, 0, -5)) };
|
||
Note("route points y: " + string.Join(", ", pts.Select(v => v.y.ToString("0.0")).ToArray()));
|
||
Check("cmd.path.point1", Rnpc($"rnpc.path point {At(pts[0])}").Contains("Point 1"), "");
|
||
Check("cmd.path.saveTooFew", Rnpc("rnpc.path save").Contains("at least two"), "");
|
||
Check("cmd.path.pointOffMesh", Rnpc($"rnpc.path point {At(pts[0] + Vector3.up * 30f)}").Contains("Refused"), "");
|
||
Rnpc($"rnpc.path point {At(pts[1])}");
|
||
Rnpc($"rnpc.path point {At(pts[3])}");
|
||
Check("cmd.path.undo", Rnpc("rnpc.path undo").Contains("2 left"), "");
|
||
Rnpc($"rnpc.path point {At(pts[2])}");
|
||
Check("cmd.path.saveBadHow", Rnpc("rnpc.path save sideways").Contains("loop|back"), "");
|
||
r = Rnpc("rnpc.path save back");
|
||
JObject routes = Api<JObject>("RunicNpc_Routes");
|
||
JToken rec = routes?["t_rec"];
|
||
Check("cmd.path.saved", r.Contains("Saved 't_rec': 3 points") && rec != null && ((JArray)rec["points"]).Count == 3 && !(bool)rec["loop"], "" + rec?.ToString(Formatting.None));
|
||
Check("cmd.path.list", Rnpc("rnpc.path list").Contains("t_rec · 3 points"), "");
|
||
|
||
// A patrol on it.
|
||
r = Rnpc($"rnpc.place t_roam {At(pts[0])} move=route:t_rec respawn=20");
|
||
Check("cmd.patrol.placed", r.Contains("Placed t_roam-2"), "");
|
||
for (int s = 0; s < 4; s++)
|
||
{
|
||
yield return new WaitForSeconds(3f);
|
||
List<Dictionary<string, object>> seen = List("placement:t_roam-2");
|
||
Note(seen.Count == 1 ? $"patrol +{3 * (s + 1)} s: {Flat(V(seen[0]["position"]), pts[0]):0.0} m from point 0, state={seen[0]["state"]} rest={seen[0]["rest"]}" : "patrol: " + seen.Count + " listed");
|
||
}
|
||
List<Dictionary<string, object>> patrol = List("placement:t_roam-2");
|
||
Check("cmd.patrol.walks", patrol.Count == 1 && (string)patrol[0]["movement"] == "route:t_rec" && Flat(V(patrol[0]["position"]), pts[0]) > 3f,
|
||
patrol.Count == 1 ? $"{patrol[0]["movement"]}, {Flat(V(patrol[0]["position"]), pts[0]):0.0} m from the first point" : "not spawned");
|
||
r = Rnpc("rnpc.path delete t_rec");
|
||
yield return new WaitForSeconds(2f);
|
||
Dictionary<string, object> row = Row("t_roam-2");
|
||
Check("cmd.path.deleteWaits", r.Contains("1 placement(s) on it now wait") && row != null && ("" + row["waiting"]).Contains("t_rec") && List("placement:t_roam-2").Count == 0,
|
||
row != null ? "" + row["waiting"] : "no row");
|
||
Rnpc("rnpc.remove t_roam-2");
|
||
|
||
// respawn, clear, remove.
|
||
HashSet<ulong> old = new HashSet<ulong>(List("placement:t_roam-1").Select(d => (ulong)d["netId"]));
|
||
r = Rnpc("rnpc.respawn t_roam-1");
|
||
yield return new WaitForSeconds(3f);
|
||
List<Dictionary<string, object>> fresh = List("placement:t_roam-1");
|
||
Check("cmd.respawn", r.Contains("Respawning 2") && fresh.Count == 2 && fresh.All(d => !old.Contains((ulong)d["netId"])), $"{fresh.Count} alive, fresh={fresh.All(d => !old.Contains((ulong)d["netId"]))}");
|
||
r = Rnpc("rnpc.clear placement:t_gate");
|
||
Check("cmd.clear", r.Contains("Removed 1") && List("placement:t_gate").Count == 0 && PlacementData("t_gate") != null, "the NPC goes, the placement stays");
|
||
Check("cmd.clear.refuseBadOwner", Rnpc("rnpc.clear nobody").Contains("Usage"), "");
|
||
r = Rnpc("rnpc.remove t_gate");
|
||
Check("cmd.remove", r.Contains("Removed t_gate") && PlacementData("t_gate") == null, "");
|
||
Check("cmd.remove.unknown", Rnpc("rnpc.remove t_gate").Contains("no placement"), "");
|
||
Check("cmd.near.console", Rnpc("rnpc.near").Contains("server console"), "");
|
||
Check("cmd.info.console", Rnpc("rnpc.info").Contains("in game"), "");
|
||
|
||
// The chat path, as a stand-in player: nothing without a permission, then place where it stands.
|
||
BasePlayer admin = StandIn(b, 20);
|
||
string uid = admin.UserIDString;
|
||
RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "here", "t_roam" });
|
||
Check("cmd.chat.needsPermission", PlacementData("t_roam-2") == null, "");
|
||
permission.GrantUserPermission(uid, "runicnpc.place", null);
|
||
RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "here", "t_roam", "count=1" });
|
||
JObject chatPlaced = PlacementData("t_roam-2");
|
||
Check("cmd.chat.here", chatPlaced != null && Flat(V(chatPlaced["position"].ToObject<Dictionary<string, object>>()), b) < 1f, "" + chatPlaced?.ToString(Formatting.None));
|
||
RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "respawn", "t_roam-2" });
|
||
Check("cmd.chat.adminNeedsAdmin", Row("t_roam-2") != null, "respawn is refused with runicnpc.place alone (no error)");
|
||
RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "remove", "t_roam-2" });
|
||
Check("cmd.chat.remove", PlacementData("t_roam-2") == null, "");
|
||
permission.RevokeUserPermission(uid, "runicnpc.place");
|
||
admin.Kill();
|
||
_standIns.Remove(admin);
|
||
|
||
// Standalone profile edits (D221): refused while managed, then allowed once the file says it is not.
|
||
Check("cmd.profile.managed", Rnpc("rnpc.profile create t_new").Contains("managed"), "");
|
||
Check("cmd.profile.show", Rnpc("rnpc.profile show t_roam").Contains("\"names\""), "");
|
||
JObject file = Api<JObject>("RunicNpc_Profiles");
|
||
file.Remove("refused");
|
||
file["managed"] = false;
|
||
Interface.Oxide.DataFileSystem.WriteObject("RunicNPC/profiles", file);
|
||
Rnpc("rnpc.reload");
|
||
Check("cmd.profile.create", Rnpc("rnpc.profile create t_new from=t_roam").Contains("in use"), "");
|
||
Rnpc("rnpc.profile set t_new health 321");
|
||
Rnpc("rnpc.profile set t_new names Old Jim,Bob");
|
||
Rnpc("rnpc.profile set t_new ranges.sense 44");
|
||
JToken made = Api<JObject>("RunicNpc_Profiles")?["profiles"]?["t_new"];
|
||
Check("cmd.profile.set", made != null && (float)made["health"] == 321f && (float)made["ranges"]["sense"] == 44f &&
|
||
made["names"].ToObject<string[]>().SequenceEqual(new[] { "Old Jim", "Bob" }), "" + made?.ToString(Formatting.None));
|
||
Check("cmd.profile.set.noField", Rnpc("rnpc.profile set t_new nope 1").Contains("no field"), "");
|
||
Check("cmd.profile.set.object", Rnpc("rnpc.profile set t_new ranges 1").Contains("fields of its own"), "");
|
||
Check("cmd.profile.set.refusedKit", Rnpc("rnpc.profile set t_new kits rnt_nokit").Contains("cannot spawn yet"), "");
|
||
Check("cmd.profile.delete", Rnpc("rnpc.profile delete t_new").Contains("Deleted") && Api<JObject>("RunicNpc_Profiles")?["profiles"]?["t_new"] == null, "");
|
||
SetTestProfiles();
|
||
|
||
IEnumerator floorTest = Floor(field);
|
||
while (floorTest.MoveNext()) yield return floorTest.Current;
|
||
}
|
||
|
||
/// <summary>
|
||
/// D239: a roamer placed on a player-built floor is told so. The floor is then destroyed under
|
||
/// a live NPC (what happens to it is measured, not asserted), and after its death the placement
|
||
/// falls back to the nearest navmesh until the floor returns.
|
||
/// </summary>
|
||
// ---- api3: stage 4's calls (D249) — a placement made from a map point, named as in game ----
|
||
|
||
private Dictionary<string, object> Add(object placement) =>
|
||
Api<Dictionary<string, object>>("RunicNpc_AddPlacement", JObject.FromObject(placement)) ?? new Dictionary<string, object> { ["error"] = "no answer" };
|
||
|
||
private static string Said(Dictionary<string, object> d) =>
|
||
d.ContainsKey("error") ? "error: " + d["error"] : "id " + d["id"] + (d.ContainsKey("position") ? " at " + V(d["position"]) : "");
|
||
|
||
private IEnumerator Api3(Vector3 field)
|
||
{
|
||
_changed.Clear();
|
||
Vector3 a = OnMesh(field + new Vector3(-40, 0, 40));
|
||
StandIn(OnMesh(field + new Vector3(-40, 0, 140)), 31);
|
||
|
||
// A map point: x and z only. RunicNPC grounds it and names it like rnpc place (D241, D246).
|
||
Dictionary<string, object> r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, count = 2, respawn = 30f, respawnMode = "group" });
|
||
Check("api3.add.named", !r.ContainsKey("error") && (string)r["id"] == "t_roam-1", Said(r));
|
||
Vector3 put = r.ContainsKey("position") ? V(r["position"]) : Vector3.zero;
|
||
float ground = TerrainMeta.HeightMap.GetHeight(a);
|
||
Check("api3.add.grounded", r.ContainsKey("position") && Mathf.Abs(put.y - ground) < 1.5f, $"y {put.y:0.00}, terrain {ground:0.00}");
|
||
Check("api3.add.cost", r.ContainsKey("cost") && ((string)r["cost"]).Contains("NPC(s) on this server"), r.ContainsKey("cost") ? (string)r["cost"] : "");
|
||
Check("api3.add.notBuilt", r.ContainsKey("built") && !(bool)r["built"], "");
|
||
JObject data = PlacementData("t_roam-1");
|
||
Check("api3.add.values", data != null && (int)data["count"] == 2 && (string)data["respawnMode"] == "group", "" + data?.ToString(Formatting.None));
|
||
Check("api3.hook.set", _changed.Contains("set:t_roam-1"), string.Join(",", _changed.ToArray()));
|
||
|
||
r = Add(new { profile = "t_roam", position = new { x = a.x + 6f, z = a.z } });
|
||
Check("api3.add.nextName", !r.ContainsKey("error") && (string)r["id"] == "t_roam-2", Said(r));
|
||
|
||
// Refusals, each with the in-game reason; none leaves a placement behind.
|
||
int before = (Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).Count;
|
||
float edge = World.Size / 2f + 50f;
|
||
r = Add(new { profile = "t_roam", position = new { x = edge, z = 0f } });
|
||
Check("api3.refuse.offMap", r.ContainsKey("error") && ((string)r["error"]).Contains("off the map"), Said(r));
|
||
r = Add(new { profile = "t_roam", position = new { x = a.x, y = a.y + 40f, z = a.z } });
|
||
Check("api3.refuse.offMesh", r.ContainsKey("error") && ((string)r["error"]).Contains("m above the"), Said(r));
|
||
r = Add(new { profile = "nope", position = new { x = a.x, z = a.z } });
|
||
Check("api3.refuse.unknownProfile", r.ContainsKey("error") && ((string)r["error"]).Contains("no profile"), Said(r));
|
||
r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, count = 0 });
|
||
Check("api3.refuse.count", r.ContainsKey("error") && ((string)r["error"]).Contains("count"), Said(r));
|
||
r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, movement = new { mode = "route:t_none", radius = 0f } });
|
||
Check("api3.refuse.missingRoute", r.ContainsKey("error") && ((string)r["error"]).Contains("no route 't_none'"), Said(r));
|
||
Vector3 sea;
|
||
if (FindWater(out sea))
|
||
{
|
||
r = Add(new { profile = "t_roam", position = new { x = sea.x, z = sea.z } });
|
||
Check("api3.refuse.water", r.ContainsKey("error") && ((string)r["error"]).Contains("under water"), Said(r) + $" at {sea}");
|
||
}
|
||
else
|
||
Note("api3.refuse.water skipped: no deep water found");
|
||
Check("api3.refuse.leftNothing", (Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).Count == before, "");
|
||
|
||
yield return new WaitForSeconds(4f);
|
||
Check("api3.spawned", List("placement:t_roam-1").Count == 2, "listed " + List("placement:t_roam-1").Count);
|
||
|
||
// Rename and respawn, as rnpc rename and rnpc respawn.
|
||
_changed.Clear();
|
||
string renamed = Api<string>("RunicNpc_RenamePlacement", "t_roam-2", "t_site");
|
||
Check("api3.rename", renamed == null && PlacementData("t_site") != null && PlacementData("t_roam-2") == null, renamed ?? "");
|
||
Check("api3.hook.renamed", _changed.Contains("renamed:t_roam-2>t_site"), string.Join(",", _changed.ToArray()));
|
||
string taken = Api<string>("RunicNpc_RenamePlacement", "t_site", "t_roam-1");
|
||
Check("api3.rename.refuseTaken", taken != null && taken.Contains("taken"), taken ?? "null");
|
||
ulong first = List("placement:t_roam-1").Select(d => (ulong)d["netId"]).FirstOrDefault();
|
||
int respawned = Api<int>("RunicNpc_RespawnPlacement", "t_roam-1");
|
||
Check("api3.respawn", respawned == 2, "respawned " + respawned);
|
||
Check("api3.respawn.missing", Api<int>("RunicNpc_RespawnPlacement", "t_nothing") == -1, "");
|
||
yield return new WaitForSeconds(3f);
|
||
List<Dictionary<string, object>> after = List("placement:t_roam-1");
|
||
Check("api3.respawn.fresh", after.Count == 2 && after.All(d => (ulong)d["netId"] != first), $"listed {after.Count}");
|
||
|
||
// The hook for every change made in game too.
|
||
_changed.Clear();
|
||
Rnpc($"rnpc.place t_roam {At(OnMesh(field + new Vector3(-30, 0, 40)))}");
|
||
Rnpc("rnpc.remove t_roam-2");
|
||
Check("api3.hook.inGame", _changed.Contains("set:t_roam-2") && _changed.Contains("removed:t_roam-2"), string.Join(",", _changed.ToArray()));
|
||
Api<bool>("RunicNpc_RemovePlacement", "t_site");
|
||
Check("api3.hook.removed", _changed.Contains("removed:t_site"), string.Join(",", _changed.ToArray()));
|
||
Api<bool>("RunicNpc_RemovePlacement", "t_roam-1");
|
||
}
|
||
|
||
/// <summary>A point of open sea at least 5 m deep, for the under-water refusal.</summary>
|
||
private static bool FindWater(out Vector3 at)
|
||
{
|
||
var rng = new System.Random(7);
|
||
float half = World.Size / 2f - 20f;
|
||
for (int i = 0; i < 4000; i++)
|
||
{
|
||
var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half);
|
||
if (TerrainMeta.WaterMap.GetHeight(p) - TerrainMeta.HeightMap.GetHeight(p) > 5f)
|
||
{
|
||
at = p;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
at = Vector3.zero;
|
||
return false;
|
||
}
|
||
|
||
private IEnumerator Floor(Vector3 field)
|
||
{
|
||
// Over dry land: a first run put it over water (terrain −0.25 m), and Rust's navmesh never
|
||
// covered it there in 10 s.
|
||
Vector3 at = Vector3.zero;
|
||
foreach (Vector3 o in new[] { new Vector3(0, 0, 20), new Vector3(-20, 0, 20), new Vector3(20, 0, 0), new Vector3(0, 0, -20), new Vector3(-20, 0, -20) })
|
||
{
|
||
Vector3 ground = OnMesh(field + o);
|
||
if (ground.y > TerrainMeta.WaterMap.GetHeight(ground) + 1f)
|
||
{
|
||
at = ground + Vector3.up * 4f;
|
||
break;
|
||
}
|
||
}
|
||
|
||
BaseEntity floor = at == Vector3.zero ? null : BuildFloor(at);
|
||
if (floor == null)
|
||
{
|
||
Check("cmd.floor.built", false, "no floor");
|
||
yield break;
|
||
}
|
||
|
||
Vector3 top = floor.transform.position + Vector3.up * 0.1f;
|
||
float t0 = Time.realtimeSinceStartup;
|
||
NavMeshHit onTop;
|
||
bool covered = false;
|
||
while (!(covered = SampleNavmesh(top, 1f, out onTop) && Mathf.Abs(onTop.position.y - top.y) < 0.6f) && Time.realtimeSinceStartup - t0 < 10f)
|
||
yield return null;
|
||
Note($"floor at y={top.y:0.00} over terrain {TerrainMeta.HeightMap.GetHeight(top):0.00}, owner {floor.OwnerID}: " +
|
||
(covered ? $"covered after {Time.realtimeSinceStartup - t0:0.00} s" : "NOT covered within 10 s"));
|
||
|
||
// A route leg an NPC cannot walk is refused: the floor has no stairs, so its top is on the
|
||
// navmesh but not reachable from the ground beside it.
|
||
Rnpc("rnpc.path record t_up");
|
||
Rnpc($"rnpc.path point {At(OnMesh(top + new Vector3(8f, 0f, 0f)))}");
|
||
Check("cmd.path.unreachableRefused", Rnpc($"rnpc.path point {At(top)}").Contains("cannot walk here"), "ground to the top of a floor with no stairs");
|
||
Rnpc("rnpc.path cancel");
|
||
|
||
string r = Rnpc($"rnpc.place t_roam {At(top)} respawn=5 move=wander radius=2");
|
||
string id = System.Text.RegularExpressions.Regex.Match(r, @"Placed (\S+):").Groups[1].Value;
|
||
Check("cmd.floor.warns", r.Contains("player-built structure"), id);
|
||
StandIn(OnMesh(field + new Vector3(60, 0, 90)), 22);
|
||
yield return new WaitForSeconds(3f);
|
||
List<Dictionary<string, object>> on = List("placement:" + id);
|
||
Check("cmd.floor.standsOnIt", on.Count == 1 && Mathf.Abs(V(on[0]["position"]).y - top.y) < 0.8f, on.Count == 1 ? $"y={V(on[0]["position"]).y:0.00}, floor {top.y:0.00}" : "not spawned");
|
||
|
||
// D239 under a live NPC: Rust leaves it standing in the air; RunicNPC puts it on the nearest
|
||
// navmesh within its 2 s check.
|
||
ulong liveId = on.Count == 1 ? (ulong)on[0]["netId"] : 0UL;
|
||
floor.Kill();
|
||
_built.Remove(floor);
|
||
float downAfter = -1f;
|
||
for (int s = 1; s <= 10; s++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
on = List("placement:" + id);
|
||
if (on.Count == 1 && downAfter < 0f && V(on[0]["position"]).y < top.y - 2f)
|
||
downAfter = s;
|
||
if (s == 1 || s == downAfter || s == 10)
|
||
Note(on.Count == 1
|
||
? $"floor gone +{s} s: y={V(on[0]["position"]).y:0.00} (floor was {top.y:0.00}) state={on[0]["state"]} health={Convert.ToSingle(on[0]["health"]):0} regrounded={on[0]["regrounded"]}"
|
||
: $"floor gone +{s} s: {on.Count} NPC(s) listed");
|
||
}
|
||
|
||
on = List("placement:" + id);
|
||
Check("cmd.floor.liveRegrounds", on.Count == 1 && (ulong)on[0]["netId"] == liveId && downAfter > 0f && downAfter <= 4f && Row(id)?["note"] != null,
|
||
downAfter > 0f ? $"on the ground {downAfter:0} s after the floor went, the same NPC; note: {Row(id)?["note"]}" : "still in the air after 10 s");
|
||
|
||
foreach (Dictionary<string, object> d in List("placement:" + id))
|
||
Kill((ulong)d["netId"]);
|
||
yield return new WaitForSeconds(9f);
|
||
on = List("placement:" + id);
|
||
Dictionary<string, object> row = Row(id);
|
||
Check("cmd.floor.fallsBack", on.Count == 1 && V(on[0]["position"]).y < top.y - 2f && row != null && row["note"] != null,
|
||
on.Count == 1 ? $"respawned at y={V(on[0]["position"]).y:0.00}; note: {row?["note"]}" : $"{on.Count} alive; lastError={row?["lastError"]}");
|
||
|
||
// The floor is rebuilt: the next respawn is back on it, and the note clears.
|
||
floor = BuildFloor(at);
|
||
yield return new WaitForSeconds(1f);
|
||
Rnpc("rnpc.respawn " + id);
|
||
yield return new WaitForSeconds(3f);
|
||
on = List("placement:" + id);
|
||
row = Row(id);
|
||
Check("cmd.floor.returns", on.Count == 1 && Mathf.Abs(V(on[0]["position"]).y - top.y) < 0.8f && row?["note"] == null,
|
||
on.Count == 1 ? $"y={V(on[0]["position"]).y:0.00}; note: {row?["note"] ?? "-"}" : "not spawned");
|
||
Rnpc("rnpc.remove " + id);
|
||
Rnpc("rnpc.remove t_roam-1");
|
||
}
|
||
|
||
private BaseEntity BuildFloor(Vector3 at)
|
||
{
|
||
BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", at);
|
||
if (floor == null) return null;
|
||
floor.enableSaving = false;
|
||
// An unsupported floor collapses at once under Rust's stability; grounded, it stays up.
|
||
StabilityEntity stable = floor as StabilityEntity;
|
||
if (stable != null) stable.grounded = true;
|
||
floor.OwnerID = StandInId;
|
||
floor.Spawn();
|
||
_built.Add(floor);
|
||
return floor;
|
||
}
|
||
|
||
private static Vector3 OnMesh(Vector3 p)
|
||
{
|
||
p.y = TerrainMeta.HeightMap.GetHeight(p);
|
||
NavMeshHit hit;
|
||
return SampleNavmesh(p, 3f, out hit) ? hit.position : p;
|
||
}
|
||
|
||
private static JObject Placement(string profile, Vector3 at, int count, float respawn, string mode) =>
|
||
JObject.FromObject(new { profile, position = new { x = at.x, y = at.y, z = at.z }, yaw = 0f, count, respawn, respawnMode = mode });
|
||
|
||
private Dictionary<string, object> Row(string id) =>
|
||
(Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).FirstOrDefault(r => (string)r["id"] == id);
|
||
|
||
private static void Kill(ulong netId)
|
||
{
|
||
BasePlayer p = BaseNetworkable.serverEntities.Find(new NetworkableId(netId)) as BasePlayer;
|
||
if (p != null && !p.IsDead()) p.Hurt(new HitInfo(null, p, Rust.DamageType.Bullet, 10000f));
|
||
}
|
||
|
||
/// <summary>
|
||
/// After a reload or restart: every placement is back to its count, every RunicNPC entity in
|
||
/// the world is one RunicNPC lists, and no plain scientist stands at a placement's spot (the
|
||
/// saved-subclass symptom stage 1 found).
|
||
/// </summary>
|
||
private void After()
|
||
{
|
||
_pass = _fail = 0;
|
||
List<Dictionary<string, object>> rows = Api<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>();
|
||
foreach (Dictionary<string, object> r in rows)
|
||
{
|
||
JObject p = (JObject)r["placement"];
|
||
Check($"after.placement.{r["id"]}", Convert.ToInt32(r["alive"]) == (int)p["count"], $"{r["alive"]}/{p["count"]} alive, waiting={r["waiting"] ?? "-"}");
|
||
}
|
||
|
||
var listed = new HashSet<ulong>(List(null).Select(d => (ulong)d["netId"]));
|
||
List<BasePlayer> world = BaseNetworkable.serverEntities.OfType<BasePlayer>().Where(p => p.GetType().Name == "RunicNpcPlayer").ToList();
|
||
Check("after.worldMatchesRegistry", world.Count == listed.Count && world.All(p => listed.Contains(p.net.ID.Value)), $"world {world.Count}, listed {listed.Count}");
|
||
|
||
int strays = 0;
|
||
foreach (Dictionary<string, object> r in rows)
|
||
{
|
||
Vector3 at = V(((JObject)r["placement"])["position"].ToObject<Dictionary<string, object>>());
|
||
strays += BaseNetworkable.serverEntities.OfType<ScientistNPC>().Count(s => s.GetType() == typeof(ScientistNPC) && Vector3.Distance(s.transform.position, at) < 25f);
|
||
}
|
||
|
||
Check("after.noSavedScientists", strays == 0, strays + " plain scientist(s) at placement spots");
|
||
Check("after.pluginOwnedGone", List(Owner).Count == 0, List(Owner).Count + " left for " + Owner);
|
||
Note($"after: {_pass} pass, {_fail} fail");
|
||
}
|
||
|
||
// ==== stage 5: behaviour (docs/runicnpc/PLAN.md, D253–D272) ====
|
||
|
||
private const string ScientistPrefab = "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab";
|
||
private const string Zone = "rnt_zone";
|
||
|
||
private readonly List<string> _escortEnded = new List<string>();
|
||
|
||
private void OnRunicNpcEscortEnded(BasePlayer npc, string profile, string owner) => _escortEnded.Add($"{profile}/{owner}");
|
||
|
||
/// <summary>The stage 5 test profiles, beside the others; three are refused on purpose.</summary>
|
||
private static void Stage5Profiles(JObject all)
|
||
{
|
||
JObject P(string name, Action<JObject> edit)
|
||
{
|
||
JObject p = Prof(name: name);
|
||
edit(p);
|
||
return p;
|
||
}
|
||
|
||
all["t_red"] = P("TRed", p => p["faction"] = "red");
|
||
all["t_blue"] = P("TBlue", p => p["faction"] = "blue");
|
||
all["t_hunter"] = P("THunter", p =>
|
||
{
|
||
p["relations"] = JObject.FromObject(new Dictionary<string, string> { ["scientists"] = "hostile" });
|
||
p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 40f, attack = 30f });
|
||
});
|
||
all["t_leashed"] = P("TLeashed", p =>
|
||
{
|
||
p["relations"] = JObject.FromObject(new Dictionary<string, string> { ["scientists"] = "hostile" });
|
||
p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 8f, attack = 30f });
|
||
});
|
||
all["t_bully"] = P("TBully", p => p["relations"] = JObject.FromObject(new Dictionary<string, string> { ["profile:t_roam"] = "hostile" }));
|
||
all["t_guard"] = P("TGuard", p => p["role"] = "guard");
|
||
all["t_kit"] = P("TKit", p =>
|
||
{
|
||
p["kitUse"] = JObject.FromObject(new { heal = true, grenades = true, melee = true, rockets = true, flamethrower = true });
|
||
// Hostile to scientists, so the rocketeer's target is an NPC: Rust's player line of sight never sees a stand-in at 25 m.
|
||
p["relations"] = JObject.FromObject(new Dictionary<string, string> { ["scientists"] = "hostile" });
|
||
});
|
||
all["t_ignore"] = P("TIgnore", p => p["turrets"] = "ignore");
|
||
all["t_always"] = P("TAlways", p => p["turrets"] = "always");
|
||
all["t_unkill"] = P("TUnkill", p => p["hurtByPlayers"] = false);
|
||
all["t_harmless"] = P("THarmless", p => p["hurtsPlayers"] = false);
|
||
all["t_badfaction"] = P("TBadFaction", p => p["faction"] = "scientists");
|
||
all["t_badturrets"] = P("TBadTurrets", p => p["turrets"] = "sometimes");
|
||
}
|
||
|
||
private ScientistNPC SpawnScientist(Vector3 at)
|
||
{
|
||
ScientistNPC s = GameManager.server.CreateEntity(ScientistPrefab, OnMesh(at)) as ScientistNPC;
|
||
if (s == null) return null;
|
||
s.enableSaving = false;
|
||
s.Spawn();
|
||
_built.Add(s);
|
||
return s;
|
||
}
|
||
|
||
private static ulong Id(BaseEntity e) => e != null && e.net != null ? e.net.ID.Value : 0UL;
|
||
|
||
private static bool Gone(BaseCombatEntity e) => e == null || e.IsDestroyed || e.IsDead();
|
||
|
||
private static bool Knows(BasePlayer npc, BaseEntity who)
|
||
{
|
||
ScientistNPC s = npc as ScientistNPC;
|
||
return s != null && s.Brain != null && s.Brain.Senses != null && s.Brain.Senses.Memory.Targets.Contains(who);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Hands a player to an NPC's own memory, as Rust's senses do when they notice one. Rust's sensing
|
||
/// of a stand-in is unreliable (it can miss one at 2 m), so the tests that are about what happens
|
||
/// next start from here: SetKnown asks the NPC's IsTarget, which is what they test.
|
||
/// </summary>
|
||
private static void Sense(BasePlayer npc, BasePlayer who)
|
||
{
|
||
ScientistNPC s = npc as ScientistNPC;
|
||
if (s?.Brain?.Senses?.Memory != null && who != null)
|
||
s.Brain.Senses.Memory.SetKnown(who, s, s.Brain.Senses);
|
||
}
|
||
|
||
private static string TargetOf(Dictionary<string, object> d) => d == null ? "?" : d["target"] as string ?? "none";
|
||
|
||
private static void Give(BasePlayer npc, string item, int amount)
|
||
{
|
||
Item made = ItemManager.CreateByName(item, amount);
|
||
if (made != null && !made.MoveToContainer(npc.inventory.containerMain))
|
||
made.Remove();
|
||
}
|
||
|
||
private static int Count(BasePlayer npc, string item) =>
|
||
Items(npc).Where(i => i.info.shortname == item).Sum(i => i.amount);
|
||
|
||
private static List<Item> Items(BasePlayer npc)
|
||
{
|
||
var all = new List<Item>();
|
||
npc.inventory.GetAllItems(all);
|
||
return all;
|
||
}
|
||
|
||
private static void Teleport(BasePlayer npc, Vector3 to)
|
||
{
|
||
ScientistNPC s = npc as ScientistNPC;
|
||
NavMeshHit hit;
|
||
if (s?.Brain?.Navigator?.Agent != null && SampleNavmesh(to, 3f, out hit))
|
||
{
|
||
s.Brain.Navigator.Stop();
|
||
s.Brain.Navigator.Agent.Warp(hit.position);
|
||
s.transform.position = hit.position;
|
||
}
|
||
}
|
||
|
||
private static void MoveStandIn(BasePlayer p, Vector3 to)
|
||
{
|
||
p.transform.position = OnMesh(to);
|
||
p.SendNetworkUpdateImmediate();
|
||
}
|
||
|
||
/// <summary>A spot near <paramref name="near"/> with a clear line to a second spot <paramref name="range"/> m east: no rock between.</summary>
|
||
private static bool ClearLine(Vector3 near, float range, out Vector3 a, out Vector3 b)
|
||
{
|
||
for (int i = 0; i < 40; i++)
|
||
{
|
||
a = OnMesh(near + new Vector3((i % 8) * 6f - 21f, 0, (i / 8) * 8f - 16f));
|
||
b = OnMesh(a + new Vector3(range, 0, 0));
|
||
NavMeshHit h1, h2;
|
||
if (!SampleNavmesh(a, 1f, out h1) || !SampleNavmesh(b, 1f, out h2)) continue;
|
||
if (!Physics.Linecast(a + Vector3.up * 1.6f, b + Vector3.up * 1f, 1218519041, QueryTriggerInteraction.Ignore))
|
||
return true;
|
||
}
|
||
|
||
a = b = Vector3.zero;
|
||
return false;
|
||
}
|
||
|
||
private int HorrorLeft() => BaseNetworkable.serverEntities.OfType<BaseCombatEntity>().Count(c => !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror);
|
||
|
||
private IEnumerator Stage5()
|
||
{
|
||
Vector3 f;
|
||
if (!FindField(300f, 2025, out f))
|
||
{
|
||
Check("s5.setup.field", false, "no second open field on the navmesh");
|
||
yield break;
|
||
}
|
||
|
||
int horrorBefore = HorrorLeft();
|
||
Note($"s5 field {f}, Horror entities before: {horrorBefore}");
|
||
|
||
// ---- the faction table (D254, D268) and the profile refusals ----
|
||
string said = Api<string>("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "red", relation = "hostile" } }));
|
||
Check("s5.factions.refuseSelf", said != null && said.Contains("allied to itself"), said ?? "null");
|
||
said = Api<string>("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "scientists", b = "animals", relation = "hostile" } }));
|
||
Check("s5.factions.refuseRust", said != null && said.Contains("Rust's own"), said ?? "null");
|
||
said = Api<string>("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "angry" } }));
|
||
Check("s5.factions.refuseRelation", said != null && said.Contains("not hostile, neutral or allied"), said ?? "null");
|
||
said = Api<string>("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" }, new { a = "blue", b = "red", relation = "allied" } }));
|
||
Check("s5.factions.refuseTwice", said != null && said.Contains("both ways"), said ?? "null");
|
||
said = Api<string>("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" } }));
|
||
JArray table = Api<JArray>("RunicNpc_Factions");
|
||
Check("s5.factions.set", said == null && table != null && table.Count == 1, (said ?? "set") + " " + table?.ToString(Formatting.None));
|
||
JObject profiles = Api<JObject>("RunicNpc_Profiles");
|
||
Check("s5.profile.refuseFaction", ("" + profiles?["refused"]?["t_badfaction"]).Contains("Rust's own"), "" + profiles?["refused"]?["t_badfaction"]);
|
||
Check("s5.profile.refuseTurrets", ("" + profiles?["refused"]?["t_badturrets"]).Contains("not default, ignore or always"), "" + profiles?["refused"]?["t_badturrets"]);
|
||
|
||
// A stand-in 60 m off keeps every NPC of the field (x ±140) within 160 m and awake (D235), unsensed.
|
||
BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50);
|
||
|
||
// ---- five fights at once, 70 m apart (D231, D255, D263, D268) ----
|
||
Vector3 a = OnMesh(f + new Vector3(-140, 0, 0));
|
||
Vector3 b = OnMesh(f + new Vector3(-70, 0, 0));
|
||
Vector3 c = OnMesh(f + new Vector3(0, 0, 0));
|
||
Vector3 d = OnMesh(f + new Vector3(70, 0, 0));
|
||
Vector3 e = OnMesh(f + new Vector3(140, 0, 0));
|
||
|
||
BasePlayer red = Spawn(a, "t_red");
|
||
BasePlayer blue = Spawn(OnMesh(a + new Vector3(0, 0, 15)), "t_blue");
|
||
BasePlayer hunter = Spawn(b, "t_hunter");
|
||
ScientistNPC prey = SpawnScientist(b + new Vector3(0, 0, 15));
|
||
BasePlayer neutral = Spawn(c, "t_roam");
|
||
ScientistNPC bystander = SpawnScientist(c + new Vector3(0, 0, 15));
|
||
BasePlayer leashed = Spawn(d, "t_leashed");
|
||
ScientistNPC far = SpawnScientist(d + new Vector3(0, 0, 25));
|
||
BasePlayer victim = Spawn(e, "t_roam");
|
||
BasePlayer bully = Spawn(OnMesh(e + new Vector3(0, 0, 15)), "t_bully");
|
||
Check("s5.fight.spawned", red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully, "");
|
||
if (!(red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully)) yield break;
|
||
|
||
float farHealth = far.health, bystanderHealth = bystander.health, preyHealth = prey.health;
|
||
float redHp = red.health, blueHp = blue.health;
|
||
float factionsAt = -1f, scientistAt = -1f;
|
||
bool neutralFought = false, leashedFought = false, victimAnswered = false;
|
||
int marked = 0;
|
||
for (int t = 1; t <= 70; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
if (factionsAt < 0f && (Gone(red) || Gone(blue))) factionsAt = t;
|
||
if (scientistAt < 0f && Gone(prey)) scientistAt = t;
|
||
if (!Gone(neutral) && TargetOf(Describe(neutral)) != "none") neutralFought = true;
|
||
if (!Gone(leashed) && TargetOf(Describe(leashed)) != "none") leashedFought = true;
|
||
if (!Gone(victim) && TargetOf(Describe(victim)) != "none") victimAnswered = true;
|
||
foreach (BasePlayer x in new[] { red, blue, hunter })
|
||
{
|
||
Dictionary<string, object> dx = Gone(x) ? null : Describe(x);
|
||
if (dx != null) marked = Math.Max(marked, Convert.ToInt32(dx["shotsMarked"]));
|
||
}
|
||
|
||
if (factionsAt > 0f && scientistAt > 0f && victimAnswered && t >= 25) break;
|
||
}
|
||
|
||
float redNow = Gone(red) ? 0f : red.health, blueNow = Gone(blue) ? 0f : blue.health;
|
||
Check("s5.fight.factions", redNow < redHp && blueNow < blueHp,
|
||
$"red and blue (hostile in the table) both hit: red {redHp:0}→{redNow:0}, blue {blueHp:0}→{blueNow:0}{(factionsAt > 0f ? $", one died at {factionsAt:0} s" : "")}");
|
||
Check("s5.fight.marked", marked > 0, $"{marked} shot(s) marked Horror for their length");
|
||
Check("s5.fight.scientists", scientistAt > 0f || (!Gone(prey) && prey.health < preyHealth * 0.5f),
|
||
scientistAt > 0f ? $"the hunter killed a stock scientist at {scientistAt:0} s" : $"scientist {preyHealth:0}→{prey?.health:0} hp in 70 s");
|
||
Check("s5.fight.noFactionNoFight", !neutralFought && !Gone(bystander) && bystander.health >= bystanderHealth, $"t_roam beside a scientist: fought={neutralFought}, scientist {bystander?.health:0}/{bystanderHealth:0} (D255)");
|
||
Check("s5.fight.leash", !leashedFought && !Gone(far) && far.health >= farHealth, $"chase 8, scientist 25 m off: fought={leashedFought}, scientist {far?.health:0}/{farHealth:0} (D263)");
|
||
Check("s5.fight.shootBack", victimAnswered, $"t_roam (neutral) shot by a profile hostile to it one-sidedly: answered={victimAnswered} (D268)");
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
ClearWorld();
|
||
keeper = StandIn(f + new Vector3(0, 0, 60), 50);
|
||
yield return new WaitForSeconds(1f);
|
||
|
||
// ---- the alarm (D256): allies within 40 m learn the attacker, seen or not ----
|
||
BasePlayer shooter = StandIn(f + new Vector3(0, 0, -120), 51);
|
||
BasePlayer b0 = Spawn(a, "t_blue");
|
||
BasePlayer b1 = Spawn(OnMesh(a + new Vector3(6, 0, 0)), "t_blue");
|
||
BasePlayer b2 = Spawn(OnMesh(a + new Vector3(-6, 0, 0)), "t_blue");
|
||
BasePlayer b3 = Spawn(OnMesh(a + new Vector3(0, 0, 60)), "t_blue");
|
||
BasePlayer r0 = Spawn(OnMesh(a + new Vector3(0, 0, -6)), "t_roam");
|
||
yield return new WaitForSeconds(2f);
|
||
b0.Hurt(new HitInfo(shooter, b0, Rust.DamageType.Bullet, 5f));
|
||
yield return new WaitForSeconds(1f);
|
||
Check("s5.alarm.near", Knows(b1, shooter) && Knows(b2, shooter), $"b1={Knows(b1, shooter)} b2={Knows(b2, shooter)}");
|
||
Check("s5.alarm.far", !Knows(b3, shooter), $"60 m off: knows={Knows(b3, shooter)}");
|
||
Check("s5.alarm.notEnemy", !Knows(r0, shooter), $"a t_roam beside them: knows={Knows(r0, shooter)} (not their ally)");
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
ClearWorld();
|
||
keeper = StandIn(f + new Vector3(0, 0, 60), 50);
|
||
yield return new WaitForSeconds(1f);
|
||
|
||
// ---- allies (D257, D270): never targeted, and defended with what they own ----
|
||
BasePlayer friend = StandIn(OnMesh(b + new Vector3(0, 0, 12)), 52);
|
||
// The control: the same profile beside it, allied to nobody, sees the same player. (Rust senses
|
||
// only one of two stand-ins this close, so a second stand-in cannot be the control.)
|
||
BasePlayer guardian = Spawn(b, "t_roam");
|
||
BasePlayer control = Spawn(OnMesh(b + new Vector3(2, 0, 0)), "t_roam");
|
||
said = Api<string>("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString);
|
||
Check("s5.ally.set", said == null, said ?? "allied to a player");
|
||
said = Api<string>("RunicNpc_Ally", Id(guardian), "clan", "424242");
|
||
Check("s5.ally.refuseClan", said != null && said.Contains("no clan"), said ?? "null");
|
||
Api<string>("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString);
|
||
yield return new WaitForSeconds(2f);
|
||
Sense(guardian, friend);
|
||
Sense(control, friend);
|
||
yield return new WaitForSeconds(1f);
|
||
Check("s5.ally.spared", !Knows(guardian, friend), $"the ally 12 m off in sight: target={Knows(guardian, friend)}");
|
||
Check("s5.ally.controlFights", Knows(control, friend), $"the same profile, allied to nobody, 2 m away: target={Knows(control, friend)}");
|
||
Api<int>("RunicNpc_Despawn", Id(control));
|
||
BasePlayer raider = StandIn(OnMesh(b + new Vector3(0, 0, -90)), 54);
|
||
friend.Hurt(new HitInfo(raider, friend, Rust.DamageType.Bullet, 5f));
|
||
yield return new WaitForSeconds(1f);
|
||
Check("s5.ally.defendsPlayer", Knows(guardian, raider), $"a raider 90 m off shot the ally: guardian knows={Knows(guardian, raider)}");
|
||
|
||
BaseEntity foundation = GameManager.server.CreateEntity("assets/prefabs/building core/foundation/foundation.prefab", OnMesh(b + new Vector3(8, 0, -8)));
|
||
if (foundation != null)
|
||
{
|
||
foundation.enableSaving = false;
|
||
foundation.OwnerID = (ulong)friend.userID;
|
||
if (foundation is StabilityEntity) ((StabilityEntity)foundation).grounded = true;
|
||
foundation.Spawn();
|
||
_built.Add(foundation);
|
||
BasePlayer wrecker = StandIn(OnMesh(b + new Vector3(-90, 0, 0)), 55);
|
||
((BaseCombatEntity)foundation).Hurt(new HitInfo(wrecker, foundation, Rust.DamageType.Explosion, 1f));
|
||
yield return new WaitForSeconds(1f);
|
||
Check("s5.ally.defendsBuilding", Knows(guardian, wrecker), $"a wrecker 90 m off hit the ally's foundation: guardian knows={Knows(guardian, wrecker)}");
|
||
}
|
||
else
|
||
Check("s5.ally.defendsBuilding", false, "no foundation could be made");
|
||
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
ClearWorld();
|
||
keeper = StandIn(f + new Vector3(0, 0, 60), 50);
|
||
yield return new WaitForSeconds(1f);
|
||
|
||
// ---- escort (D258, D269) ----
|
||
_escortEnded.Clear();
|
||
// A stock scientist as the charge: a stand-in has no connection, so it is never "on the server".
|
||
ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0));
|
||
BasePlayer escort = Spawn(c, "t_roam");
|
||
BasePlayer sentry = Spawn(OnMesh(c + new Vector3(-10, 0, 0)), "t_sentry");
|
||
said = Api<string>("RunicNpc_Escort", Id(sentry), charge);
|
||
Check("s5.escort.refuseSentry", said != null && said.Contains("sentry"), said ?? "null");
|
||
said = Api<string>("RunicNpc_Escort", Id(escort), charge);
|
||
Check("s5.escort.set", said == null, said ?? "escorting");
|
||
yield return new WaitForSeconds(2f);
|
||
Vector3 home = escort.transform.position;
|
||
// 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<string, object> de = Describe(escort);
|
||
Check("s5.escort.ends", _escortEnded.Count == 1 && de != null && de["escort"] == null, $"hook {_escortEnded.Count}, escort={de?["escort"] ?? "null"}");
|
||
float back = float.MaxValue;
|
||
for (int t = 0; t < 45 && back > 4f; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
back = Flat(escort.transform.position, V(Describe(escort)["home"]));
|
||
}
|
||
|
||
Check("s5.escort.home", back <= 4f, $"{back:0.0} m from its spot after its charge was gone (D269)");
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
ClearWorld();
|
||
keeper = StandIn(f + new Vector3(0, 0, 60), 50);
|
||
yield return new WaitForSeconds(1f);
|
||
|
||
// ---- the guard role ----
|
||
BasePlayer guard = Spawn(d, "t_guard");
|
||
yield return new WaitForSeconds(2f);
|
||
Vector3 post = V(Describe(guard)["home"]);
|
||
Teleport(guard, post + new Vector3(10, 0, 0));
|
||
float off = float.MaxValue;
|
||
for (int t = 0; t < 25 && off > 2.5f; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
off = Flat(guard.transform.position, post);
|
||
}
|
||
|
||
Check("s5.guard.returns", off <= 2.5f, $"put 10 m off its post: {off:0.0} m from it");
|
||
// It stops within 2 m of its post; let it arrive before measuring how still it holds.
|
||
yield return new WaitForSeconds(4f);
|
||
Vector3 held = guard.transform.position;
|
||
float drift = 0f;
|
||
for (int t = 0; t < 12; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
drift = Mathf.Max(drift, Flat(guard.transform.position, held));
|
||
}
|
||
|
||
Check("s5.guard.holds", drift < 1.5f, $"moved {drift:0.0} m in 12 s at its post (a roamer would wander)");
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
yield return new WaitForSeconds(1f);
|
||
|
||
// ---- the tether (D272) ----
|
||
Plugin zm = plugins.Find("ZoneManager");
|
||
if (zm == null)
|
||
Note("s5.tether skipped: ZoneManager is not loaded");
|
||
else
|
||
{
|
||
// Within 160 m of the keeper, or the NPC sleeps (D235) and the test reads 0 m.
|
||
Vector3 centre = OnMesh(c);
|
||
zm.Call("CreateOrUpdateZone", Zone, new[] { "radius", "12", "name", "rnt tether" }, centre);
|
||
BasePlayer tethered = Spawn(centre, "t_roam");
|
||
said = Api<string>("RunicNpc_Tether", Id(tethered), "rnt_nozone");
|
||
Check("s5.tether.refuseMissing", said != null && said.Contains("no zone"), said ?? "null");
|
||
said = Api<string>("RunicNpc_Tether", Id(tethered), Zone);
|
||
Check("s5.tether.set", said == null, said ?? "held");
|
||
float widest = 0f;
|
||
for (int t = 0; t < 30; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
widest = Mathf.Max(widest, Flat(tethered.transform.position, centre));
|
||
}
|
||
|
||
Check("s5.tether.holds", widest <= 13f, $"wander radius 15 in a 12 m zone: widest {widest:0.0} m from its centre in 30 s");
|
||
Teleport(tethered, centre + new Vector3(25, 0, 0));
|
||
float inside = float.MaxValue;
|
||
for (int t = 0; t < 25 && inside > 12f; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
inside = Flat(tethered.transform.position, centre);
|
||
}
|
||
|
||
Check("s5.tether.returns", inside <= 12f, $"put 25 m out: {inside:0.0} m from the centre");
|
||
string placed = Api<string>("RunicNpc_SetPlacement", "t_tether", JObject.FromObject(new { profile = "t_roam", position = new { centre.x, centre.y, centre.z }, tether = "rnt_nozone" }));
|
||
Check("s5.tether.placementRefused", placed != null && placed.Contains("no zone"), placed ?? "null");
|
||
Api<bool>("RunicNpc_RemovePlacement", "t_tether");
|
||
zm.Call("EraseZone", Zone);
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
}
|
||
|
||
// ---- PVE (D261, D271) ----
|
||
BasePlayer player = StandIn(OnMesh(a + new Vector3(0, 0, 40)), 57);
|
||
BasePlayer unkill = Spawn(a, "t_unkill");
|
||
BasePlayer harmless = Spawn(OnMesh(a + new Vector3(4, 0, 0)), "t_harmless");
|
||
BasePlayer plain = Spawn(OnMesh(a + new Vector3(-4, 0, 0)), "t_roam");
|
||
object o1 = Interface.CallHook("CanEntityTakeDamage", unkill, new HitInfo(player, unkill, Rust.DamageType.Bullet, 10f));
|
||
object o2 = Interface.CallHook("CanEntityTakeDamage", plain, new HitInfo(player, plain, Rust.DamageType.Bullet, 10f));
|
||
object o3 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f));
|
||
object o4 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f));
|
||
Check("s5.pve.answers", Equals(o1, false) && Equals(o2, true) && Equals(o3, false) && Equals(o4, true), $"unkillable {o1 ?? "null"}, plain {o2 ?? "null"}, harmless→player {o3 ?? "null"}, plain→player {o4 ?? "null"}");
|
||
float hp = unkill.health;
|
||
unkill.Hurt(new HitInfo(player, unkill, Rust.DamageType.Bullet, 30f));
|
||
float plainHp = plain.health;
|
||
plain.Hurt(new HitInfo(player, plain, Rust.DamageType.Bullet, 30f));
|
||
Check("s5.pve.unkillable", Mathf.Approximately(unkill.health, hp) && plain.health < plainHp, $"unkillable {hp:0}→{unkill.health:0}, plain {plainHp:0}→{plain.health:0}");
|
||
object h1 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f));
|
||
object h2 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f));
|
||
Check("s5.pve.harmless", Equals(h1, true) && h2 == null, $"harmless→player {h1 ?? "null"}, plain→player {h2 ?? "null"}");
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
ClearWorld();
|
||
keeper = StandIn(f + new Vector3(0, 0, 60), 50);
|
||
yield return new WaitForSeconds(1f);
|
||
|
||
// ---- turrets (D259, D264, D267): the hook, then Outpost's own sentries ----
|
||
BasePlayer ignoring = Spawn(b, "t_ignore");
|
||
BasePlayer defaulting = Spawn(OnMesh(b + new Vector3(4, 0, 0)), "t_roam");
|
||
object i1 = Interface.CallHook("CanBeTargeted", ignoring, (MonoBehaviour)null);
|
||
object i2 = Interface.CallHook("CanBeTargeted", defaulting, (MonoBehaviour)null);
|
||
Check("s5.turrets.ignore", Equals(i1, false) && i2 == null, $"ignore {i1 ?? "null"}, default {i2 ?? "null"}");
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
IEnumerator sentries = Sentries();
|
||
while (sentries.MoveNext()) yield return sentries.Current;
|
||
ClearWorld();
|
||
keeper = StandIn(f + new Vector3(0, 0, 60), 50);
|
||
yield return new WaitForSeconds(1f);
|
||
|
||
// ---- the kit's extras (D260, D265), each NPC 60 m from the next ----
|
||
IEnumerator kit = KitExtras(f);
|
||
while (kit.MoveNext()) yield return kit.Current;
|
||
|
||
int horrorAfter = HorrorLeft();
|
||
Check("s5.noHorrorLeft", horrorAfter <= horrorBefore, $"Horror entities: {horrorBefore} before, {horrorAfter} after");
|
||
Api<string>("RunicNpc_SetFactions", new JArray());
|
||
}
|
||
|
||
/// <summary>
|
||
/// D264 and D267 on Outpost's (or Bandit Camp's) own sentries, and the org lead's condition: they
|
||
/// must never target Rust's own scientists or bandit guards. Ours are put in front of a sentry:
|
||
/// one <c>always</c>, one <c>default</c>, and a stock scientist beside them.
|
||
/// </summary>
|
||
private IEnumerator Sentries()
|
||
{
|
||
List<NPCAutoTurret> turrets = BaseNetworkable.serverEntities.OfType<NPCAutoTurret>().Where(t => t != null && !t.IsDestroyed).ToList();
|
||
if (turrets.Count == 0)
|
||
{
|
||
Note("s5.sentry skipped: this map has no Outpost or Bandit Camp sentries");
|
||
yield break;
|
||
}
|
||
|
||
NPCAutoTurret turret = null;
|
||
Vector3 spot = Vector3.zero;
|
||
foreach (NPCAutoTurret t in turrets)
|
||
{
|
||
Vector3 ahead = t.transform.position + t.transform.forward * 12f;
|
||
NavMeshHit hit, start;
|
||
// The approach must be walkable too: ours start 45 m out and are walked in (a sentry by the
|
||
// water left the Carbon run's NPC unspawned).
|
||
if (SampleNavmesh(ahead, 4f, out hit) && Mathf.Abs(hit.position.y - t.transform.position.y) < 6f &&
|
||
SampleNavmesh(t.transform.position + t.transform.forward * 45f, 3f, out start))
|
||
{
|
||
turret = t;
|
||
spot = hit.position;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (turret == null)
|
||
{
|
||
Check("s5.sentry.spot", false, $"no navmesh 12 m in front of any of {turrets.Count} sentries");
|
||
yield break;
|
||
}
|
||
|
||
Note($"s5.sentry: {turret.ShortPrefabName} at {turret.transform.position}, {turrets.Count} on the map");
|
||
BasePlayer near = StandIn(spot + new Vector3(0, 0, 80), 58);
|
||
Vector3 side = Vector3.Cross(Vector3.up, turret.transform.forward).normalized;
|
||
// Spawned 45 m out and walked in: a turret notices only what ENTERS its trigger (stage 5 spike).
|
||
Vector3 outside = turret.transform.position + turret.transform.forward * 45f;
|
||
BasePlayer always = Spawn(OnMesh(outside), "t_always");
|
||
BasePlayer plain = Spawn(OnMesh(outside + side * 4f), "t_roam");
|
||
ScientistNPC stock = SpawnScientist(outside - side * 4f);
|
||
yield return new WaitForSeconds(1f);
|
||
Teleport(always, spot);
|
||
Teleport(plain, spot + side * 4f);
|
||
Teleport(stock, spot - side * 4f);
|
||
float alwaysHp = always != null ? always.health : 0f;
|
||
var targeted = new HashSet<string>();
|
||
bool alwaysTargeted = false;
|
||
int stockTargets = 0;
|
||
for (int t = 0; t < 120; t++)
|
||
{
|
||
yield return new WaitForSeconds(0.25f);
|
||
foreach (NPCAutoTurret any in turrets)
|
||
{
|
||
BaseCombatEntity target = any != null && !any.IsDestroyed ? any.target : null;
|
||
if (target == null) continue;
|
||
targeted.Add(target.GetType().Name);
|
||
if (target == always) alwaysTargeted = true;
|
||
if (target is BanditGuard || (target is ScientistNPC && target.GetType().Name != "RunicNpcPlayer")) stockTargets++;
|
||
}
|
||
}
|
||
|
||
if (ReferenceEquals(always, null) || ReferenceEquals(plain, null) || ReferenceEquals(stock, null)) Note($"s5.sentry: spawned always={always != null} default={plain != null} stock={stock != null}");
|
||
Check("s5.sentry.always", alwaysTargeted && (Gone(always) || always.health < alwaysHp), $"targeted={alwaysTargeted}, health {alwaysHp:0}→{(Gone(always) ? 0f : always.health):0}");
|
||
Check("s5.sentry.default", Gone(plain) || plain.health >= plain.MaxHealth() - 0.5f, $"a default NPC beside it: {(Gone(plain) ? "dead" : plain.health.ToString("0"))} hp");
|
||
Check("s5.sentry.noStockTargets", stockTargets == 0 && !Gone(stock), $"samples on a stock scientist or bandit guard: {stockTargets}; targets seen: {string.Join(", ", targeted.ToArray())} (the org lead's condition, D267)");
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
}
|
||
|
||
private IEnumerator KitExtras(Vector3 f)
|
||
{
|
||
Vector3[] at = new Vector3[6];
|
||
for (int i = 0; i < 6; i++)
|
||
at[i] = OnMesh(f + new Vector3(-150 + i * 60, 0, -60));
|
||
|
||
// Heal in a lull, one syringe used up; and a profile that did not opt in never heals.
|
||
BasePlayer healer = Spawn(at[0], "t_kit");
|
||
BasePlayer plain = Spawn(OnMesh(at[0] + new Vector3(8, 0, 0)), "t_roam");
|
||
Give(healer, "syringe.medical", 2);
|
||
Give(plain, "syringe.medical", 2);
|
||
yield return new WaitForSeconds(1f);
|
||
healer.health = healer.MaxHealth() * 0.3f;
|
||
plain.health = plain.MaxHealth() * 0.3f;
|
||
float plainHp = plain.health;
|
||
|
||
// A grenadier, a rocketeer, a brawler and a flamer, each with a stand-in at its range.
|
||
BasePlayer grenadier = Spawn(at[1], "t_kit");
|
||
Give(grenadier, "grenade.f1", 2);
|
||
BasePlayer gTarget = StandIn(OnMesh(at[1] + new Vector3(12, 0, 0)), 60);
|
||
Vector3 rAt, rTo;
|
||
if (!ClearLine(at[2], 25f, out rAt, out rTo)) { rAt = at[2]; rTo = at[2] + new Vector3(25, 0, 0); Note("s5.kit.rocket: no clear 25 m line found near its spot"); }
|
||
BasePlayer rocketeer = Spawn(rAt, "t_kit");
|
||
Give(rocketeer, "rocket.launcher", 1);
|
||
Give(rocketeer, "ammo.rocket.basic", 2);
|
||
ScientistNPC rTarget = SpawnScientist(rTo);
|
||
BasePlayer brawler = Spawn(at[3], "t_kit");
|
||
Give(brawler, "machete", 1);
|
||
BasePlayer mTarget = StandIn(OnMesh(at[3] + new Vector3(1.5f, 0, 0)), 62);
|
||
BasePlayer flamer = Spawn(at[4], "t_kit");
|
||
Give(flamer, "flamethrower", 1);
|
||
Give(flamer, "lowgradefuel", 50);
|
||
// An empty tank, so the extra must burn the kit's fuel (D265: only extras are used up).
|
||
foreach (Item it in Items(flamer))
|
||
if (it.GetHeldEntity() is FlameThrower) ((FlameThrower)it.GetHeldEntity()).ammo = 0;
|
||
BasePlayer near = StandIn(OnMesh(at[4] + new Vector3(4, 0, 0)), 63);
|
||
Sense(flamer, near);
|
||
|
||
bool flameHeld = false;
|
||
for (int t = 0; t < 45; t++)
|
||
{
|
||
yield return new WaitForSeconds(1f);
|
||
if (t % 5 == 0)
|
||
{
|
||
if (!Gone(flamer)) Sense(flamer, near);
|
||
if (!Gone(grenadier)) Sense(grenadier, gTarget);
|
||
if (!Gone(brawler)) Sense(brawler, mTarget);
|
||
}
|
||
Item held = Gone(flamer) ? null : flamer.GetActiveItem();
|
||
if (held == null || held.info.shortname != "flamethrower") continue;
|
||
// Its tank emptied while it is held, then one shot: the extra must burn the kit's fuel (D265).
|
||
FlameThrower tank = held.GetHeldEntity() as FlameThrower;
|
||
if (!flameHeld && tank != null)
|
||
{
|
||
tank.ammo = 0;
|
||
((NPCPlayer)flamer).ShotTest(4f);
|
||
}
|
||
|
||
flameHeld = true;
|
||
}
|
||
|
||
Dictionary<string, object> Used(BasePlayer x) => Gone(x) ? null : Describe(x)?["kitUsed"] as Dictionary<string, object>;
|
||
int UsedN(BasePlayer x, string k) => Used(x) != null ? Convert.ToInt32(Used(x)[k]) : -1;
|
||
|
||
Check("s5.kit.heal", UsedN(healer, "heals") > 0 && Count(healer, "syringe.medical") < 2, $"heals {UsedN(healer, "heals")}, syringes left {(Gone(healer) ? -1 : Count(healer, "syringe.medical"))}, health {(Gone(healer) ? 0 : healer.health):0}");
|
||
Check("s5.kit.offByDefault", !Gone(plain) && plain.health <= plainHp + 0.5f && Count(plain, "syringe.medical") == 2, $"t_roam: health {plainHp:0}→{(Gone(plain) ? 0 : plain.health):0}, syringes {(Gone(plain) ? -1 : Count(plain, "syringe.medical"))}");
|
||
Check("s5.kit.grenade", UsedN(grenadier, "throws") > 0 && Count(grenadier, "grenade.f1") < 2, $"throws {UsedN(grenadier, "throws")}, grenades left {(Gone(grenadier) ? -1 : Count(grenadier, "grenade.f1"))}");
|
||
Check("s5.kit.rocket", (UsedN(rocketeer, "rockets") > 0 || Gone(rTarget)) && Count(rocketeer, "ammo.rocket.basic") < 2, $"rockets {UsedN(rocketeer, "rockets")}, left {(Gone(rocketeer) ? -1 : Count(rocketeer, "ammo.rocket.basic"))}");
|
||
Check("s5.kit.melee", UsedN(brawler, "meleeHits") > 0, $"melee swings {UsedN(brawler, "meleeHits")}");
|
||
Check("s5.kit.flame", flameHeld && !Gone(flamer) && Count(flamer, "lowgradefuel") < 50, $"held={flameHeld}, fuel left {(Gone(flamer) ? -1 : Count(flamer, "lowgradefuel"))}");
|
||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
||
}
|
||
|
||
/// <summary>A route, as RunicNpc_Routes returns it.</summary>
|
||
private sealed class Route
|
||
{
|
||
public List<P> points = new List<P>();
|
||
public bool loop;
|
||
}
|
||
|
||
private sealed class P
|
||
{
|
||
public float x, y, z;
|
||
public Vector3 V => new Vector3(x, y, z);
|
||
}
|
||
}
|
||
}
|