diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index 514071e..5670545 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -2063,14 +2063,14 @@ namespace Oxide.Plugins } /// - /// Rust's CanSeeTarget 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 CanSeeTarget, 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. /// 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 /// D269: whom it escorts died or left the server; it walks back to its spot. 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()) diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 3055ff5..c5175ce 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -24,7 +24,9 @@ namespace Oxide.Plugins /// /// /// rnt.run <group> runs one group: api, hooks, move, - /// sentry, sleep, cmd (stage 3's commands), api3 (stage 4's calls), place, or all for each in turn. + /// sentry, sleep, cmd (stage 3's commands), api3 (stage 4's calls), place, + /// s5 (stage 5's behaviour: factions, fights, alarm, allies, escort, guard, tether, turrets, + /// PVE and the kit's extras), or all for each in turn. /// rnt.run place leaves two placements behind for the restart check, and /// rnt.after checks, after a reload or restart, that every placement is back and nothing /// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and @@ -42,8 +44,8 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [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>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "no answer" }; } @@ -325,6 +328,100 @@ namespace Oxide.Plugins arg.ReplyWith($"rnt: spawned {n} as {Owner}"); } + /// A probe: two profiles 15 m apart, described every two seconds for 30 s. + [ConsoleCommand("rnt.duel")] + private void CmdDuel(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + BasePlayer x = Spawn(OnMesh(f + new Vector3(-140, 0, 0)), arg.GetString(0, "t_red")); + BasePlayer y = Spawn(OnMesh(f + new Vector3(-140, 0, 15)), arg.GetString(1, "t_blue")); + int n = 0; + timer.Repeat(2f, 15, () => + { + n++; + foreach (BasePlayer p in new[] { x, y }) + { + Dictionary d = p == null || p.IsDestroyed ? null : Describe(p); + Puts($"[duel {n}] {(d == null ? "gone" : JsonConvert.SerializeObject(d))} hp={(p == null || p.IsDestroyed ? 0 : p.health):0} held={(p == null || p.IsDestroyed ? "-" : p.GetActiveItem()?.info.shortname ?? "none")}"); + } + if (n == 15) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: duel started"); + } + + /// A probe: a t_kit NPC with a flamethrower and fuel, a stand-in 4 m off, every second for 20 s. + [ConsoleCommand("rnt.kitprobe")] + private void CmdKitProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + Vector3 at = OnMesh(f + new Vector3(-30, 0, -60)); + BasePlayer flamer = Spawn(at, "t_kit"); + Give(flamer, "flamethrower", 1); + Give(flamer, "lowgradefuel", 50); + BasePlayer target = StandIn(OnMesh(at + new Vector3(4, 0, 0)), 63); + int n = 0; + timer.Repeat(1f, 20, () => + { + n++; + ScientistNPC s = flamer as ScientistNPC; + Item held = flamer == null || flamer.IsDestroyed ? null : flamer.GetActiveItem(); + FlameThrower tank = held?.GetHeldEntity() as FlameThrower; + if (n == 3 && tank != null) tank.ammo = 0; + var mem = s?.Brain?.Senses?.Memory; + Puts($"[kit {n}] state={s?.Brain?.CurrentState?.StateType} held={held?.info.shortname ?? "none"} ammo={(tank != null ? tank.ammo : -1)} fuel={(flamer == null || flamer.IsDestroyed ? -1 : Count(flamer, "lowgradefuel"))} knows={Knows(flamer, target)} los={(mem != null && mem.IsLOS(target))} hits={_hitsOnStandIns}"); + if (n == 20) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: kit probe started"); + } + + /// Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks. + [ConsoleCommand("rnt.list")] + private void CmdList(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + string owner = arg.GetString(0, null); + var rows = List(owner); + arg.ReplyWith(string.Join(Environment.NewLine, rows.Select(d => JsonConvert.SerializeObject(d)).ToArray()) + Environment.NewLine + $"rnt: {rows.Count} NPC(s)"); + } + + /// A probe: a t_kit rocketeer and a stock scientist 25 m off, described every 2 s for 40 s. + [ConsoleCommand("rnt.rocketprobe")] + private void CmdRocketProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + Vector3 at = OnMesh(f + new Vector3(-30, 0, -60)); + BasePlayer r = Spawn(at, "t_kit"); + Give(r, "rocket.launcher", 1); + Give(r, "ammo.rocket.basic", 2); + ScientistNPC target = SpawnScientist(at + new Vector3(25, 0, 0)); + int n = 0; + timer.Repeat(2f, 20, () => + { + n++; + Dictionary d = Gone(r) ? null : Describe(r); + string line = "-"; + if (!Gone(r) && !Gone(target)) + { + RaycastHit hh; + line = Physics.Linecast(r.eyes.position, target.CenterPoint(), out hh, 1218519041, QueryTriggerInteraction.Ignore) + ? $"hit {hh.collider?.name}/{LayerMask.LayerToName(hh.collider != null ? hh.collider.gameObject.layer : 0)}/{hh.GetEntity()?.ShortPrefabName ?? "none"} at {hh.distance:0.0}" + : "clear"; + line += $" eyes={r.eyes.position.y:0.0} centre={target.CenterPoint().y:0.0}"; + } + Puts($"[rocket {n}] line={line} target={TargetOf(d)} engaged={d?["engaged"]} state={d?["state"]} rest={d?["rest"]} kit={(d != null ? JsonConvert.SerializeObject(d["kitUsed"]) : "-")} held={(Gone(r) ? "-" : r.GetActiveItem()?.info.shortname)} ammo={(Gone(r) ? -1 : Count(r, "ammo.rocket.basic"))} sci={(Gone(target) ? 0f : target.health):0} dist={(Gone(r) || Gone(target) ? 0f : Vector3.Distance(r.transform.position, target.transform.position)):0}"); + if (n == 20) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: rocket probe started"); + } + [ConsoleCommand("rnt.clear")] private void CmdClear(ConsoleSystem.Arg arg) { @@ -351,11 +448,11 @@ namespace Oxide.Plugins } Dictionary 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("RunicNpc_ApiVersion") == 3, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + Check("api.version", Api("RunicNpc_ApiVersion") == 4, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); JObject profiles = Api("RunicNpc_Profiles"); Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]); @@ -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 _escortEnded = new List(); + + private void OnRunicNpcEscortEnded(BasePlayer npc, string profile, string owner) => _escortEnded.Add($"{profile}/{owner}"); + + /// The stage 5 test profiles, beside the others; three are refused on purpose. + private static void Stage5Profiles(JObject all) + { + JObject P(string name, Action edit) + { + JObject p = Prof(name: name); + edit(p); + return p; + } + + all["t_red"] = P("TRed", p => p["faction"] = "red"); + all["t_blue"] = P("TBlue", p => p["faction"] = "blue"); + all["t_hunter"] = P("THunter", p => + { + p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); + p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 40f, attack = 30f }); + }); + all["t_leashed"] = P("TLeashed", p => + { + p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); + p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 8f, attack = 30f }); + }); + all["t_bully"] = P("TBully", p => p["relations"] = JObject.FromObject(new Dictionary { ["profile:t_roam"] = "hostile" })); + all["t_guard"] = P("TGuard", p => p["role"] = "guard"); + all["t_kit"] = P("TKit", p => + { + p["kitUse"] = JObject.FromObject(new { heal = true, grenades = true, melee = true, rockets = true, flamethrower = true }); + // Hostile to scientists, so the rocketeer's target is an NPC: Rust's player line of sight never sees a stand-in at 25 m. + p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); + }); + all["t_ignore"] = P("TIgnore", p => p["turrets"] = "ignore"); + all["t_always"] = P("TAlways", p => p["turrets"] = "always"); + all["t_unkill"] = P("TUnkill", p => p["hurtByPlayers"] = false); + all["t_harmless"] = P("THarmless", p => p["hurtsPlayers"] = false); + all["t_badfaction"] = P("TBadFaction", p => p["faction"] = "scientists"); + all["t_badturrets"] = P("TBadTurrets", p => p["turrets"] = "sometimes"); + } + + private ScientistNPC SpawnScientist(Vector3 at) + { + ScientistNPC s = GameManager.server.CreateEntity(ScientistPrefab, OnMesh(at)) as ScientistNPC; + if (s == null) return null; + s.enableSaving = false; + s.Spawn(); + _built.Add(s); + return s; + } + + private static ulong Id(BaseEntity e) => e != null && e.net != null ? e.net.ID.Value : 0UL; + + private static bool Gone(BaseCombatEntity e) => e == null || e.IsDestroyed || e.IsDead(); + + private static bool Knows(BasePlayer npc, BaseEntity who) + { + ScientistNPC s = npc as ScientistNPC; + return s != null && s.Brain != null && s.Brain.Senses != null && s.Brain.Senses.Memory.Targets.Contains(who); + } + + /// + /// Hands a player to an NPC's own memory, as Rust's senses do when they notice one. Rust's sensing + /// of a stand-in is unreliable (it can miss one at 2 m), so the tests that are about what happens + /// next start from here: SetKnown asks the NPC's IsTarget, which is what they test. + /// + private static void Sense(BasePlayer npc, BasePlayer who) + { + ScientistNPC s = npc as ScientistNPC; + if (s?.Brain?.Senses?.Memory != null && who != null) + s.Brain.Senses.Memory.SetKnown(who, s, s.Brain.Senses); + } + + private static string TargetOf(Dictionary d) => d == null ? "?" : d["target"] as string ?? "none"; + + private static void Give(BasePlayer npc, string item, int amount) + { + Item made = ItemManager.CreateByName(item, amount); + if (made != null && !made.MoveToContainer(npc.inventory.containerMain)) + made.Remove(); + } + + private static int Count(BasePlayer npc, string item) => + Items(npc).Where(i => i.info.shortname == item).Sum(i => i.amount); + + private static List Items(BasePlayer npc) + { + var all = new List(); + npc.inventory.GetAllItems(all); + return all; + } + + private static void Teleport(BasePlayer npc, Vector3 to) + { + ScientistNPC s = npc as ScientistNPC; + NavMeshHit hit; + if (s?.Brain?.Navigator?.Agent != null && SampleNavmesh(to, 3f, out hit)) + { + s.Brain.Navigator.Stop(); + s.Brain.Navigator.Agent.Warp(hit.position); + s.transform.position = hit.position; + } + } + + private static void MoveStandIn(BasePlayer p, Vector3 to) + { + p.transform.position = OnMesh(to); + p.SendNetworkUpdateImmediate(); + } + + /// A spot near with a clear line to a second spot m east: no rock between. + private static bool ClearLine(Vector3 near, float range, out Vector3 a, out Vector3 b) + { + for (int i = 0; i < 40; i++) + { + a = OnMesh(near + new Vector3((i % 8) * 6f - 21f, 0, (i / 8) * 8f - 16f)); + b = OnMesh(a + new Vector3(range, 0, 0)); + NavMeshHit h1, h2; + if (!SampleNavmesh(a, 1f, out h1) || !SampleNavmesh(b, 1f, out h2)) continue; + if (!Physics.Linecast(a + Vector3.up * 1.6f, b + Vector3.up * 1f, 1218519041, QueryTriggerInteraction.Ignore)) + return true; + } + + a = b = Vector3.zero; + return false; + } + + private int HorrorLeft() => BaseNetworkable.serverEntities.OfType().Count(c => !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror); + + private IEnumerator Stage5() + { + Vector3 f; + if (!FindField(300f, 2025, out f)) + { + Check("s5.setup.field", false, "no second open field on the navmesh"); + yield break; + } + + int horrorBefore = HorrorLeft(); + Note($"s5 field {f}, Horror entities before: {horrorBefore}"); + + // ---- the faction table (D254, D268) and the profile refusals ---- + string said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "red", relation = "hostile" } })); + Check("s5.factions.refuseSelf", said != null && said.Contains("allied to itself"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "scientists", b = "animals", relation = "hostile" } })); + Check("s5.factions.refuseRust", said != null && said.Contains("Rust's own"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "angry" } })); + Check("s5.factions.refuseRelation", said != null && said.Contains("not hostile, neutral or allied"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" }, new { a = "blue", b = "red", relation = "allied" } })); + Check("s5.factions.refuseTwice", said != null && said.Contains("both ways"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" } })); + JArray table = Api("RunicNpc_Factions"); + Check("s5.factions.set", said == null && table != null && table.Count == 1, (said ?? "set") + " " + table?.ToString(Formatting.None)); + JObject profiles = Api("RunicNpc_Profiles"); + Check("s5.profile.refuseFaction", ("" + profiles?["refused"]?["t_badfaction"]).Contains("Rust's own"), "" + profiles?["refused"]?["t_badfaction"]); + Check("s5.profile.refuseTurrets", ("" + profiles?["refused"]?["t_badturrets"]).Contains("not default, ignore or always"), "" + profiles?["refused"]?["t_badturrets"]); + + // A stand-in 60 m off keeps every NPC of the field (x ±140) within 160 m and awake (D235), unsensed. + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + + // ---- five fights at once, 70 m apart (D231, D255, D263, D268) ---- + Vector3 a = OnMesh(f + new Vector3(-140, 0, 0)); + Vector3 b = OnMesh(f + new Vector3(-70, 0, 0)); + Vector3 c = OnMesh(f + new Vector3(0, 0, 0)); + Vector3 d = OnMesh(f + new Vector3(70, 0, 0)); + Vector3 e = OnMesh(f + new Vector3(140, 0, 0)); + + BasePlayer red = Spawn(a, "t_red"); + BasePlayer blue = Spawn(OnMesh(a + new Vector3(0, 0, 15)), "t_blue"); + BasePlayer hunter = Spawn(b, "t_hunter"); + ScientistNPC prey = SpawnScientist(b + new Vector3(0, 0, 15)); + BasePlayer neutral = Spawn(c, "t_roam"); + ScientistNPC bystander = SpawnScientist(c + new Vector3(0, 0, 15)); + BasePlayer leashed = Spawn(d, "t_leashed"); + ScientistNPC far = SpawnScientist(d + new Vector3(0, 0, 25)); + BasePlayer victim = Spawn(e, "t_roam"); + BasePlayer bully = Spawn(OnMesh(e + new Vector3(0, 0, 15)), "t_bully"); + Check("s5.fight.spawned", red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully, ""); + if (!(red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully)) yield break; + + float farHealth = far.health, bystanderHealth = bystander.health, preyHealth = prey.health; + float redHp = red.health, blueHp = blue.health; + float factionsAt = -1f, scientistAt = -1f; + bool neutralFought = false, leashedFought = false, victimAnswered = false; + int marked = 0; + for (int t = 1; t <= 70; t++) + { + yield return new WaitForSeconds(1f); + if (factionsAt < 0f && (Gone(red) || Gone(blue))) factionsAt = t; + if (scientistAt < 0f && Gone(prey)) scientistAt = t; + if (!Gone(neutral) && TargetOf(Describe(neutral)) != "none") neutralFought = true; + if (!Gone(leashed) && TargetOf(Describe(leashed)) != "none") leashedFought = true; + if (!Gone(victim) && TargetOf(Describe(victim)) != "none") victimAnswered = true; + foreach (BasePlayer x in new[] { red, blue, hunter }) + { + Dictionary dx = Gone(x) ? null : Describe(x); + if (dx != null) marked = Math.Max(marked, Convert.ToInt32(dx["shotsMarked"])); + } + + if (factionsAt > 0f && scientistAt > 0f && victimAnswered && t >= 25) break; + } + + float redNow = Gone(red) ? 0f : red.health, blueNow = Gone(blue) ? 0f : blue.health; + Check("s5.fight.factions", redNow < redHp && blueNow < blueHp, + $"red and blue (hostile in the table) both hit: red {redHp:0}→{redNow:0}, blue {blueHp:0}→{blueNow:0}{(factionsAt > 0f ? $", one died at {factionsAt:0} s" : "")}"); + Check("s5.fight.marked", marked > 0, $"{marked} shot(s) marked Horror for their length"); + Check("s5.fight.scientists", scientistAt > 0f || (!Gone(prey) && prey.health < preyHealth * 0.5f), + scientistAt > 0f ? $"the hunter killed a stock scientist at {scientistAt:0} s" : $"scientist {preyHealth:0}→{prey?.health:0} hp in 70 s"); + Check("s5.fight.noFactionNoFight", !neutralFought && !Gone(bystander) && bystander.health >= bystanderHealth, $"t_roam beside a scientist: fought={neutralFought}, scientist {bystander?.health:0}/{bystanderHealth:0} (D255)"); + Check("s5.fight.leash", !leashedFought && !Gone(far) && far.health >= farHealth, $"chase 8, scientist 25 m off: fought={leashedFought}, scientist {far?.health:0}/{farHealth:0} (D263)"); + Check("s5.fight.shootBack", victimAnswered, $"t_roam (neutral) shot by a profile hostile to it one-sidedly: answered={victimAnswered} (D268)"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the alarm (D256): allies within 40 m learn the attacker, seen or not ---- + BasePlayer shooter = StandIn(f + new Vector3(0, 0, -120), 51); + BasePlayer b0 = Spawn(a, "t_blue"); + BasePlayer b1 = Spawn(OnMesh(a + new Vector3(6, 0, 0)), "t_blue"); + BasePlayer b2 = Spawn(OnMesh(a + new Vector3(-6, 0, 0)), "t_blue"); + BasePlayer b3 = Spawn(OnMesh(a + new Vector3(0, 0, 60)), "t_blue"); + BasePlayer r0 = Spawn(OnMesh(a + new Vector3(0, 0, -6)), "t_roam"); + yield return new WaitForSeconds(2f); + b0.Hurt(new HitInfo(shooter, b0, Rust.DamageType.Bullet, 5f)); + yield return new WaitForSeconds(1f); + Check("s5.alarm.near", Knows(b1, shooter) && Knows(b2, shooter), $"b1={Knows(b1, shooter)} b2={Knows(b2, shooter)}"); + Check("s5.alarm.far", !Knows(b3, shooter), $"60 m off: knows={Knows(b3, shooter)}"); + Check("s5.alarm.notEnemy", !Knows(r0, shooter), $"a t_roam beside them: knows={Knows(r0, shooter)} (not their ally)"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- allies (D257, D270): never targeted, and defended with what they own ---- + BasePlayer friend = StandIn(OnMesh(b + new Vector3(0, 0, 12)), 52); + // The control: the same profile beside it, allied to nobody, sees the same player. (Rust senses + // only one of two stand-ins this close, so a second stand-in cannot be the control.) + BasePlayer guardian = Spawn(b, "t_roam"); + BasePlayer control = Spawn(OnMesh(b + new Vector3(2, 0, 0)), "t_roam"); + said = Api("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString); + Check("s5.ally.set", said == null, said ?? "allied to a player"); + said = Api("RunicNpc_Ally", Id(guardian), "clan", "424242"); + Check("s5.ally.refuseClan", said != null && said.Contains("no clan"), said ?? "null"); + Api("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString); + yield return new WaitForSeconds(2f); + Sense(guardian, friend); + Sense(control, friend); + yield return new WaitForSeconds(1f); + Check("s5.ally.spared", !Knows(guardian, friend), $"the ally 12 m off in sight: target={Knows(guardian, friend)}"); + Check("s5.ally.controlFights", Knows(control, friend), $"the same profile, allied to nobody, 2 m away: target={Knows(control, friend)}"); + Api("RunicNpc_Despawn", Id(control)); + BasePlayer raider = StandIn(OnMesh(b + new Vector3(0, 0, -90)), 54); + friend.Hurt(new HitInfo(raider, friend, Rust.DamageType.Bullet, 5f)); + yield return new WaitForSeconds(1f); + Check("s5.ally.defendsPlayer", Knows(guardian, raider), $"a raider 90 m off shot the ally: guardian knows={Knows(guardian, raider)}"); + + BaseEntity foundation = GameManager.server.CreateEntity("assets/prefabs/building core/foundation/foundation.prefab", OnMesh(b + new Vector3(8, 0, -8))); + if (foundation != null) + { + foundation.enableSaving = false; + foundation.OwnerID = (ulong)friend.userID; + if (foundation is StabilityEntity) ((StabilityEntity)foundation).grounded = true; + foundation.Spawn(); + _built.Add(foundation); + BasePlayer wrecker = StandIn(OnMesh(b + new Vector3(-90, 0, 0)), 55); + ((BaseCombatEntity)foundation).Hurt(new HitInfo(wrecker, foundation, Rust.DamageType.Explosion, 1f)); + yield return new WaitForSeconds(1f); + Check("s5.ally.defendsBuilding", Knows(guardian, wrecker), $"a wrecker 90 m off hit the ally's foundation: guardian knows={Knows(guardian, wrecker)}"); + } + else + Check("s5.ally.defendsBuilding", false, "no foundation could be made"); + + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- escort (D258, D269) ---- + _escortEnded.Clear(); + // A stock scientist as the charge: a stand-in has no connection, so it is never "on the server". + ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0)); + BasePlayer escort = Spawn(c, "t_roam"); + BasePlayer sentry = Spawn(OnMesh(c + new Vector3(-10, 0, 0)), "t_sentry"); + said = Api("RunicNpc_Escort", Id(sentry), charge); + Check("s5.escort.refuseSentry", said != null && said.Contains("sentry"), said ?? "null"); + said = Api("RunicNpc_Escort", Id(escort), charge); + Check("s5.escort.set", said == null, said ?? "escorting"); + yield return new WaitForSeconds(2f); + Vector3 home = escort.transform.position; + 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 de = Describe(escort); + Check("s5.escort.ends", _escortEnded.Count == 1 && de != null && de["escort"] == null, $"hook {_escortEnded.Count}, escort={de?["escort"] ?? "null"}"); + float back = float.MaxValue; + for (int t = 0; t < 45 && back > 4f; t++) + { + yield return new WaitForSeconds(1f); + back = Flat(escort.transform.position, V(Describe(escort)["home"])); + } + + Check("s5.escort.home", back <= 4f, $"{back:0.0} m from its spot after its charge was gone (D269)"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the guard role ---- + BasePlayer guard = Spawn(d, "t_guard"); + yield return new WaitForSeconds(2f); + Vector3 post = V(Describe(guard)["home"]); + Teleport(guard, post + new Vector3(10, 0, 0)); + float off = float.MaxValue; + for (int t = 0; t < 25 && off > 2.5f; t++) + { + yield return new WaitForSeconds(1f); + off = Flat(guard.transform.position, post); + } + + Check("s5.guard.returns", off <= 2.5f, $"put 10 m off its post: {off:0.0} m from it"); + // It stops within 2 m of its post; let it arrive before measuring how still it holds. + yield return new WaitForSeconds(4f); + Vector3 held = guard.transform.position; + float drift = 0f; + for (int t = 0; t < 12; t++) + { + yield return new WaitForSeconds(1f); + drift = Mathf.Max(drift, Flat(guard.transform.position, held)); + } + + Check("s5.guard.holds", drift < 1.5f, $"moved {drift:0.0} m in 12 s at its post (a roamer would wander)"); + Api("RunicNpc_DespawnOwner", Owner); + yield return new WaitForSeconds(1f); + + // ---- the tether (D272) ---- + Plugin zm = plugins.Find("ZoneManager"); + if (zm == null) + Note("s5.tether skipped: ZoneManager is not loaded"); + else + { + // Within 160 m of the keeper, or the NPC sleeps (D235) and the test reads 0 m. + Vector3 centre = OnMesh(c); + zm.Call("CreateOrUpdateZone", Zone, new[] { "radius", "12", "name", "rnt tether" }, centre); + BasePlayer tethered = Spawn(centre, "t_roam"); + said = Api("RunicNpc_Tether", Id(tethered), "rnt_nozone"); + Check("s5.tether.refuseMissing", said != null && said.Contains("no zone"), said ?? "null"); + said = Api("RunicNpc_Tether", Id(tethered), Zone); + Check("s5.tether.set", said == null, said ?? "held"); + float widest = 0f; + for (int t = 0; t < 30; t++) + { + yield return new WaitForSeconds(1f); + widest = Mathf.Max(widest, Flat(tethered.transform.position, centre)); + } + + Check("s5.tether.holds", widest <= 13f, $"wander radius 15 in a 12 m zone: widest {widest:0.0} m from its centre in 30 s"); + Teleport(tethered, centre + new Vector3(25, 0, 0)); + float inside = float.MaxValue; + for (int t = 0; t < 25 && inside > 12f; t++) + { + yield return new WaitForSeconds(1f); + inside = Flat(tethered.transform.position, centre); + } + + Check("s5.tether.returns", inside <= 12f, $"put 25 m out: {inside:0.0} m from the centre"); + string placed = Api("RunicNpc_SetPlacement", "t_tether", JObject.FromObject(new { profile = "t_roam", position = new { centre.x, centre.y, centre.z }, tether = "rnt_nozone" })); + Check("s5.tether.placementRefused", placed != null && placed.Contains("no zone"), placed ?? "null"); + Api("RunicNpc_RemovePlacement", "t_tether"); + zm.Call("EraseZone", Zone); + Api("RunicNpc_DespawnOwner", Owner); + } + + // ---- PVE (D261, D271) ---- + BasePlayer player = StandIn(OnMesh(a + new Vector3(0, 0, 40)), 57); + BasePlayer unkill = Spawn(a, "t_unkill"); + BasePlayer harmless = Spawn(OnMesh(a + new Vector3(4, 0, 0)), "t_harmless"); + BasePlayer plain = Spawn(OnMesh(a + new Vector3(-4, 0, 0)), "t_roam"); + object o1 = Interface.CallHook("CanEntityTakeDamage", unkill, new HitInfo(player, unkill, Rust.DamageType.Bullet, 10f)); + object o2 = Interface.CallHook("CanEntityTakeDamage", plain, new HitInfo(player, plain, Rust.DamageType.Bullet, 10f)); + object o3 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f)); + object o4 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f)); + Check("s5.pve.answers", Equals(o1, false) && Equals(o2, true) && Equals(o3, false) && Equals(o4, true), $"unkillable {o1 ?? "null"}, plain {o2 ?? "null"}, harmless→player {o3 ?? "null"}, plain→player {o4 ?? "null"}"); + float hp = unkill.health; + unkill.Hurt(new HitInfo(player, unkill, Rust.DamageType.Bullet, 30f)); + float plainHp = plain.health; + plain.Hurt(new HitInfo(player, plain, Rust.DamageType.Bullet, 30f)); + Check("s5.pve.unkillable", Mathf.Approximately(unkill.health, hp) && plain.health < plainHp, $"unkillable {hp:0}→{unkill.health:0}, plain {plainHp:0}→{plain.health:0}"); + object h1 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f)); + object h2 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f)); + Check("s5.pve.harmless", Equals(h1, true) && h2 == null, $"harmless→player {h1 ?? "null"}, plain→player {h2 ?? "null"}"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- turrets (D259, D264, D267): the hook, then Outpost's own sentries ---- + BasePlayer ignoring = Spawn(b, "t_ignore"); + BasePlayer defaulting = Spawn(OnMesh(b + new Vector3(4, 0, 0)), "t_roam"); + object i1 = Interface.CallHook("CanBeTargeted", ignoring, (MonoBehaviour)null); + object i2 = Interface.CallHook("CanBeTargeted", defaulting, (MonoBehaviour)null); + Check("s5.turrets.ignore", Equals(i1, false) && i2 == null, $"ignore {i1 ?? "null"}, default {i2 ?? "null"}"); + Api("RunicNpc_DespawnOwner", Owner); + IEnumerator sentries = Sentries(); + while (sentries.MoveNext()) yield return sentries.Current; + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the kit's extras (D260, D265), each NPC 60 m from the next ---- + IEnumerator kit = KitExtras(f); + while (kit.MoveNext()) yield return kit.Current; + + int horrorAfter = HorrorLeft(); + Check("s5.noHorrorLeft", horrorAfter <= horrorBefore, $"Horror entities: {horrorBefore} before, {horrorAfter} after"); + Api("RunicNpc_SetFactions", new JArray()); + } + + /// + /// D264 and D267 on Outpost's (or Bandit Camp's) own sentries, and the org lead's condition: they + /// must never target Rust's own scientists or bandit guards. Ours are put in front of a sentry: + /// one always, one default, and a stock scientist beside them. + /// + private IEnumerator Sentries() + { + List turrets = BaseNetworkable.serverEntities.OfType().Where(t => t != null && !t.IsDestroyed).ToList(); + if (turrets.Count == 0) + { + Note("s5.sentry skipped: this map has no Outpost or Bandit Camp sentries"); + yield break; + } + + NPCAutoTurret turret = null; + Vector3 spot = Vector3.zero; + foreach (NPCAutoTurret t in turrets) + { + Vector3 ahead = t.transform.position + t.transform.forward * 12f; + NavMeshHit hit, start; + // The approach must be walkable too: ours start 45 m out and are walked in (a sentry by the + // water left the Carbon run's NPC unspawned). + if (SampleNavmesh(ahead, 4f, out hit) && Mathf.Abs(hit.position.y - t.transform.position.y) < 6f && + SampleNavmesh(t.transform.position + t.transform.forward * 45f, 3f, out start)) + { + turret = t; + spot = hit.position; + break; + } + } + + if (turret == null) + { + Check("s5.sentry.spot", false, $"no navmesh 12 m in front of any of {turrets.Count} sentries"); + yield break; + } + + Note($"s5.sentry: {turret.ShortPrefabName} at {turret.transform.position}, {turrets.Count} on the map"); + BasePlayer near = StandIn(spot + new Vector3(0, 0, 80), 58); + Vector3 side = Vector3.Cross(Vector3.up, turret.transform.forward).normalized; + // Spawned 45 m out and walked in: a turret notices only what ENTERS its trigger (stage 5 spike). + Vector3 outside = turret.transform.position + turret.transform.forward * 45f; + BasePlayer always = Spawn(OnMesh(outside), "t_always"); + BasePlayer plain = Spawn(OnMesh(outside + side * 4f), "t_roam"); + ScientistNPC stock = SpawnScientist(outside - side * 4f); + yield return new WaitForSeconds(1f); + Teleport(always, spot); + Teleport(plain, spot + side * 4f); + Teleport(stock, spot - side * 4f); + float alwaysHp = always != null ? always.health : 0f; + var targeted = new HashSet(); + bool alwaysTargeted = false; + int stockTargets = 0; + for (int t = 0; t < 120; t++) + { + yield return new WaitForSeconds(0.25f); + foreach (NPCAutoTurret any in turrets) + { + BaseCombatEntity target = any != null && !any.IsDestroyed ? any.target : null; + if (target == null) continue; + targeted.Add(target.GetType().Name); + if (target == always) alwaysTargeted = true; + if (target is BanditGuard || (target is ScientistNPC && target.GetType().Name != "RunicNpcPlayer")) stockTargets++; + } + } + + if (ReferenceEquals(always, null) || ReferenceEquals(plain, null) || ReferenceEquals(stock, null)) Note($"s5.sentry: spawned always={always != null} default={plain != null} stock={stock != null}"); + Check("s5.sentry.always", alwaysTargeted && (Gone(always) || always.health < alwaysHp), $"targeted={alwaysTargeted}, health {alwaysHp:0}→{(Gone(always) ? 0f : always.health):0}"); + Check("s5.sentry.default", Gone(plain) || plain.health >= plain.MaxHealth() - 0.5f, $"a default NPC beside it: {(Gone(plain) ? "dead" : plain.health.ToString("0"))} hp"); + Check("s5.sentry.noStockTargets", stockTargets == 0 && !Gone(stock), $"samples on a stock scientist or bandit guard: {stockTargets}; targets seen: {string.Join(", ", targeted.ToArray())} (the org lead's condition, D267)"); + Api("RunicNpc_DespawnOwner", Owner); + } + + private IEnumerator KitExtras(Vector3 f) + { + Vector3[] at = new Vector3[6]; + for (int i = 0; i < 6; i++) + at[i] = OnMesh(f + new Vector3(-150 + i * 60, 0, -60)); + + // Heal in a lull, one syringe used up; and a profile that did not opt in never heals. + BasePlayer healer = Spawn(at[0], "t_kit"); + BasePlayer plain = Spawn(OnMesh(at[0] + new Vector3(8, 0, 0)), "t_roam"); + Give(healer, "syringe.medical", 2); + Give(plain, "syringe.medical", 2); + yield return new WaitForSeconds(1f); + healer.health = healer.MaxHealth() * 0.3f; + plain.health = plain.MaxHealth() * 0.3f; + float plainHp = plain.health; + + // A grenadier, a rocketeer, a brawler and a flamer, each with a stand-in at its range. + BasePlayer grenadier = Spawn(at[1], "t_kit"); + Give(grenadier, "grenade.f1", 2); + BasePlayer gTarget = StandIn(OnMesh(at[1] + new Vector3(12, 0, 0)), 60); + Vector3 rAt, rTo; + if (!ClearLine(at[2], 25f, out rAt, out rTo)) { rAt = at[2]; rTo = at[2] + new Vector3(25, 0, 0); Note("s5.kit.rocket: no clear 25 m line found near its spot"); } + BasePlayer rocketeer = Spawn(rAt, "t_kit"); + Give(rocketeer, "rocket.launcher", 1); + Give(rocketeer, "ammo.rocket.basic", 2); + ScientistNPC rTarget = SpawnScientist(rTo); + BasePlayer brawler = Spawn(at[3], "t_kit"); + Give(brawler, "machete", 1); + BasePlayer mTarget = StandIn(OnMesh(at[3] + new Vector3(1.5f, 0, 0)), 62); + BasePlayer flamer = Spawn(at[4], "t_kit"); + Give(flamer, "flamethrower", 1); + Give(flamer, "lowgradefuel", 50); + // An empty tank, so the extra must burn the kit's fuel (D265: only extras are used up). + foreach (Item it in Items(flamer)) + if (it.GetHeldEntity() is FlameThrower) ((FlameThrower)it.GetHeldEntity()).ammo = 0; + BasePlayer near = StandIn(OnMesh(at[4] + new Vector3(4, 0, 0)), 63); + Sense(flamer, near); + + bool flameHeld = false; + for (int t = 0; t < 45; t++) + { + yield return new WaitForSeconds(1f); + if (t % 5 == 0) + { + if (!Gone(flamer)) Sense(flamer, near); + if (!Gone(grenadier)) Sense(grenadier, gTarget); + if (!Gone(brawler)) Sense(brawler, mTarget); + } + Item held = Gone(flamer) ? null : flamer.GetActiveItem(); + if (held == null || held.info.shortname != "flamethrower") continue; + // Its tank emptied while it is held, then one shot: the extra must burn the kit's fuel (D265). + FlameThrower tank = held.GetHeldEntity() as FlameThrower; + if (!flameHeld && tank != null) + { + tank.ammo = 0; + ((NPCPlayer)flamer).ShotTest(4f); + } + + flameHeld = true; + } + + Dictionary Used(BasePlayer x) => Gone(x) ? null : Describe(x)?["kitUsed"] as Dictionary; + int UsedN(BasePlayer x, string k) => Used(x) != null ? Convert.ToInt32(Used(x)[k]) : -1; + + Check("s5.kit.heal", UsedN(healer, "heals") > 0 && Count(healer, "syringe.medical") < 2, $"heals {UsedN(healer, "heals")}, syringes left {(Gone(healer) ? -1 : Count(healer, "syringe.medical"))}, health {(Gone(healer) ? 0 : healer.health):0}"); + Check("s5.kit.offByDefault", !Gone(plain) && plain.health <= plainHp + 0.5f && Count(plain, "syringe.medical") == 2, $"t_roam: health {plainHp:0}→{(Gone(plain) ? 0 : plain.health):0}, syringes {(Gone(plain) ? -1 : Count(plain, "syringe.medical"))}"); + Check("s5.kit.grenade", UsedN(grenadier, "throws") > 0 && Count(grenadier, "grenade.f1") < 2, $"throws {UsedN(grenadier, "throws")}, grenades left {(Gone(grenadier) ? -1 : Count(grenadier, "grenade.f1"))}"); + Check("s5.kit.rocket", (UsedN(rocketeer, "rockets") > 0 || Gone(rTarget)) && Count(rocketeer, "ammo.rocket.basic") < 2, $"rockets {UsedN(rocketeer, "rockets")}, left {(Gone(rocketeer) ? -1 : Count(rocketeer, "ammo.rocket.basic"))}"); + Check("s5.kit.melee", UsedN(brawler, "meleeHits") > 0, $"melee swings {UsedN(brawler, "meleeHits")}"); + Check("s5.kit.flame", flameHeld && !Gone(flamer) && Count(flamer, "lowgradefuel") < 50, $"held={flameHeld}, fuel left {(Gone(flamer) ? -1 : Count(flamer, "lowgradefuel"))}"); + Api("RunicNpc_DespawnOwner", Owner); + } + /// A route, as RunicNpc_Routes returns it. private sealed class Route {