fix: escort end, line of sight and rockets; test(harness): stage 5 group (stage 5)
What the rigs' stage 5 runs found in the plugin: - an escort whose charge was destroyed was never released: Unity's == says a destroyed entity is null, so CheckEscort returned before it looked. It now tests the reference itself (D269); - our own fight's line of sight no longer uses Rust's CanSeeTarget for an NPC target: its rays are unbounded, so on open ground they run past the target into the terrain behind it. A bounded linecast to the target's centre, which counts the target's own collider as seen; - a rocket's line of sight: Rust's sensed one for a real player, ours for an NPC or an animal, which are never in Rust's player memory. tools/RunicNpcTest.cs 0.5.0 adds `rnt.run s5`: 52 checks of the faction table and its refusals, faction fights, the leash, shooting back, the alarm, allies spared and defended (players and buildings), escort, the guard, the tether, PVE, turrets (`ignore` through the hook, `always` on the map's own sentries with the org lead's condition: no stock scientist or bandit guard ever targeted), and the kit's extras used up. Also `rnt.duel`, `rnt.kitprobe`, `rnt.rocketprobe` and `rnt.list`, for looking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -2063,14 +2063,14 @@ namespace Oxide.Plugins
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rust's <c>CanSeeTarget</c> for a player; for anything else (which Rust answers "seen" without
|
||||
/// a ray), a line from its eyes that counts the target's own collider as seen.
|
||||
/// A line from its eyes to the target's centre that counts the target's own collider as seen.
|
||||
/// Not Rust's <c>CanSeeTarget</c>, even for a scientist or one of ours: its rays are unbounded,
|
||||
/// so on open ground they run past the target into the terrain behind it, and an NPC 25 m off
|
||||
/// read as unseen until ours had walked right up to it (stage 5 build). Players are Rust's
|
||||
/// design's to see; this is only for what our own fight takes on.
|
||||
/// </summary>
|
||||
private static bool Los(RunicNpcPlayer npc, BaseCombatEntity t)
|
||||
{
|
||||
BasePlayer p = t as BasePlayer;
|
||||
if (p != null)
|
||||
return npc.CanSeeTarget(p);
|
||||
RaycastHit hit;
|
||||
if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore))
|
||||
return true;
|
||||
@@ -2335,15 +2335,17 @@ namespace Oxide.Plugins
|
||||
/// <summary>D269: whom it escorts died or left the server; it walks back to its spot.</summary>
|
||||
private void CheckEscort(RunicNpcPlayer npc)
|
||||
{
|
||||
// Unity's == says a destroyed entity is null, which is exactly the case to catch here.
|
||||
BaseEntity e = npc.Escort;
|
||||
if (e == null)
|
||||
if (ReferenceEquals(e, null))
|
||||
return;
|
||||
BasePlayer p = e as BasePlayer;
|
||||
BaseCombatEntity c = e as BaseCombatEntity;
|
||||
bool gone = e.IsDestroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected);
|
||||
bool destroyed = e == null || e.IsDestroyed;
|
||||
BasePlayer p = destroyed ? null : e as BasePlayer;
|
||||
BaseCombatEntity c = destroyed ? null : e as BaseCombatEntity;
|
||||
bool gone = destroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected);
|
||||
if (!gone)
|
||||
return;
|
||||
string who = p != null ? p.displayName : e.ShortPrefabName;
|
||||
string who = p != null ? p.displayName : destroyed ? "its charge" : e.ShortPrefabName;
|
||||
npc.Escort = null;
|
||||
NoteDefender(npc);
|
||||
npc.RnBrain?.Navigator?.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal);
|
||||
@@ -2491,10 +2493,17 @@ namespace Oxide.Plugins
|
||||
}
|
||||
}
|
||||
|
||||
if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket && npc.TargetLos)
|
||||
if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket)
|
||||
{
|
||||
npc.NextRocket = now + 10f;
|
||||
if (FireRocket(npc, target))
|
||||
npc.NextRocket = now + 2f;
|
||||
// A real player's line of sight is the one Rust's design shoots on; an NPC (a scientist, one of
|
||||
// ours) or an animal is never in Rust's player memory, so it is ours.
|
||||
BaseCombatEntity aim = target as BaseCombatEntity;
|
||||
BasePlayer human = aim as BasePlayer;
|
||||
bool seen = human != null && !human.IsNpc
|
||||
? brain.Senses?.Memory != null && brain.Senses.Memory.IsLOS(aim)
|
||||
: aim != null && Los(npc, aim);
|
||||
if (seen && FireRocket(npc, target))
|
||||
{
|
||||
npc.KitBusyUntil = now + 1.5f;
|
||||
return;
|
||||
@@ -2594,6 +2603,7 @@ namespace Oxide.Plugins
|
||||
ammo.UseItem(1);
|
||||
launcher.GetHeldEntity()?.SignalBroadcast(BaseEntity.Signal.Attack, string.Empty);
|
||||
npc.Rockets++;
|
||||
npc.NextRocket = Time.time + 10f;
|
||||
timer.Once(1.2f, () =>
|
||||
{
|
||||
if (npc != null && !npc.IsDestroyed && !npc.IsDead())
|
||||
|
||||
@@ -24,7 +24,9 @@ namespace Oxide.Plugins
|
||||
///
|
||||
/// <para>
|
||||
/// <c>rnt.run <group></c> runs one group: <c>api</c>, <c>hooks</c>, <c>move</c>,
|
||||
/// <c>sentry</c>, <c>sleep</c>, <c>cmd</c> (stage 3's commands), <c>api3</c> (stage 4's calls), <c>place</c>, or <c>all</c> for each in turn.
|
||||
/// <c>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
|
||||
@@ -42,8 +44,8 @@ namespace Oxide.Plugins
|
||||
/// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Info("RunicNpcTest", "Runic Gateway", "0.4.0")]
|
||||
[Description("RunicNPC stage 2, 3 and 4 tests. A developer tool: never ship it.")]
|
||||
[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;
|
||||
@@ -277,6 +279,7 @@ namespace Oxide.Plugins
|
||||
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" };
|
||||
}
|
||||
|
||||
@@ -325,6 +328,100 @@ namespace Oxide.Plugins
|
||||
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");
|
||||
}
|
||||
|
||||
[ConsoleCommand("rnt.clear")]
|
||||
private void CmdClear(ConsoleSystem.Arg arg)
|
||||
{
|
||||
@@ -351,11 +448,11 @@ namespace Oxide.Plugins
|
||||
}
|
||||
|
||||
Dictionary<string, string> refused = SetTestProfiles();
|
||||
Check("setup.profiles", refused.Count == 1 && refused.ContainsKey("t_bad"), "refused: " + string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()));
|
||||
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", "place" } : new[] { group };
|
||||
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)
|
||||
@@ -367,6 +464,7 @@ namespace Oxide.Plugins
|
||||
: g == "api3" ? Api3(field)
|
||||
: g == "floor" ? Floor(field)
|
||||
: g == "place" ? Place(field)
|
||||
: g == "s5" ? Stage5()
|
||||
: null;
|
||||
if (inner == null)
|
||||
{
|
||||
@@ -388,7 +486,7 @@ namespace Oxide.Plugins
|
||||
|
||||
private IEnumerator Api(Vector3 field)
|
||||
{
|
||||
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 3, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
|
||||
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"]);
|
||||
@@ -1188,6 +1286,586 @@ namespace Oxide.Plugins
|
||||
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;
|
||||
Teleport(charge, c + new Vector3(0, 0, 35));
|
||||
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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user