diff --git a/plugin.toml b/plugin.toml index 82d231e..66c490f 100644 --- a/plugin.toml +++ b/plugin.toml @@ -24,9 +24,11 @@ # # 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else. # 2 was stage 2's whole API, PLAN.md §4. -# Current: 3 — stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the -# OnRunicNpcPlacementChanged hook, documented in docs/runicnpc/API.md. -api = 3 +# 3 was stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the +# OnRunicNpcPlacementChanged hook. +# Current: 4 — stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5 +# fields, a placement's tether, and the OnRunicNpcEscortEnded hook, documented in docs/runicnpc/API.md. +api = 4 # ── Framework floors ───────────────────────────────────────────────────────── # @@ -45,6 +47,6 @@ min_carbon_version = "2.0.259" # `doctor`, which reports a missing one rather than installing it. The PR check # holds this list and the plugin's `// Requires:` lines equal. # -# ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6), and a -# profile that asks for one on a server without it is refused when it is saved. +# ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6). A placement +# or an event step that asks for one on a server without it is refused (D272). requires_plugins = ["Kits"] diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index d40df64..5670545 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -58,7 +58,7 @@ namespace Oxide.Plugins /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// - private const int ApiVersion = 3; + private const int ApiVersion = 4; /// /// Every hook this plugin implements. rnpc.status reports which have fired, because @@ -70,7 +70,11 @@ namespace Oxide.Plugins "OnServerInitialized", "OnServerSave", "OnEntityKill", - "OnPluginUnloaded" + "OnPluginUnloaded", + "OnEntitySpawned", + "OnEntityTakeDamage", + "CanBeTargeted", + "CanEntityTakeDamage" }; private readonly Dictionary _hookCounts = new Dictionary(); @@ -200,7 +204,55 @@ namespace Oxide.Plugins [JsonProperty("sleepDistance")] public float SleepDistance = 160f; [JsonProperty("healthThresholds")] public List HealthThresholds = new List(); + // ---- stage 5 (D254–D261, D268, D271) ---- + + /// Its faction (D254), or null: then its allies are its own profile's NPCs (D256). + [JsonProperty("faction")] public string Faction; + + /// + /// Its own exceptions, which win over the faction table (D254): a faction, scientists, + /// animals or profile:<name>, each hostile, neutral or allied. + /// + [JsonProperty("relations")] public Dictionary Relations = new Dictionary(); + + /// Metres within which its allies join a fight one of them is in (D256); 0 is off. + [JsonProperty("alertRadius")] public float AlertRadius = 40f; + + /// default, ignore or always (D259, D264, D267). + [JsonProperty("turrets")] public string Turrets = "default"; + + /// D271: off makes it unkillable by players, on every server, PVE or not. + [JsonProperty("hurtByPlayers")] public bool HurtByPlayers = true; + + /// D271: off makes it harmless to players; it still fights NPCs and turrets. + [JsonProperty("hurtsPlayers")] public bool HurtsPlayers = true; + + /// The kit's extras it uses (D260); all off by default, and each one used is used up (D265). + [JsonProperty("kitUse")] public KitUse KitUse = new KitUse(); + [JsonIgnore] public bool IsSentry => Role == "sentry"; + + [JsonIgnore] public bool IsGuard => Role == "guard"; + } + + /// D260: the behaviours a kit's extra items allow, each opted into. + public sealed class KitUse + { + [JsonProperty("heal")] public bool Heal; + [JsonProperty("grenades")] public bool Grenades; + [JsonProperty("melee")] public bool Melee; + [JsonProperty("rockets")] public bool Rockets; + [JsonProperty("flamethrower")] public bool Flamethrower; + + [JsonIgnore] public bool Any => Heal || Grenades || Melee || Rockets || Flamethrower; + } + + /// One row of the faction table: a pair, both ways (D268). + public sealed class FactionPair + { + [JsonProperty("a")] public string A; + [JsonProperty("b")] public string B; + [JsonProperty("relation")] public string Relation = "neutral"; } private sealed class ProfileFile @@ -208,6 +260,9 @@ namespace Oxide.Plugins /// True once a site has pushed profiles (D221); in-game edits are then refused. [JsonProperty("managed")] public bool Managed; [JsonProperty("profiles")] public Dictionary Profiles = new Dictionary(); + + /// The faction table (D254), pushed with the profiles; rnpc.faction edits it standalone. + [JsonProperty("factions")] public List Factions = new List(); } /// @@ -229,6 +284,9 @@ namespace Oxide.Plugins /// Overrides the profile's movement for this placement only (D233); null keeps it. [JsonProperty("movement", NullValueHandling = NullValueHandling.Ignore)] public Movement Movement; + + /// A ZoneManager zone its NPCs never leave (D272); null for none. + [JsonProperty("tether", NullValueHandling = NullValueHandling.Ignore)] public string Tether; } private sealed class PlacementFile @@ -281,8 +339,7 @@ namespace Oxide.Plugins private void LoadData() { - _profileFile = ReadData(DataProfiles); - if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); + ReadProfileFile(); _routeFile = ReadData(DataRoutes); if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary(); _placementFile = ReadData(DataPlacements); @@ -295,6 +352,13 @@ namespace Oxide.Plugins SavePlacements(); } + private void ReadProfileFile() + { + _profileFile = ReadData(DataProfiles); + if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); + if (_profileFile.Factions == null) _profileFile.Factions = new List(); + } + private void SaveProfiles() => Interface.Oxide.DataFileSystem.WriteObject(DataProfiles, _profileFile); private void SaveRoutes() => Interface.Oxide.DataFileSystem.WriteObject(DataRoutes, _routeFile); @@ -321,6 +385,17 @@ namespace Oxide.Plugins foreach (KeyValuePair kv in _refused) PrintWarning($"Profile '{kv.Key}' is refused: {kv.Value}"); + + string factions = ValidateFactions(_profileFile.Factions); + if (factions != null) + { + PrintWarning($"The faction table is ignored: {factions}"); + _factionTable.Clear(); + } + else + BuildFactionTable(_profileFile.Factions); + + RebuildHunts(); } /// Null when the profile can spawn; otherwise the reason, for the log and the caller. @@ -336,8 +411,8 @@ namespace Oxide.Plugins return $"kits: this server has no kit '{kit}'"; if (ResolvePrefab(p.Prefab) == null) return $"prefab: '{p.Prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)"; - if (p.Role != "roamer" && p.Role != "sentry") - return $"role: '{p.Role}' is not roamer or sentry"; + if (p.Role != "roamer" && p.Role != "sentry" && p.Role != "guard") + return $"role: '{p.Role}' is not roamer, sentry or guard"; string movement = ValidateMovement(p.Movement); if (movement != null) return movement; if (!(p.Health > 0f)) return "health: must be above 0"; @@ -351,9 +426,75 @@ namespace Oxide.Plugins if (p.SleepDistance < 0f) return "sleepDistance: 0 (never sleeps) or more"; if (p.HealthThresholds == null || p.HealthThresholds.Any(t => !(t > 0f && t < 1f))) return "healthThresholds: fractions between 0 and 1"; + if (!string.IsNullOrEmpty(p.Faction) && (!NameRule.IsMatch(p.Faction) || BuiltInFactions.Contains(p.Faction))) + return $"faction: '{p.Faction}' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)"; + if (p.Relations == null) + return "relations: an object, even an empty one"; + foreach (KeyValuePair kv in p.Relations) + { + if (!IsFactionKey(kv.Key, true)) + return $"relations: '{kv.Key}' is not a faction, scientists, animals or profile:"; + if (!Relations.Contains(kv.Value)) + return $"relations.{kv.Key}: '{kv.Value}' is not hostile, neutral or allied"; + } + + if (!(p.AlertRadius >= 0f)) return "alertRadius: 0 (off) or more"; + if (p.Turrets != "default" && p.Turrets != "ignore" && p.Turrets != "always") + return $"turrets: '{p.Turrets}' is not default, ignore or always"; + if (p.KitUse == null) return "kitUse: an object, even an empty one"; return null; } + // ---- the faction table (D254, D268) ---- + + /// Rust's own two factions (D254). Neither can be a profile's faction. + private static readonly HashSet BuiltInFactions = new HashSet { "scientists", "animals" }; + + private static readonly HashSet Relations = new HashSet { "hostile", "neutral", "allied" }; + + /// A faction name, one of Rust's two, or (where a profile names it) profile:<name>. + private static bool IsFactionKey(string key, bool profileAllowed) + { + if (string.IsNullOrEmpty(key)) return false; + if (BuiltInFactions.Contains(key) || NameRule.IsMatch(key)) return true; + return profileAllowed && key.StartsWith("profile:") && NameRule.IsMatch(key.Substring(8)); + } + + /// Null when the table can be used; otherwise the first problem. + private static string ValidateFactions(List pairs) + { + if (pairs == null) return "it is missing"; + var seen = new HashSet(); + for (int i = 0; i < pairs.Count; i++) + { + FactionPair f = pairs[i]; + if (f == null || !IsFactionKey(f.A, false) || !IsFactionKey(f.B, false)) + return $"factions[{i}]: a and b are faction names (1–40 of a-z, 0-9, _ and -), scientists or animals"; + if (f.A == f.B) + return $"factions[{i}]: a faction is always allied to itself"; + if (BuiltInFactions.Contains(f.A) && BuiltInFactions.Contains(f.B)) + return $"factions[{i}]: both are Rust's own, and RunicNPC does not change how Rust's NPCs treat each other"; + if (!Relations.Contains(f.Relation)) + return $"factions[{i}]: '{f.Relation}' is not hostile, neutral or allied"; + if (!seen.Add(PairKey(f.A, f.B))) + return $"factions[{i}]: {f.A} and {f.B} are given twice (one row is both ways, D268)"; + } + + return null; + } + + /// The faction table, by pair, either way round (D268). + private readonly Dictionary _factionTable = new Dictionary(); + + private static string PairKey(string a, string b) => string.CompareOrdinal(a, b) < 0 ? a + "|" + b : b + "|" + a; + + private void BuildFactionTable(List pairs) + { + _factionTable.Clear(); + foreach (FactionPair f in pairs) + _factionTable[PairKey(f.A, f.B)] = f.Relation; + } + private static string ValidateMovement(Movement m) { if (m == null) return "movement: missing"; @@ -400,9 +541,15 @@ namespace Oxide.Plugins if (movement != null) return movement; } + if (p.Tether != null && !TetherNameRule.IsMatch(p.Tether)) + return $"tether: '{p.Tether}' is not a ZoneManager zone id"; + return null; } + /// ZoneManager's zone ids are free text; RunicNPC asks only that they be one plain word. + private static readonly Regex TetherNameRule = new Regex(@"^[A-Za-z0-9_.:-]{1,64}$"); + private static string ValidateRoute(Route r) { if (r == null || r.Points == null || r.Points.Count < 2) return "points: at least two"; @@ -700,7 +847,7 @@ namespace Oxide.Plugins /// Our NPC. What only a subclass can do (PLAN.md §2): its own name, its name on the /// victim's death screen, its attack range, and damage taken by body part. /// - public class RunicNpcPlayer : ScientistNPC, IAIAttack + public class RunicNpcPlayer : ScientistNPC, IAIAttack, IAISenses { public string RnName = "Scientist"; public string ProfileName; @@ -709,6 +856,7 @@ namespace Oxide.Plugins public string PlacementId; public int Slot = -1; public Vector3 Home; + public float HomeYaw; public string MoveKind = "wander"; public float MoveRadius = 20f; public string RouteName; @@ -745,6 +893,102 @@ namespace Oxide.Plugins public new float EngagementRange() => Profile != null ? Profile.Ranges.Attack : base.EngagementRange(); + // ---- stage 5: whom it fights, beside Rust's own design (D231, D254–D272) ---- + + /// + /// Its own target: an NPC, an animal or a turret. Rust's design still owns every fight with a + /// player; this one is fought by when the design has none. + /// + public BaseCombatEntity NpcTarget; + + /// True while it fights : its roam state stands aside (D262). + public bool Engaged; + + /// Whom it escorts (D258, D269), or null. Its leash and home follow it. + public BaseEntity Escort; + + /// The clan, team or lone player it is allied to (D257, D270); 0 for none. + public long AllyClan; + public ulong AllyTeam; + public ulong AllyPlayer; + + /// The ZoneManager zone it never leaves (D272), or null. + public string Tether; + + public int HuntsVersion = -1; + public HashSet Hunts; + public float NextSense, NextLos, NextMove, NextAlert, NextDefend; + public bool TargetLos; + public float LastHurtAt = float.NegativeInfinity; + public float NextHeal, NextThrow, NextRocket, KitBusyUntil; + public int Heals, Throws, Rockets, MeleeHits, ShotsMarked; + + public bool HasAlly => AllyClan != 0L || AllyTeam != 0UL || AllyPlayer != 0UL; + + /// Where its leash is measured from: the escorted entity while it has one, else its spot. + public Vector3 Anchor => Escort != null && !Escort.IsDestroyed ? Escort.transform.position : Home; + + /// + /// Rust's HumanNPC.IsTarget (players, pets, scarecrows), less anyone it is allied to or + /// escorting (D257, D269). IAISenses is re-implemented so Rust's senses ask here. + /// + public new bool IsTarget(BaseEntity entity) => + base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity)); + + public new bool IsThreat(BaseEntity entity) => IsTarget(entity); + + public new bool IsFriendly(BaseEntity entity) => base.IsFriendly(entity); + + private BaseCombatEntity _marked; + private BaseCombatEntity.Faction _markedWas; + + /// + /// Rust drops every hit an NPC's bullet makes on another NPC unless the victim's faction is + /// Horror (BaseProjectile.ServerUse, stage 5 spike). So its own NPC target alone is + /// made Horror for the length of its own shot, then put back. ShotTest calls TriggerDown + /// inside itself: a mark is made only when none is in place, or the inner call would save + /// Horror as the faction to restore and leave the target Horror for everyone's bullets. + /// + private bool Mark() + { + if (_marked != null) + return false; + BaseCombatEntity t = NpcTarget; + if (!Engaged || t == null || t.IsDestroyed || !t.IsNpc) + return false; + _marked = t; + _markedWas = t.faction; + t.faction = BaseCombatEntity.Faction.Horror; + ShotsMarked++; + return true; + } + + private void Unmark(bool marked) + { + if (!marked) + return; + if (_marked != null && !_marked.IsDestroyed) + _marked.faction = _markedWas; + _marked = null; + } + + /// A single-shot weapon fires inside ShotTest, a burst weapon from TriggerDown. + public override bool ShotTest(float targetDist) + { + if (_self != null && !_self.FuelExtra(this)) + return false; + bool marked = Mark(); + try { return base.ShotTest(targetDist); } + finally { Unmark(marked); } + } + + public override void TriggerDown() + { + bool marked = Mark(); + try { base.TriggerDown(); } + finally { Unmark(marked); } + } + public override void Hurt(HitInfo info) { if (Profile != null && info != null) @@ -758,6 +1002,11 @@ namespace Oxide.Plugins if (!Mathf.Approximately(scale, 1f)) info.damageTypes.ScaleAll(scale); + // D271: "players can hurt it" off. RunicNPC's own rule, so it holds with a PVE plugin + // or without one; NextGenPVE ignores a "deny" answer (stage 5 spike). + BasePlayer by = info.InitiatorPlayer; + if (!Profile.HurtByPlayers && by != null && !by.IsNpc) + info.damageTypes.ScaleAll(0f); } // A contributor is credited with the health it actually took, after Rust's own @@ -772,7 +1021,15 @@ namespace Oxide.Plugins if (attackerId != 0UL && !Died) Credit(attackerId, _healthBefore - health); if (Profile != null && !IsDead()) + { _self?.CheckThresholds(this); + // An unkillable NPC (D271) takes nothing and still answers the attack. + if (info != null && info.Initiator != null) + { + LastHurtAt = Time.time; + _self?.NpcHurt(this, info); + } + } } private ulong _pendingAttacker; @@ -855,15 +1112,59 @@ namespace Oxide.Plugins foreach (AIState type in SentryHolds) if (states.ContainsKey(type)) Replace(new HoldState(type, Aggressive.Contains(type))); + return; } - else if (npc.MoveKind == "wander" || npc.MoveKind == "route") + + // Every other NPC's roam is ours (stage 5): it stands aside while ours fights an NPC + // (D262), follows an escort (D269), and otherwise walks as its role and movement say. + // Rust's own roam and chase both need an AI zone's move points, so outside a monument + // they do nothing (stage 1, Q3; stage 2 found the chase's Error branch): a guard, a + // wanderer and a route walker get ours, and a monument roamer keeps both of Rust's. + BasicAIState inner; + if (npc.Profile != null && npc.Profile.IsGuard) + inner = new RnGuardState(); + else if (npc.MoveKind == "wander") + inner = new WanderState(); + else if (npc.MoveKind == "route") + inner = new RouteState(); + else { - // Rust's own roam and chase both need an AI zone's move points, so outside a - // monument they do nothing (stage 1, Q3; stage 2 found the chase's Error branch). - // A monument roamer keeps both of Rust's. - Replace(npc.MoveKind == "wander" ? (BasicAIState)new WanderState() : new RouteState()); - Replace(new PursueState()); + BasicAIState rust; + inner = states.TryGetValue(AIState.Roam, out rust) ? rust : null; } + + if (inner != null) + Replace(new RnRoamState(inner)); + if (npc.MoveKind != "monument" || (npc.Profile != null && npc.Profile.IsGuard)) + Replace(new PursueState()); + } + + /// Whether Rust's design is fighting a player now; ours then leaves the fight to it. + public bool FightingAPlayer() + { + if (CurrentState == null || !CurrentState.AgrresiveState || Senses?.Memory == null) + return false; + foreach (BaseEntity e in Senses.Memory.Targets) + { + BasePlayer p = e as BasePlayer; + if (p != null && !p.IsNpc && p.IsAlive()) + return true; + } + + return false; + } + + /// Wakes a sleeping NPC at once, for an ally's alarm (D256) or a defence (D257). + public void Rouse() + { + if (Rest == RestState.Awake) + return; + bool asleep = Rest == RestState.Asleep; + Rest = RestState.Awake; + _nextCheck = Time.time + 10f; + _playerNear = true; + if (asleep) + ((IAISleepable)this).WakeAI(); } private void Replace(BasicAIState state) @@ -891,6 +1192,14 @@ namespace Oxide.Plugins _playerNear = npc.Profile.SleepDistance <= 0f || PlayerWithin(npc.transform.position, npc.Profile.SleepDistance); if (!Sentry && !Navigator.IsOnNavMeshLink) Reground(npc); + _self?.CheckEscort(npc); + + // D272: outside its zone (pushed, or the zone moved), it forgets the fight and walks home. + if (!Sentry && npc.Tether != null && _self != null && !_self.InTether(npc, npc.transform.position)) + { + Tethered++; + Leash(npc); + } } switch (Rest) @@ -914,21 +1223,21 @@ namespace Oxide.Plugins } lastThinkTime = now; - if (Flat(npc.transform.position - npc.Home) <= 2f || now >= _homeBy) + if (Flat(npc.transform.position - npc.Anchor) <= 2f || now >= _homeBy) Sleep(); - else if (!Navigator.Moving && !Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal)) + else if (!Navigator.Moving && !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal)) Sleep(); return; default: - if (!_playerNear && (CurrentState == null || !CurrentState.AgrresiveState)) + if (!_playerNear && !npc.Engaged && (CurrentState == null || !CurrentState.AgrresiveState)) { // D235: walk home first, then sleep. Nothing thinks on the way: no player // is within the sleep distance, so there is nothing to sense or fight. Rest = RestState.GoingHome; _homeBy = now + 120f; lastThinkTime = now; - if (Sentry || !Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal)) + if (Sentry || !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal)) Sleep(); return; } @@ -937,12 +1246,28 @@ namespace Oxide.Plugins } if (!Sentry && npc.Profile.Ranges.Chase > 0f && CurrentState != null && CurrentState.AgrresiveState && - Flat(npc.transform.position - npc.Home) > npc.Profile.Ranges.Chase) + Flat(npc.transform.position - npc.Anchor) > npc.Profile.Ranges.Chase) Leash(npc); base.Think(delta); + + // Stage 5: our own fight, after Rust's, so Rust's design still owns every fight with a + // player. It costs nothing for an NPC with no faction settings and no target (D255). + if (_self != null && !npc.IsDead()) + { + long t0 = Stopwatch.GetTimestamp(); + _self.Fight(npc, this, delta); + FightTicks++; + FightMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency; + } } + public int Tethered; + + /// What our own fight layer has cost, for rnpc.status and the cost warning. + public static double FightMs; + public static long FightTicks; + /// /// D239: the floor under a roamer was destroyed. Rust leaves the NPC standing in the air /// where the floor was (stage 3 measured 15 s and counting), so it is put on the nearest @@ -971,6 +1296,8 @@ namespace Oxide.Plugins public void Leash(RunicNpcPlayer npc) { Leashes++; + npc.NpcTarget = null; + npc.Engaged = false; Rust.Ai.SimpleAIMemory memory = Senses.Memory; memory.All.Clear(); memory.Players.Clear(); @@ -979,7 +1306,8 @@ namespace Oxide.Plugins memory.LOS.Clear(); Events.Memory.Entity.Clear(); Senses.DelaySenseUpdate(5f); - Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Fast); + npc.NextSense = Time.time + 5f; + Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Fast); } private void Sleep() @@ -1017,6 +1345,7 @@ namespace Oxide.Plugins Vector2 offset = UnityEngine.Random.insideUnitCircle * npc.MoveRadius; NavMeshHit hit; if (SampleNavmesh(npc.Home + new Vector3(offset.x, 0f, offset.y), 3f, out hit) && + (npc.Tether == null || _self == null || _self.InTether(npc, hit.position)) && brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Slow)) { _status = StateStatus.Running; @@ -1040,6 +1369,163 @@ namespace Oxide.Plugins } } + /// + /// The roam state of every NPC that is not a sentry (stage 5). It hands the NPC to one of + /// three things, in order: + /// + /// + /// an escort (D269): it keeps within a few metres of whom it escorts; + /// its own fight with an NPC (D231): it stands aside while + /// moves the NPC, and a route walker resumes at the point it was heading to (D262); + /// otherwise its role's own roam: a guard's hold, our wander or route, or Rust's own + /// monument roam. + /// + /// + public class RnRoamState : BaseAIBrain.BasicAIState + { + private readonly BaseAIBrain.BasicAIState _inner; + private bool _innerActive; + private float _nextFollow; + + /// How close an escort keeps, and how far it lets its charge get before it runs. + private const float Keep = 4f; + private const float Run = 12f; + + public RnRoamState(BaseAIBrain.BasicAIState inner) : base(AIState.Roam) + { + _inner = inner; + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _innerActive = false; + _nextFollow = 0f; + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc != null && !Elsewhere(npc)) + EnterInner(brain, entity); + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc == null) + return StateStatus.Error; + + if (Elsewhere(npc)) + { + LeaveInner(brain, entity); + if (npc.Escort != null && !npc.Engaged) + Follow(brain, npc); + return StateStatus.Running; + } + + if (!_innerActive) + EnterInner(brain, entity); + return _inner.StateThink(delta, brain, entity); + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + LeaveInner(brain, entity); + brain.Navigator.ClearFacingDirectionOverride(); + } + + private static bool Elsewhere(RunicNpcPlayer npc) => npc.Engaged || (npc.Escort != null && !npc.Escort.IsDestroyed); + + private void EnterInner(BaseAIBrain brain, BaseEntity entity) + { + _inner.StateEnter(brain, entity); + _innerActive = true; + } + + private void LeaveInner(BaseAIBrain brain, BaseEntity entity) + { + if (!_innerActive) + return; + _inner.StateLeave(brain, entity); + _innerActive = false; + } + + /// D269: keep close to whom it escorts, running when they get away. + private void Follow(BaseAIBrain brain, RunicNpcPlayer npc) + { + if (Time.time < _nextFollow) + return; + _nextFollow = Time.time + 0.5f; + Vector3 there = npc.Escort.transform.position; + float distance = Vector3.Distance(npc.transform.position, there); + if (distance <= Keep + 1f) + { + if (brain.Navigator.Moving) + brain.Navigator.Stop(); + return; + } + + // A spot a little short of the escorted entity, on the side ours comes from, so a squad + // gathers round rather than on top of it. + Vector3 from = (npc.transform.position - there); + from.y = 0f; + Vector3 goal = there + (from.sqrMagnitude > 0.01f ? from.normalized : Vector3.forward) * Keep; + NavMeshHit hit; + if (SampleNavmesh(goal, 3f, out hit) || SampleNavmesh(there, 3f, out hit)) + brain.Navigator.SetDestination(hit.position, distance > Run ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal); + } + } + + /// + /// A guard's roam (stage 5): it holds its spot facing the way it was placed, and walks back + /// to it after a chase. Its chase range is its leash, as for any roamer. + /// + public class RnGuardState : BaseAIBrain.BasicAIState + { + private float _next; + + public RnGuardState() : base(AIState.Roam) + { + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _next = 0f; + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc == null) + return StateStatus.Error; + if (Time.time < _next) + return StateStatus.Running; + _next = Time.time + 1f; + + if (Flat(npc.transform.position - npc.Home) > 2f) + { + if (!brain.Navigator.Moving) + brain.Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal); + return StateStatus.Running; + } + + if (brain.Navigator.Moving) + brain.Navigator.Stop(); + brain.Navigator.SetFacingDirectionOverride(Quaternion.Euler(0f, npc.HomeYaw, 0f) * Vector3.forward); + return StateStatus.Running; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.Stop(); + brain.Navigator.ClearFacingDirectionOverride(); + } + + private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude; + } + /// D233 route:<name>: walks a recorded route's points in order. public class RouteState : BaseAIBrain.BasicAIState { @@ -1193,11 +1679,19 @@ namespace Oxide.Plugins Vector3 goal = there - (there - here).normalized * stopAt; float chase = npc.Profile.Ranges.Chase; - Vector3 fromHome = goal - npc.Home; + Vector3 anchor = npc.Anchor; + Vector3 fromHome = goal - anchor; fromHome.y = 0f; bool clamped = chase > 0f && fromHome.magnitude > chase; if (clamped) - goal = npc.Home + fromHome.normalized * chase; + goal = anchor + fromHome.normalized * chase; + + // D272: the zone's edge is a leash too. Past it, ours stands and waits, as at the chase range. + if (npc.Tether != null && _self != null && !_self.InTether(npc, goal)) + { + goal = here; + clamped = true; + } if (clamped && distance > npc.Profile.Ranges.Attack) { @@ -1264,6 +1758,1053 @@ namespace Oxide.Plugins #endregion + #region Factions, sensing and the fight (stage 5) + + // Rust's own design still owns every fight with a player. What is ours: whom else an NPC fights + // (D231, D254, D255), its allies' alarm (D256), the clan or team it serves (D257), escorts (D258, + // D269), turrets (D259, D264, D267), the kit's extras (D260), PVE (D261, D271) and the tether (D272). + // An NPC with no faction settings, no ally and no escort costs one dictionary look-up a think. + + /// Rust's scientists (not ours), for the profiles hostile to scientists. + private readonly HashSet _scientists = new HashSet(); + + /// Rust's animals, old and new, for the profiles hostile to animals. + private readonly HashSet _animals = new HashSet(); + + /// Our live NPCs by profile, for the profiles hostile to another profile. + private readonly Dictionary> _byProfile = new Dictionary>(); + + /// Our NPCs with an ally or an escort: the only ones a damage event is checked for (D257, D269). + private readonly HashSet _defenders = new HashSet(); + + /// Moves whenever profiles or the faction table change, so each NPC recomputes whom it hunts. + private int _huntsVersion; + + private static bool IsScientist(BaseEntity e) => e is ScientistNPC && !(e is RunicNpcPlayer); + + private static bool IsAnimal(BaseEntity e) => e is BaseNpc || e is Rust.Ai.Gen2.BaseNPC2; + + private void Track(BaseNetworkable entity) + { + BaseCombatEntity e = entity as BaseCombatEntity; + if (e == null || e is RunicNpcPlayer) + return; + if (IsScientist(e)) + _scientists.Add(e); + else if (IsAnimal(e)) + _animals.Add(e); + } + + private void Untrack(BaseNetworkable entity) + { + BaseCombatEntity e = entity as BaseCombatEntity; + if (e == null) + return; + _scientists.Remove(e); + _animals.Remove(e); + } + + private void TrackWorld() + { + _scientists.Clear(); + _animals.Clear(); + foreach (BaseNetworkable e in BaseNetworkable.serverEntities) + Track(e); + } + + private void RebuildHunts() => _huntsVersion++; + + /// A profile's faction, or profile:<name> when it has none: its allies are then its own (D256). + private static string FactionOf(string profileName, Profile p) => string.IsNullOrEmpty(p.Faction) ? "profile:" + profileName : p.Faction; + + private static string FactionOf(RunicNpcPlayer npc) => FactionOf(npc.ProfileName, npc.Profile); + + /// + /// How a profile treats a faction: its own exceptions first (D254), then its own faction + /// (allied), then the table, both ways (D268); neutral when nothing says otherwise. + /// + private string RelationOf(string profileName, Profile p, string otherFaction, string otherProfile) + { + string r; + if (otherProfile != null && p.Relations.TryGetValue("profile:" + otherProfile, out r)) + return r; + if (p.Relations.TryGetValue(otherFaction, out r)) + return r; + string mine = FactionOf(profileName, p); + if (mine == otherFaction) + return "allied"; + return _factionTable.TryGetValue(PairKey(mine, otherFaction), out r) ? r : "neutral"; + } + + /// How one of ours treats an entity: a relation for NPCs and animals, null for anything else. + private string RelationTo(RunicNpcPlayer npc, BaseEntity e) + { + RunicNpcPlayer other = e as RunicNpcPlayer; + if (other != null) + return other.Profile == null ? null : RelationOf(npc.ProfileName, npc.Profile, FactionOf(other), other.ProfileName); + if (IsScientist(e)) + return RelationOf(npc.ProfileName, npc.Profile, "scientists", null); + if (IsAnimal(e)) + return RelationOf(npc.ProfileName, npc.Profile, "animals", null); + return null; + } + + /// D256: an ally is the same faction, or the same profile when there is no faction. + private static bool SameSide(RunicNpcPlayer a, RunicNpcPlayer b) => + a.Profile != null && b.Profile != null && FactionOf(a) == FactionOf(b); + + /// The registry keys an NPC hunts: scientists, animals and ours:<profile>. Null for none (D255). + private HashSet HuntsOf(RunicNpcPlayer npc) + { + if (npc.HuntsVersion == _huntsVersion) + return npc.Hunts; + npc.HuntsVersion = _huntsVersion; + var hunts = new HashSet(); + if (RelationOf(npc.ProfileName, npc.Profile, "scientists", null) == "hostile") + hunts.Add("scientists"); + if (RelationOf(npc.ProfileName, npc.Profile, "animals", null) == "hostile") + hunts.Add("animals"); + foreach (KeyValuePair kv in _profiles) + if (RelationOf(npc.ProfileName, npc.Profile, FactionOf(kv.Key, kv.Value), kv.Key) == "hostile") + hunts.Add("ours:" + kv.Key); + npc.Hunts = hunts.Count > 0 ? hunts : null; + return npc.Hunts; + } + + private IEnumerable Registry(string key) + { + if (key == "scientists") + return _scientists; + if (key == "animals") + return _animals; + HashSet ours; + return key.StartsWith("ours:") && _byProfile.TryGetValue(key.Substring(5), out ours) ? ours.Cast() : null; + } + + private void Index(RunicNpcPlayer npc, bool add) + { + HashSet set; + if (!_byProfile.TryGetValue(npc.ProfileName, out set)) + { + if (!add) + return; + _byProfile[npc.ProfileName] = set = new HashSet(); + } + + if (add) + set.Add(npc); + else + { + set.Remove(npc); + _defenders.Remove(npc); + } + } + + private void NoteDefender(RunicNpcPlayer npc) + { + if (npc.HasAlly || npc.Escort != null) + _defenders.Add(npc); + else + _defenders.Remove(npc); + } + + /// How far from its anchor ours may fight an NPC (D263): its chase range, or for a sentry its attack range. + private static float LeashOf(RunicNpcPlayer npc) => + npc.Profile.IsSentry ? npc.Profile.Ranges.Attack : npc.Profile.Ranges.Chase > 0f ? npc.Profile.Ranges.Chase : npc.Profile.Ranges.Sense; + + private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude; + + // ---- the fight ---- + + /// LOS tests a sense may spend: the nearest few candidates only (stage 5 spike). + private const int LosBudget = 3; + + /// Rust's own line-of-sight mask for an NPC's shots. + private const int LosMask = 1218519041; + + private static readonly BaseCombatEntity[] Best = new BaseCombatEntity[LosBudget]; + private static readonly float[] BestD2 = new float[LosBudget]; + + /// + /// Our own fight, after Rust's think (stage 5). With no player to fight and a target of ours — + /// sensed (D231, D263), learned from an ally (D256), or whoever hurt it or its charge (D257, + /// D268) — it faces it, closes in within its leash and its zone, and hands Rust's own attack + /// routine the target, so every shot is Rust's. + /// + private void Fight(RunicNpcPlayer npc, RunicNpcBrain brain, float delta) + { + float now = Time.time; + HashSet hunts = HuntsOf(npc); + BaseCombatEntity t = npc.NpcTarget; + Vector3 anchor = npc.Anchor; + float leash = LeashOf(npc); + + if (t != null && (t.IsDestroyed || t.IsDead() || Spares(npc, t) || + Vector3.Distance(npc.transform.position, t.transform.position) > Mathf.Max(npc.Profile.Ranges.LoseTarget, leash) + 10f || + Flat(t.transform.position - anchor) > leash + Mathf.Max(npc.Profile.Ranges.Attack, 10f))) + t = npc.NpcTarget = null; + + if (hunts != null && now >= npc.NextSense && TurretOf(t) == null) + { + npc.NextSense = now + 1f; + long s0 = Stopwatch.GetTimestamp(); + BaseCombatEntity found = Sense(npc, hunts, anchor, leash); + SenseMs += (Stopwatch.GetTimestamp() - s0) * 1000.0 / Stopwatch.Frequency; + SenseRuns++; + if (found != null) + t = npc.NpcTarget = found; + } + + bool player = brain.FightingAPlayer(); + npc.Engaged = t != null && !player; + + if (npc.Profile.KitUse.Any) + KitTick(npc, brain, npc.Engaged ? t : PlayerTarget(brain), now); + + if (!npc.Engaged) + return; + + if (now >= npc.NextLos) + { + npc.NextLos = now + 0.5f; + npc.TargetLos = Los(npc, t); + } + + brain.Navigator.SetFacingDirectionEntity(t); + float distance = Vector3.Distance(npc.transform.position, t.transform.position); + if (!npc.Profile.IsSentry) + { + if (!npc.TargetLos || distance > npc.EngagementRange() * 0.9f) + { + if (now >= npc.NextMove) + { + npc.NextMove = now + 1f; + Vector3 goal = t.transform.position; + Vector3 fromAnchor = goal - anchor; + fromAnchor.y = 0f; + if (fromAnchor.magnitude > leash) + goal = anchor + fromAnchor.normalized * leash; + NavMeshHit hit; + if ((npc.Tether == null || InTether(npc, goal)) && SampleNavmesh(goal, 3f, out hit)) + brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Fast); + else if (brain.Navigator.Moving) + brain.Navigator.Stop(); + } + } + else if (brain.Navigator.Moving) + brain.Navigator.Stop(); + } + + if (now >= npc.KitBusyUntil) + npc.AttackTick(delta, t, npc.TargetLos); + } + + /// The player Rust's design is fighting, for the kit's extras (D260). + private static BaseEntity PlayerTarget(RunicNpcBrain brain) + { + if (brain.CurrentState == null || !brain.CurrentState.AgrresiveState) + return null; + return brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot); + } + + public static double SenseMs; + public static long SenseRuns; + + /// + /// The nearest hostile within its sense range AND its leash around its anchor (D263), from the + /// registry rather than a physics query: distances first, then line of sight on the nearest + /// few only. Stage 5's spike measured this at 1.5–2.5 ms a think in a 100-against-100 fight. + /// + private BaseCombatEntity Sense(RunicNpcPlayer npc, HashSet hunts, Vector3 anchor, float leash) + { + for (int i = 0; i < LosBudget; i++) + { + Best[i] = null; + BestD2[i] = float.MaxValue; + } + + Vector3 at = npc.transform.position; + float sense = npc.Profile.IsSentry ? Mathf.Min(npc.Profile.Ranges.Sense, npc.Profile.Ranges.Attack) : npc.Profile.Ranges.Sense; + float r2 = sense * sense; + float leash2 = leash * leash; + foreach (string key in hunts) + { + IEnumerable set = Registry(key); + if (set == null) + continue; + foreach (BaseCombatEntity e in set) + { + if (e == null || e.IsDestroyed || e == npc || e.IsDead()) + continue; + Vector3 p = e.transform.position; + float d2 = (p - at).sqrMagnitude; + if (d2 > r2 || d2 >= BestD2[LosBudget - 1]) + continue; + Vector3 fromAnchor = p - anchor; + if (fromAnchor.x * fromAnchor.x + fromAnchor.z * fromAnchor.z > leash2) + continue; + int k = LosBudget - 1; + while (k > 0 && BestD2[k - 1] > d2) + { + Best[k] = Best[k - 1]; + BestD2[k] = BestD2[k - 1]; + k--; + } + + Best[k] = e; + BestD2[k] = d2; + } + } + + for (int i = 0; i < LosBudget && Best[i] != null; i++) + if ((npc.Tether == null || InTether(npc, Best[i].transform.position)) && Los(npc, Best[i])) + return Best[i]; + return null; + } + + /// + /// 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) + { + RaycastHit hit; + if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore)) + return true; + BaseEntity e = hit.GetEntity(); + return e != null && (e == t || e.EqualNetID(t) || (e.HasParent() && e.GetParentEntity() == t)); + } + + /// + /// The turret behind a hit. A flame turret hurts through its fireballs, whose creator is the + /// turret or the fireball that spawned them, so the chain is followed (stage 5 spike). + /// + private static BaseCombatEntity TurretOf(BaseEntity e) + { + for (int i = 0; i < 6 && e is FireBall; i++) + e = e.creatorEntity == e ? null : e.creatorEntity; + return e is AutoTurret || e is FlameTurret || e is GunTrap ? (BaseCombatEntity)e : null; + } + + /// + /// Whom one of ours never targets: whom it escorts (D269), its allied clan's, team's or player's + /// people (D257), and NPCs it is allied to (D254). + /// + private bool Spares(RunicNpcPlayer npc, BaseEntity e) + { + if (e == null) + return false; + if (npc.Escort != null && (e == npc.Escort || e.EqualNetID(npc.Escort))) + return true; + BasePlayer p = e as BasePlayer; + if (p != null && !p.IsNpc) + return npc.HasAlly && IsAllyMember(npc, p); + return npc.Profile != null && (e is RunicNpcPlayer || IsScientist(e) || IsAnimal(e)) && RelationTo(npc, e) == "allied"; + } + + // ---- hurt: shoot back (D259, D268) and raise the alarm (D256) ---- + + /// Called from the NPC's own Hurt, after Rust's. + private void NpcHurt(RunicNpcPlayer npc, HitInfo info) + { + BaseEntity source = info.Initiator; + BaseCombatEntity attacker = TurretOf(source) ?? TurretOf(info.Weapon != null ? info.Weapon.GetParentEntity() : null) ?? source as BaseCombatEntity; + if (attacker == null || attacker == npc || attacker.IsDestroyed || attacker.IsDead()) + return; + + RunicNpcPlayer ours = attacker as RunicNpcPlayer; + if (ours != null && SameSide(npc, ours)) + return; // an ally's fire or grenade (D266): no fight among friends. + + BasePlayer player = attacker as BasePlayer; + bool real = player != null && !player.IsNpc; + if (real && npc.HasAlly && IsAllyMember(npc, player)) + return; + + // D268: whoever shot it is fought back, whatever the table says. Rust's design already learns + // a player (HumanNPC.Hurt); an NPC, an animal or a turret is ours to fight. + if (!real && (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed)) + npc.NpcTarget = attacker; + + Alarm(npc, attacker, real); + } + + /// D256: every ally within the profile's alert radius learns the attacker, seen or not. + private void Alarm(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real) + { + float radius = npc.Profile.AlertRadius; + float now = Time.time; + if (radius <= 0f || now < npc.NextAlert) + return; + npc.NextAlert = now + 1f; + float r2 = radius * radius; + Vector3 at = npc.transform.position; + foreach (RunicNpcPlayer other in _npcs.Values) + { + if (other == npc || other.IsDestroyed || other.IsDead() || other.Profile == null || !SameSide(npc, other)) + continue; + if ((other.transform.position - at).sqrMagnitude > r2) + continue; + Learn(other, attacker, real); + Alarms++; + } + } + + public static long Alarms, Defences; + + /// Tells one of ours about an attacker: Rust's memory for a player, our own target for anything else. + private void Learn(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real) + { + if (Spares(npc, attacker)) + return; + npc.RnBrain?.Rouse(); + if (real) + { + BaseAIBrain brain = npc.Brain; + if (brain?.Senses?.Memory != null) + brain.Senses.Memory.SetKnown(attacker, npc, brain.Senses); + return; + } + + if (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed) + npc.NpcTarget = attacker; + } + + // ---- allies: a clan, a team or a player (D257, D270) ---- + + private sealed class Members + { + public HashSet Ids = new HashSet(); + public float ReadAt = float.NegativeInfinity; + } + + private readonly Dictionary _members = new Dictionary(); + + /// Whether a player belongs to whom one of ours is allied to. + private bool IsAllyMember(RunicNpcPlayer npc, BasePlayer p) + { + if (npc.AllyPlayer != 0UL && (ulong)p.userID == npc.AllyPlayer) + return true; + if (npc.AllyClan != 0L && p.clanId == npc.AllyClan) + return true; + return npc.AllyTeam != 0UL && p.currentTeam == npc.AllyTeam; + } + + /// + /// Whether a Steam id belongs to whom one of ours is allied to: for what they own (D257), when + /// the owner may be offline. Clan and team rosters are re-read every five seconds at most. + /// + private bool IsAllyMember(RunicNpcPlayer npc, ulong id) + { + if (id == 0UL) + return false; + if (npc.AllyPlayer == id) + return true; + if (npc.AllyClan != 0L && Roster("clan:" + npc.AllyClan).Contains(id)) + return true; + return npc.AllyTeam != 0UL && Roster("team:" + npc.AllyTeam).Contains(id); + } + + private HashSet Roster(string key) + { + Members m; + if (!_members.TryGetValue(key, out m)) + _members[key] = m = new Members(); + float now = Time.realtimeSinceStartup; + if (now - m.ReadAt < 5f) + return m.Ids; + m.ReadAt = now; + m.Ids.Clear(); + if (key.StartsWith("team:")) + { + RelationshipManager.PlayerTeam team = RelationshipManager.ServerInstance != null + ? RelationshipManager.ServerInstance.FindTeam(ulong.Parse(key.Substring(5))) + : null; + if (team?.members != null) + foreach (ulong id in team.members) + m.Ids.Add(id); + } + else + { + IClan clan = ReadClan(long.Parse(key.Substring(5))); + if (clan?.Members != null) + foreach (ClanMember member in clan.Members) + m.Ids.Add(member.SteamId); + } + + return m.Ids; + } + + /// A clan by id from the game's own clans, or null. Never waits: a clan not cached is read only if the read is instant. + private static IClan ReadClan(long clanId) + { + IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null; + if (backend == null) + return null; + IClan clan; + if (backend.TryGet(clanId, out clan) && clan != null) + return clan; + System.Threading.Tasks.ValueTask> read = backend.Get(clanId); + return read.IsCompleted && read.Result.IsSuccess ? read.Result.Value : null; + } + + /// + /// D257 and D269: a damage event against whom ours escort or serve. The attacker is fought by + /// every defender within its leash of the victim; a player, by Rust's own design. + /// + private void Defend(BaseCombatEntity victim, HitInfo info) + { + BaseEntity source = info.Initiator; + BaseCombatEntity attacker = TurretOf(source) ?? source as BaseCombatEntity; + if (attacker == null || attacker == victim || attacker.IsDead()) + return; + BasePlayer victimPlayer = victim as BasePlayer; + BasePlayer attackerPlayer = attacker as BasePlayer; + bool real = attackerPlayer != null && !attackerPlayer.IsNpc; + float now = Time.time; + + foreach (RunicNpcPlayer d in _defenders) + { + if (d == null || d.IsDestroyed || d.IsDead() || d.Profile == null || d == attacker || now < d.NextDefend) + continue; + + bool charge = d.Escort != null && (victim == d.Escort || victim.EqualNetID(d.Escort)); + if (!charge && d.HasAlly) + { + charge = victimPlayer != null && !victimPlayer.IsNpc + ? IsAllyMember(d, victimPlayer) + : victim.OwnerID != 0UL && IsAllyMember(d, victim.OwnerID); + } + + if (!charge) + continue; + + // The attacker is not fought if it is one of the side being defended. + if (real && d.HasAlly && IsAllyMember(d, attackerPlayer)) + continue; + RunicNpcPlayer ours = attacker as RunicNpcPlayer; + if (ours != null && SameSide(d, ours)) + continue; + + float reach = Mathf.Max(LeashOf(d), d.Profile.AlertRadius); + if (Flat(victim.transform.position - d.Anchor) > reach) + continue; + + d.NextDefend = now + 0.5f; + Learn(d, attacker, real); + Defences++; + } + } + + // ---- escort (D258, D269) ---- + + /// + /// Sets whom one of ours escorts, or clears it with null. Null on success, else why not: a + /// sentry never moves, and a dead or disconnected player cannot be escorted. + /// + private string SetEscort(RunicNpcPlayer npc, BaseEntity target) + { + if (target == null) + { + npc.Escort = null; + NoteDefender(npc); + return null; + } + + if (npc.Profile.IsSentry) + return $"{npc.RnName} is a sentry and never moves, so it cannot escort"; + if (target == npc || target.IsDestroyed) + return "there is nothing to escort"; + BasePlayer p = target as BasePlayer; + if (p != null && (!p.IsConnected || p.IsDead()) && !p.IsNpc) + return $"{p.displayName} is not on the server"; + BaseCombatEntity c = target as BaseCombatEntity; + if (c != null && c.IsDead()) + return "it is dead"; + + npc.Escort = target; + npc.NpcTarget = null; + NoteDefender(npc); + npc.RnBrain?.Rouse(); + return null; + } + + /// 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 (ReferenceEquals(e, null)) + return; + 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 : destroyed ? "its charge" : e.ShortPrefabName; + npc.Escort = null; + NoteDefender(npc); + npc.RnBrain?.Navigator?.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal); + Interface.CallHook("OnRunicNpcEscortEnded", npc, npc.ProfileName, npc.Owner); + Puts($"{npc.RnName} ({npc.Owner}) no longer escorts {who}, who is gone; it walks back to its spot."); + } + + // ---- the tether (D272) ---- + + [PluginReference] private Plugin ZoneManager; + + private bool _tetherWarned; + + /// + /// Whether a point is inside one of ours' zone. With ZoneManager gone the NPC is not held, + /// and the log says so once, rather than every NPC freezing where it stands. + /// + private bool InTether(RunicNpcPlayer npc, Vector3 at) + { + if (npc.Tether == null) + return true; + if (ZoneManager == null) + { + if (!_tetherWarned) + PrintWarning("ZoneManager is not loaded, so no NPC is held inside its zone."); + _tetherWarned = true; + return true; + } + + object inside = ZoneManager.Call("IsPositionInZone", npc.Tether, at); + return !(inside is bool) || (bool)inside; + } + + /// Null when a zone can hold NPCs at a point, else why not. + private string TetherRefusal(string zone, Vector3 at) + { + if (zone == null) + return null; + if (ZoneManager == null) + return "tether: ZoneManager is not loaded on this server"; + if (ZoneManager.Call("CheckZoneID", zone) == null) + return $"tether: this server has no zone '{zone}'"; + object inside = ZoneManager.Call("IsPositionInZone", zone, at); + if (inside is bool && !(bool)inside) + return $"tether: the spot is outside the zone '{zone}'"; + return null; + } + + // ---- the kit's extras (D260, D265) ---- + + /// The first item in its inventory whose held entity is a , or null. + private static Item FindHeld(RunicNpcPlayer npc) where T : HeldEntity + { + foreach (ItemContainer container in new[] { npc.inventory.containerBelt, npc.inventory.containerMain }) + { + if (container == null) + continue; + foreach (Item item in container.itemList) + if (item != null && item.GetHeldEntity() is T) + return item; + } + + return null; + } + + /// Puts an item in its hands; only the belt can be held, so it is moved there first. + private static bool Hold(RunicNpcPlayer npc, Item item) + { + if (item == null) + return false; + if (npc.GetActiveItem() == item) + return true; + if (item.parent != npc.inventory.containerBelt && !item.MoveToContainer(npc.inventory.containerBelt)) + return false; + npc.UpdateActiveItem(item.uid); + return true; + } + + /// Back to the main weapon (the belt's first slot, Rust's own rule). + private static void MainWeapon(RunicNpcPlayer npc) + { + Item main = npc.inventory.containerBelt?.GetSlot(0); + if (main != null && npc.GetActiveItem() != main) + npc.EquipWeapon(true); + } + + private static bool IsMain(RunicNpcPlayer npc, Item item) => item != null && npc.inventory.containerBelt?.GetSlot(0) == item; + + /// + /// D260: each extra the profile opts into, when the moment calls for it. Rust's AI uses none of + /// them in open ground (stage 5 spike), so these are ours: heal in a lull, melee close in, + /// the flamethrower within its reach, a grenade at 5–20 m, a rocket at 15–80 m. + /// + private void KitTick(RunicNpcPlayer npc, RunicNpcBrain brain, BaseEntity target, float now) + { + KitUse k = npc.Profile.KitUse; + if (now < npc.KitBusyUntil) + return; + + if (k.Heal && npc.health < npc.MaxHealth() * 0.5f && now - npc.LastHurtAt > 3f && now >= npc.NextHeal) + { + npc.NextHeal = now + (Heal(npc) ? 6f : 15f); + if (now < npc.KitBusyUntil) + return; + } + + if (target == null || target.IsDestroyed) + { + MainWeapon(npc); + return; + } + + float distance = Vector3.Distance(npc.transform.position, target.transform.position); + + if (k.Melee && distance <= 2.5f) + { + Item melee = FindHeld(npc); + if (melee != null && !IsMain(npc, melee) && Hold(npc, melee)) + { + brain.Navigator.SetFacingDirectionEntity(target); + if (npc.MeleeAttack()) + npc.MeleeHits++; + return; + } + } + + if (k.Flamethrower && distance <= 7f) + { + Item flame = FindHeld(npc); + if (flame != null && !IsMain(npc, flame) && HasFuel(npc, flame) && Hold(npc, flame)) + return; // Rust's own attack tick fires what it holds. + } + + if (k.Grenades && distance > 5f && distance < 20f && now >= npc.NextThrow) + { + npc.NextThrow = now + 10f; + Item thrown = FindHeld(npc); + if (thrown != null && (thrown.parent == npc.inventory.containerBelt || thrown.MoveToContainer(npc.inventory.containerBelt)) && + npc.TryUseThrownWeapon(thrown, target, 10f)) + { + // Rust's own throw: aims for 1.5 s, throws, uses one up, and re-equips the main weapon. + npc.Throws++; + npc.KitBusyUntil = now + 2.5f; + return; + } + } + + if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket) + { + 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; + } + } + + Item held = npc.GetActiveItem(); + if (held != null && !IsMain(npc, held)) + { + HeldEntity h = held.GetHeldEntity() as HeldEntity; + bool keep = (h is BaseMelee && distance <= 4f) || (h is FlameThrower && distance <= 9f); + if (!keep) + MainWeapon(npc); + } + } + + /// D260: one syringe, medkit or bandage, used up, its health given at once. + private bool Heal(RunicNpcPlayer npc) + { + Item item = FindHeld(npc); + ItemModConsumable consumable = item?.info.GetComponent(); + if (consumable == null) + return false; + float amount = 0f; + foreach (ItemModConsumable.ConsumableEffect effect in consumable.effects) + if (effect.type == MetabolismAttribute.Type.Health || effect.type == MetabolismAttribute.Type.HealthOverTime) + amount += effect.amount; + if (amount <= 0f) + return false; + + Hold(npc, item); + npc.SignalBroadcast(BaseEntity.Signal.Attack); + item.UseItem(1); + npc.Heal(amount); + npc.Heals++; + npc.KitBusyUntil = Time.time + 1.5f; + timer.Once(1.2f, () => + { + if (npc != null && !npc.IsDestroyed && !npc.IsDead()) + MainWeapon(npc); + }); + return true; + } + + /// + /// D260: a rocket from the kit's launcher, one rocket used up. Rust's AI never fires a launcher + /// (stage 5 spike), so the rocket is launched here, from its eyes, on an arc that lands on the + /// target, and credited to the NPC. + /// + private bool FireRocket(RunicNpcPlayer npc, BaseEntity target) + { + Item launcher = FindHeld(npc); + if (launcher == null) + return false; + Item ammo = null; + ItemModProjectile projectile = null; + foreach (ItemContainer container in new[] { npc.inventory.containerMain, npc.inventory.containerBelt }) + { + if (container == null) + continue; + foreach (Item item in container.itemList) + { + ItemModProjectile p = item?.info.GetComponent(); + if (p != null && p.projectileObject != null && p.projectileObject.isValid && item.info.shortname.StartsWith("ammo.rocket")) + { + ammo = item; + projectile = p; + break; + } + } + + if (ammo != null) + break; + } + + if (ammo == null || !Hold(npc, launcher)) + return false; + + Vector3 from = npc.eyes.position + npc.eyes.BodyForward() * 1f; + Vector3 to = target.CenterPoint(); + BaseEntity rocket = GameManager.server.CreateEntity(projectile.projectileObject.resourcePath, from); + ServerProjectile mover = rocket != null ? rocket.GetComponent() : null; + if (mover == null) + { + if (rocket != null) + UnityEngine.Object.Destroy(rocket.gameObject); + return false; + } + + Vector3 direction = Arc(from, to, mover.speed, -Physics.gravity.y * mover.gravityModifier); + npc.SetAimDirection(direction); + rocket.SetCreatorEntity(npc); + rocket.OwnerID = 0UL; + mover.InitializeVelocity(direction * mover.speed); + rocket.SendMessage("SetDamageScale", npc.damageScale, SendMessageOptions.DontRequireReceiver); + rocket.Spawn(); + 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()) + MainWeapon(npc); + }); + return true; + } + + /// The direction that lands a projectile of this speed and gravity on the point; straight at it when out of reach. + private static Vector3 Arc(Vector3 from, Vector3 to, float speed, float gravity) + { + Vector3 delta = to - from; + Vector3 flat = new Vector3(delta.x, 0f, delta.z); + float x = flat.magnitude; + if (gravity <= 0f || x < 0.5f) + return delta.normalized; + float y = delta.y; + float v2 = speed * speed; + float root = v2 * v2 - gravity * (gravity * x * x + 2f * y * v2); + if (root < 0f) + return delta.normalized; + float angle = Mathf.Atan((v2 - Mathf.Sqrt(root)) / (gravity * x)); + return (flat.normalized * Mathf.Cos(angle) + Vector3.up * Mathf.Sin(angle)).normalized; + } + + private static bool HasFuel(RunicNpcPlayer npc, Item flameItem) + { + FlameThrower f = flameItem.GetHeldEntity() as FlameThrower; + return f != null && (f.ammo > 0 || (f.fuelType != null && npc.inventory.FindItemByItemName(f.fuelType.shortname) != null)); + } + + /// + /// Called from ShotTest. D265: the main weapon is endless, as in Rust. A flamethrower held as an + /// EXTRA burns the kit's fuel: an empty tank is refilled from the inventory, and with none left + /// the NPC goes back to its main weapon. False means "no shot now". + /// + private bool FuelExtra(RunicNpcPlayer npc) + { + Item held = npc.GetActiveItem(); + FlameThrower f = held?.GetHeldEntity() as FlameThrower; + if (f == null || IsMain(npc, held) || f.ammo > 0) + return true; + Item fuel = f.fuelType != null ? npc.inventory.FindItemByItemName(f.fuelType.shortname) : null; + if (fuel == null) + { + MainWeapon(npc); + return false; + } + + int take = Mathf.Min(fuel.amount, f.maxAmmo); + fuel.UseItem(take); + f.ammo = take; + f.SendNetworkUpdateImmediate(); + return true; + } + + // ---- turrets (D259, D264, D267) ---- + + /// + /// turrets: ignore: no auto turret, flame turret or shotgun trap targets it. Answers only + /// for our own NPCs, and null for everything else. + /// + private object CanBeTargeted(BaseCombatEntity entity, MonoBehaviour turret) + { + MarkHook("CanBeTargeted"); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc?.Profile == null || npc.Profile.Turrets != "ignore") + return null; + return false; + } + + private HarmonyLib.Harmony _harmony; + private const string HarmonyId = "runicnpc.sentries"; + private string _sentryPatch = "not tried"; + + /// + /// D267: Outpost's and Bandit Camp's sentries refuse every scientist in Ignore and + /// IsEntityHostile, before any Oxide or Carbon hook runs, so turrets: always is a + /// Harmony patch on those two. Each answers "hostile" for our always NPCs alone and runs + /// Rust's own method for everything else, Rust's scientists and bandit guards included. + /// + private void PatchSentries() + { + try + { + const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + MethodInfo ignore = typeof(NPCAutoTurret).GetMethod("Ignore", any, null, new[] { typeof(BasePlayer) }, null); + MethodInfo hostile = typeof(NPCAutoTurret).GetMethod("IsEntityHostile", any, null, new[] { typeof(BaseCombatEntity) }, null); + if (ignore == null || hostile == null) + { + _sentryPatch = $"not patched: this Rust has no NPCAutoTurret.{(ignore == null ? "Ignore" : "IsEntityHostile")}"; + PrintWarning($"turrets: always cannot reach the safe-zone sentries ({_sentryPatch})."); + return; + } + + _harmony = new HarmonyLib.Harmony(HarmonyId); + _harmony.Patch(ignore, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.IgnorePrefix)))); + _harmony.Patch(hostile, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.HostilePrefix)))); + _sentryPatch = "patched"; + } + catch (Exception e) + { + _sentryPatch = "failed: " + e.Message; + PrintWarning($"turrets: always cannot reach the safe-zone sentries: Harmony could not patch them ({e.Message})."); + } + } + + private void UnpatchSentries() + { + try + { + _harmony?.UnpatchAll(HarmonyId); + } + catch (Exception e) + { + PrintWarning($"Harmony could not unpatch the sentries: {e.Message}"); + } + + _harmony = null; + } + + /// The two prefixes. Each answers for our always NPCs only; Rust's method runs for anything else. + public static class SentryPatch + { + public static long Answered; + + private static bool Always(BaseEntity e) + { + RunicNpcPlayer npc = e as RunicNpcPlayer; + return npc != null && npc.Profile != null && npc.Profile.Turrets == "always" && !npc.IsDead(); + } + + public static bool IgnorePrefix(BasePlayer __0, ref bool __result) + { + if (!Always(__0)) + return true; + __result = false; + Answered++; + return false; + } + + public static bool HostilePrefix(BaseCombatEntity __0, ref bool __result) + { + if (!Always(__0)) + return true; + __result = true; + Answered++; + return false; + } + } + + // ---- PVE (D261, D271) ---- + + /// + /// TruePVE's and NextGenPVE's question. Ours are "allowed" both ways with players, as Rust's + /// scientists are (D261), unless the profile turns a direction off (D271). Null for anything + /// that is not one of ours facing a player. + /// + private object CanEntityTakeDamage(BaseCombatEntity entity, HitInfo info) + { + MarkHook("CanEntityTakeDamage"); + if (entity == null || info == null) + return null; + RunicNpcPlayer victim = entity as RunicNpcPlayer; + BasePlayer attacker = info.InitiatorPlayer; + if (victim?.Profile != null && attacker != null && !attacker.IsNpc) + return victim.Profile.HurtByPlayers; + RunicNpcPlayer from = info.Initiator as RunicNpcPlayer; + BasePlayer player = entity as BasePlayer; + if (from?.Profile != null && player != null && !player.IsNpc) + return from.Profile.HurtsPlayers; + return null; + } + + /// + /// Every damage event. Two jobs only: a harmless profile's NPC never hurts a player (D271), + /// and whom ours escort or serve are defended (D257, D269). Null otherwise. + /// + private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) + { + MarkHook("OnEntityTakeDamage"); + if (entity == null || info == null) + return null; + RunicNpcPlayer from = info.Initiator as RunicNpcPlayer; + BasePlayer player = entity as BasePlayer; + if (from?.Profile != null && !from.Profile.HurtsPlayers && player != null && !player.IsNpc) + return true; + if (_defenders.Count > 0) + Defend(entity, info); + return null; + } + + private void OnEntitySpawned(BaseNetworkable entity) + { + MarkHook("OnEntitySpawned"); + if (_ready) + Track(entity); + } + + #endregion + #region Registry, owners, spawning /// Every live NPC of ours, by net id. @@ -1396,6 +2937,7 @@ namespace Oxide.Plugins npc.PlacementId = placementId; npc.Slot = slot; npc.Home = position; + npc.HomeYaw = yaw; npc.MoveKind = kind; npc.MoveRadius = movement.Radius; npc.RouteName = route; @@ -1416,6 +2958,7 @@ namespace Oxide.Plugins npc.EquipWeapon(); _npcs[npc.NetId] = npc; + Index(npc, true); Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner); return npc; } @@ -1425,6 +2968,7 @@ namespace Oxide.Plugins { if (!_npcs.Remove(npc.NetId)) return; + Index(npc, false); if (npc.PlacementId != null) { @@ -1532,6 +3076,11 @@ namespace Oxide.Plugins ParseMode(movement.Mode, out kind, out route); if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) return $"route '{route}' is missing"; + + // D272: a zone that has gone holds its placement back, as a missing route does, rather than + // letting NPCs that were meant to stay inside it roam the map. + if (data.Tether != null && (ZoneManager == null || ZoneManager.Call("CheckZoneID", data.Tether) == null)) + return ZoneManager == null ? $"its zone '{data.Tether}' needs ZoneManager, which is not loaded" : $"its zone '{data.Tether}' is missing"; return null; } @@ -1702,6 +3251,7 @@ namespace Oxide.Plugins ps.LastError = null; ps.Slots[slot].Npc = npc; + npc.Tether = data.Tether; } /// The count a placement plans, whether or not its NPCs are alive now. @@ -1726,7 +3276,9 @@ namespace Oxide.Plugins return $"RunicNPC: {total} NPC(s) on this server{estimate}. Measured on a test server, they add about " + $"{Mathf.Max(0.1f, hundreds * 1.5f):0.#} ms to every server frame while idle. If all fight at once they add about " + $"{hundreds * 5f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " + - $"{Mathf.Max(0.3f, hundreds * 2.5f):0.#} s."; + $"{Mathf.Max(0.3f, hundreds * 2.5f):0.#} s. Fighting other NPCs (a faction setting) was measured to cost about " + + $"what fighting players does; on a map whose own AI is busy, each then reacts only every " + + $"{Mathf.Max(0.5f, hundreds * 7f):0.#} s."; } #endregion @@ -1748,6 +3300,8 @@ namespace Oxide.Plugins LoadData(); RevalidateProfiles(); RebuildPlacements(); + TrackWorld(); + PatchSentries(); _ready = true; timer.Every(1f, TickPlacements); Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " + @@ -1768,6 +3322,8 @@ namespace Oxide.Plugins RunicNpcPlayer npc = entity as RunicNpcPlayer; if (npc != null) Unregister(npc); + else + Untrack(entity); } /// A plugin's NPCs die with it (§2: plugin:<name>). @@ -1785,6 +3341,7 @@ namespace Oxide.Plugins private void Unload() { _unloading = true; + UnpatchSentries(); Despawn(_npcs.Values); _npcs.Clear(); if (_placementsDirty) @@ -1873,7 +3430,15 @@ namespace Oxide.Plugins ["rest"] = brain != null ? brain.Rest.ToString() : "?", ["state"] = brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none", ["leashes"] = brain != null ? brain.Leashes : 0, - ["regrounded"] = brain != null ? brain.Regrounded : 0 + ["regrounded"] = brain != null ? brain.Regrounded : 0, + ["faction"] = n.Profile != null ? FactionOf(n) : null, + ["target"] = n.NpcTarget != null && !n.NpcTarget.IsDestroyed ? n.NpcTarget.ShortPrefabName : null, + ["engaged"] = n.Engaged, + ["escort"] = n.Escort != null && !n.Escort.IsDestroyed ? (n.Escort is BasePlayer ? ((BasePlayer)n.Escort).UserIDString : n.Escort.ShortPrefabName) : null, + ["ally"] = n.AllyClan != 0L ? "clan:" + n.AllyClan : n.AllyTeam != 0UL ? "team:" + n.AllyTeam : n.AllyPlayer != 0UL ? "player:" + n.AllyPlayer : null, + ["tether"] = n.Tether, + ["shotsMarked"] = n.ShotsMarked, + ["kitUsed"] = new Dictionary { ["heals"] = n.Heals, ["throws"] = n.Throws, ["rockets"] = n.Rockets, ["meleeHits"] = n.MeleeHits } }; } @@ -1955,6 +3520,10 @@ namespace Oxide.Plugins return $"position: {off}, and only a sentry may stand off Rust's navmesh (D219)"; } + error = TetherRefusal(data.Tether, data.Position.V); + if (error != null) + return error; + PlacementState old; if (_placements.TryGetValue(id, out old)) Despawn(old.Slots.Select(s => s.Npc).Where(n => n != null)); @@ -2113,6 +3682,114 @@ namespace Oxide.Plugins /// The cost warning (D227) for the NPCs this server plans, plus . private string RunicNpc_CostWarning(int adding) => CostWarning(PlannedTotal() + Math.Max(0, adding)); + // ---- API 4 (stage 5) ---- + + /// API 4 (D254): the faction table, one row per pair, both ways (D268). + private JArray RunicNpc_Factions() => JArray.FromObject(_profileFile.Factions); + + /// + /// API 4 (D254): replaces the faction table, as a site does with every profile push, and marks + /// the server managed (D221). Null on success; otherwise why not, and nothing changes. + /// + private string RunicNpc_SetFactions(JArray pairs) + { + List table; + try + { + table = pairs != null ? pairs.ToObject>() : new List(); + } + catch (Exception e) + { + return "the faction table could not be read: " + e.Message; + } + + string error = ValidateFactions(table); + if (error != null) + return error; + _profileFile.Factions = table; + _profileFile.Managed = true; + SaveProfiles(); + RevalidateProfiles(); + return null; + } + + /// + /// API 4 (D258, D269): one of ours escorts an entity, a player or anything else; null stops it. + /// Null on success, else why not. + /// + private string RunicNpc_Escort(ulong netId, BaseEntity target) + { + RunicNpcPlayer npc; + if (!_npcs.TryGetValue(netId, out npc)) + return $"there is no RunicNPC NPC {netId}"; + return SetEscort(npc, target); + } + + /// + /// API 4 (D257, D270): one of ours is allied to a clan (clan, the game's clan id), a team + /// (team), or a player and that player's team (player, a Steam id), or to nobody + /// (none). Null on success, else why not. + /// + private string RunicNpc_Ally(ulong netId, string kind, string id) + { + RunicNpcPlayer npc; + if (!_npcs.TryGetValue(netId, out npc)) + return $"there is no RunicNPC NPC {netId}"; + return SetAlly(npc, kind, id); + } + + /// API 4 (D272): holds one of ours inside a ZoneManager zone, or frees it with null. + private string RunicNpc_Tether(ulong netId, string zone) + { + RunicNpcPlayer npc; + if (!_npcs.TryGetValue(netId, out npc)) + return $"there is no RunicNPC NPC {netId}"; + if (zone != null && npc.Profile.IsSentry) + return $"{npc.RnName} is a sentry and never moves, so it needs no zone"; + string error = TetherRefusal(zone, npc.transform.position); + if (error != null) + return error; + npc.Tether = zone; + return null; + } + + private string SetAlly(RunicNpcPlayer npc, string kind, string id) + { + long clan = 0L; + ulong team = 0UL, player = 0UL; + switch (kind) + { + case "none": + case null: + break; + case "clan": + if (!long.TryParse(id, out clan) || clan == 0L) + return "a clan is the game's clan id"; + if (ReadClan(clan) == null) + return $"this server has no clan {id}"; + break; + case "team": + if (!ulong.TryParse(id, out team) || team == 0UL || RelationshipManager.ServerInstance?.FindTeam(team) == null) + return $"this server has no team {id}"; + break; + case "player": + if (!ulong.TryParse(id, out player) || player == 0UL) + return "a player is a Steam id"; + BasePlayer p = BasePlayer.FindAwakeOrSleepingByID(player); + if (p != null && p.currentTeam != 0UL) + team = p.currentTeam; + break; + default: + return $"'{kind}' is not clan, team, player or none"; + } + + npc.AllyClan = clan; + npc.AllyTeam = team; + npc.AllyPlayer = player; + NoteDefender(npc); + return null; + } + #endregion #region In game: the commands (PLAN.md §5, stage 3) @@ -2184,7 +3861,9 @@ namespace Oxide.Plugins ["tp"] = new Verb { Permission = PermAdmin, Usage = "tp ", Run = CmdTp }, ["respawn"] = new Verb { Permission = PermAdmin, Usage = "respawn ", Run = CmdRespawn }, ["clear"] = new Verb { Permission = PermAdmin, Usage = "clear ", Run = CmdClear }, - ["profile"] = new Verb { Permission = PermAdmin, Usage = "profile list | show | create [from=] | set | delete ", Run = CmdProfile, ConsoleOnly = true } + ["profile"] = new Verb { Permission = PermAdmin, Usage = "profile list | show | create [from=] | set | delete ", Run = CmdProfile, ConsoleOnly = true }, + ["follow"] = new Verb { Permission = PermPlace, Usage = "follow ", Run = CmdFollow }, + ["faction"] = new Verb { Permission = PermAdmin, Usage = "faction list | set hostile|neutral|allied | clear ", Run = CmdFaction, ConsoleOnly = true } }; } @@ -2273,6 +3952,8 @@ namespace Oxide.Plugins [ConsoleCommand("rnpc.clear")] private void CcClear(ConsoleSystem.Arg arg) => RunConsole(arg, "clear"); [ConsoleCommand("rnpc.profile")] private void CcProfile(ConsoleSystem.Arg arg) => RunConsole(arg, "profile"); [ConsoleCommand("rnpc.help")] private void CcHelp(ConsoleSystem.Arg arg) => RunConsole(arg, "help"); + [ConsoleCommand("rnpc.follow")] private void CcFollow(ConsoleSystem.Arg arg) => RunConsole(arg, "follow"); + [ConsoleCommand("rnpc.faction")] private void CcFaction(ConsoleSystem.Arg arg) => RunConsole(arg, "faction"); // ---- where an admin stands and looks ---- @@ -2378,6 +4059,7 @@ namespace Oxide.Plugins public float? Radius; public Vector3? At; public float? Yaw; + public string Tether; } /// @@ -2392,7 +4074,7 @@ namespace Oxide.Plugins { int eq = args[i].IndexOf('='); if (eq <= 0) - return $"'{args[i]}' is not key=value. Options: count= respawn= mode= move= radius="; + return $"'{args[i]}' is not key=value. Options: count= respawn= mode= move= radius= tether="; string key = args[i].Substring(0, eq).ToLowerInvariant(); string value = args[i].Substring(eq + 1); if (!seen.Add(key)) @@ -2436,8 +4118,12 @@ namespace Oxide.Plugins if (!TryFloat(value, out f)) return "yaw= is in degrees"; o.Yaw = f; break; + case "tether": + if (!TetherNameRule.IsMatch(value)) return "tether= is a ZoneManager zone id"; + o.Tether = value; + break; default: - return $"there is no option {key}=. Options: count= respawn= mode= move= radius="; + return $"there is no option {key}=. Options: count= respawn= mode= move= radius= tether="; } } @@ -2580,9 +4266,16 @@ namespace Oxide.Plugins Count = o.Count ?? 1, Respawn = o.Respawn ?? 300f, RespawnMode = o.Mode ?? "each", - Movement = movement + Movement = movement, + Tether = o.Tether }; + if (o.Tether != null && p.IsSentry) + { + c.Say($"'{name}' is a sentry and never moves: drop tether=."); + return; + } + string id; bool built; error = CreatePlacement(data, out id, out built); @@ -2596,7 +4289,7 @@ namespace Oxide.Plugins ParseMode((movement ?? p.Movement).Mode, out kind, out route); Movement m = movement ?? p.Movement; c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " + - $"respawn {data.Respawn:0} s, {data.RespawnMode}. Rename it with rnpc rename {id} ."); + $"respawn {data.Respawn:0} s, {data.RespawnMode}{(data.Tether != null ? $", inside zone {data.Tether}" : "")}. Rename it with rnpc rename {id} ."); if (built) c.Say("This spot is on a player-built structure. If it is destroyed, the NPC falls back to the nearest navmesh."); c.Say(CostWarning(PlannedTotal())); @@ -2751,6 +4444,134 @@ namespace Oxide.Plugins c.Say($"health {npc.health:0}/{npc.MaxHealth():0} · state {(brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none")} · " + $"{(brain != null ? brain.Rest.ToString().ToLowerInvariant() : "?")} · {move} · kit {npc.Kit}"); c.Say($"target {targetText} · {Meters(npc.transform.position, npc.Home)} from home"); + + BaseCombatEntity own = npc.NpcTarget; + HashSet hunts = HuntsOf(npc); + c.Say($"faction {FactionOf(npc)} · hunts {(hunts != null ? string.Join(", ", hunts.ToArray()) : "players only")} · " + + $"fighting {(own != null && !own.IsDestroyed ? $"{own.ShortPrefabName} {Meters(own.transform.position, npc.transform.position)}{(npc.Engaged ? "" : " (waits)")}" : "no NPC")}"); + var orders = new List(); + if (npc.Escort != null && !npc.Escort.IsDestroyed) + orders.Add("escorts " + (npc.Escort is BasePlayer ? ((BasePlayer)npc.Escort).displayName : npc.Escort.ShortPrefabName)); + if (npc.HasAlly) + orders.Add("allied to " + (npc.AllyClan != 0L ? "clan " + npc.AllyClan : npc.AllyTeam != 0UL ? "team " + npc.AllyTeam : "player " + npc.AllyPlayer)); + if (npc.Tether != null) + orders.Add("held in zone " + npc.Tether); + if (orders.Count > 0) + c.Say(string.Join(" · ", orders.ToArray())); + } + + // ---- follow (D258, D269) ---- + + /// + /// rnpc follow <placement> <player|me|off>: the placement's live NPCs escort a + /// player, for trying it out in game (D258). In memory only: a respawn or a reload ends it, and a + /// placement never escorts anyone of its own accord. + /// + private void CmdFollow(Caller c, string[] args) + { + if (args.Length < 2) + { + c.Say("Usage: rnpc " + _verbs["follow"].Usage); + return; + } + + PlacementState ps; + if (!_placements.TryGetValue(args[0], out ps)) + { + c.Say($"There is no placement '{args[0]}'. rnpc near lists those around you."); + return; + } + + BasePlayer target = null; + string who = args[1]; + if (who == "me") + { + if (c.Player == null) + { + c.Say("The server console cannot be followed; name a player."); + return; + } + + target = c.Player; + } + else if (who != "off") + { + target = BasePlayer.Find(who); + if (target == null) + { + c.Say($"No player on the server matches '{who}'."); + return; + } + } + + int n = 0; + foreach (Slot s in ps.Slots) + { + if (s.Npc == null || s.Npc.IsDestroyed) + continue; + string error = SetEscort(s.Npc, target); + if (error != null) + { + c.Say(error + "."); + return; + } + + n++; + } + + c.Say(n == 0 ? $"{ps.Id} has no live NPCs now." : target == null + ? $"{ps.Id}: {n} NPC(s) stop escorting and walk back to their spot." + : $"{ps.Id}: {n} NPC(s) escort {target.displayName}. They walk back to their spot if {target.displayName} dies or leaves; rnpc follow {ps.Id} off ends it."); + } + + // ---- faction (D254, D268) ---- + + /// rnpc.faction: the faction table on a standalone server; refused when a site manages it (D221). + private void CmdFaction(Caller c, string[] args) + { + string sub = args.Length > 0 ? args[0].ToLowerInvariant() : "list"; + if (sub == "list") + { + List table = _profileFile.Factions; + var named = _profiles.Where(kv => !string.IsNullOrEmpty(kv.Value.Faction)).GroupBy(kv => kv.Value.Faction) + .Select(g => $"{g.Key} ({string.Join(", ", g.Select(kv => kv.Key).ToArray())})").ToList(); + c.Say($"Factions in use: {(named.Count > 0 ? string.Join("; ", named.ToArray()) : "none")}. Rust's own: scientists, animals."); + if (table.Count == 0) + c.Say("The table is empty: every pair is neutral, and a profile with no faction settings fights players only (D255)."); + foreach (FactionPair f in table.OrderBy(f => f.A).ThenBy(f => f.B)) + c.Say($"{f.A} ↔ {f.B}: {f.Relation}"); + if (_profileFile.Managed) + c.Say("Managed by this server's website (D221): edit the table there."); + return; + } + + if (_profileFile.Managed) + { + c.Say("This server's faction table is managed by its website (D221): edit it there."); + return; + } + + if ((sub == "set" && args.Length < 4) || (sub == "clear" && args.Length < 3) || (sub != "set" && sub != "clear")) + { + c.Say("Usage: rnpc." + _verbs["faction"].Usage); + return; + } + + string a = args[1], b = args[2]; + var next = _profileFile.Factions.Where(f => PairKey(f.A, f.B) != PairKey(a, b)).ToList(); + if (sub == "set") + next.Add(new FactionPair { A = a, B = b, Relation = args[3].ToLowerInvariant() }); + string error = ValidateFactions(next); + if (error != null) + { + c.Say(Regex.Replace(error, @"^factions\[\d+\]: ", "")); + return; + } + + _profileFile.Factions = next; + SaveProfiles(); + RevalidateProfiles(); + c.Say(sub == "set" ? $"{a} ↔ {b}: {args[3].ToLowerInvariant()}, both ways (D268)." : $"{a} ↔ {b}: back to neutral."); } private void CmdProfiles(Caller c, string[] args) @@ -3127,6 +4948,28 @@ namespace Oxide.Plugins private string SetProfileField(string name, Profile profile, string field, string value) { JObject o = JObject.FromObject(profile); + + // Stage 5: a profile's own exceptions are keyed by faction (D254), so one is set, or removed + // with none, by name: relations.scientists hostile, relations.profile:warden none. + if (field.StartsWith("relations.")) + { + string key = field.Substring(10); + JObject relations = (JObject)o["relations"]; + if (value == "none" || value == "-") + relations.Remove(key); + else + relations[key] = value; + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + + if (field == "faction" && (value == "none" || value == "-")) + { + o["faction"] = null; + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + JToken target = o.SelectToken(field); if (target == null || target is JObject) return target == null @@ -3157,6 +5000,12 @@ namespace Oxide.Plugins if (!TryFloat(value, out f)) return $"'{field}' is a number"; replacement = new JValue(f); } + else if (target.Type == JTokenType.Boolean) + { + string v = value.ToLowerInvariant(); + if (v != "true" && v != "false" && v != "on" && v != "off") return $"'{field}' is true or false"; + replacement = new JValue(v == "true" || v == "on"); + } else replacement = new JValue(value); @@ -3217,6 +5066,12 @@ namespace Oxide.Plugins sb.Append($"placements={_placements.Count} queue={_queue.Count} waiting: " + $"{string.Join("; ", _placements.Values.Where(ps => ps.Waiting != null).Select(ps => ps.Id + ": " + ps.Waiting).DefaultIfEmpty("-").ToArray())}").Append(nl); sb.Append($"routes={_routeFile.Routes.Count}").Append(nl); + double fightMs = RunicNpcBrain.FightTicks > 0 ? RunicNpcBrain.FightMs / RunicNpcBrain.FightTicks : 0.0; + double senseMs = SenseRuns > 0 ? SenseMs / SenseRuns : 0.0; + sb.Append($"stage 5: factions={_profileFile.Factions.Count} pairs, tracked scientists={_scientists.Count} animals={_animals.Count}, " + + $"engaged={_npcs.Values.Count(n => n.Engaged)} defenders={_defenders.Count} alarms={Alarms} defences={Defences}, " + + $"fight {fightMs:0.000} ms/think over {RunicNpcBrain.FightTicks}, sense {senseMs:0.000} ms over {SenseRuns}").Append(nl); + sb.Append($"sentries (turrets: always): {_sentryPatch}, answered {SentryPatch.Answered}; zonemanager={(ZoneManager != null ? "loaded" : "absent")}").Append(nl); CapsConfig caps = _config.Caps; sb.Append($"caps: total={caps.Total} perOwner={caps.PerOwner} perProfile={caps.PerProfile} spawnsPerSecond={caps.SpawnsPerSecond} (0 = none)").Append(nl); sb.Append(CostWarning(PlannedTotal())); @@ -3229,8 +5084,7 @@ namespace Oxide.Plugins { if (arg.Connection != null && !arg.IsAdmin) return; - _profileFile = ReadData(DataProfiles); - if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); + ReadProfileFile(); _routeFile = ReadData(DataRoutes); if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary(); RevalidateProfiles(); diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index 0a4a2c1..9678467 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -69,7 +69,17 @@ const PLUGIN_TOML = path.join(ROOT, 'plugin.toml') * That half cannot be read statically; it is the reviewer's to check, which is * why the reason goes here where the reviewer will see it. */ -const ANSWERS_DELIBERATELY = Object.create(null) +const ANSWERS_DELIBERATELY = Object.assign(Object.create(null), { + // Stage 5 (D259, D264): turrets: ignore. Answers false for RunicNPC's own NPCs whose profile says + // ignore, so no auto turret, flame turret or shotgun trap targets them; null for everything else. + CanBeTargeted: 'turrets: ignore (D259, D264), our own NPCs only', + // Stage 5 (D261, D271): TruePVE's and NextGenPVE's question. Answers only for one of ours facing a + // real player, with the profile's two PVE checkboxes; null for everything else. + CanEntityTakeDamage: 'PVE allowed both ways, or the profile opt-out (D261, D271), our own NPCs only', + // Stage 5 (D271): cancels damage only when one of ours whose profile says it cannot hurt players + // hits a real player. Its other job, defending an ally or escort (D257, D269), never answers. + OnEntityTakeDamage: 'a harmless profile never hurts a player (D271), our own NPCs only', +}) /** Plugins RunicNPC must require, whatever else it requires (D217). */ const REQUIRED_PLUGINS = ['Kits'] diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js index c7628cb..9fc660d 100644 --- a/scripts/checkPlugin.test.js +++ b/scripts/checkPlugin.test.js @@ -100,13 +100,13 @@ test('returning null is not good enough — the signature is the rule', () => { // `return null` today is one edit away from `return true` tomorrow, and the // edit that breaks it looks harmless in a diff. A void method cannot be turned // into a veto without changing its signature, which is visible. - const methods = ` private bool CanBeTargeted(BaseCombatEntity entity, AutoTurret turret) + const methods = ` private bool CanLootEntity(BasePlayer player, StorageContainer container) { return true; }` - const problems = check(source({ expected: ['CanBeTargeted'], methods }), toml()) - assert.match(problems[0], /CanBeTargeted returns bool, not void/) + const problems = check(source({ expected: ['CanLootEntity'], methods }), toml()) + assert.match(problems[0], /CanLootEntity returns bool, not void/) }) test('a method that is not shaped like a hook is left alone', () => { diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 3055ff5..d4dd848 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,125 @@ 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"); + } + + /// A probe: an escort follows a scientist 35 m, the scientist is killed, then 30 s of what the escort does. + [ConsoleCommand("rnt.escortprobe")] + private void CmdEscortProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + Vector3 c = OnMesh(f); + ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0)); + BasePlayer escort = Spawn(c, "t_roam"); + Api("RunicNpc_Escort", Id(escort), charge); + int n = 0; + timer.Repeat(2f, 30, () => + { + n++; + if (n == 3) Teleport(charge, c + new Vector3(0, 0, 35)); + if (n == 12 && !Gone(charge)) charge.Kill(); + Dictionary d = Gone(escort) ? null : Describe(escort); + Puts($"[esc {n}] state={d?["state"]} rest={d?["rest"]} escort={d?["escort"] ?? "null"} target={TargetOf(d)} engaged={d?["engaged"]} home={(d == null ? -1f : Flat(escort.transform.position, V(d["home"]))):0.0} ended={_escortEnded.Count}"); + if (n == 30) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: escort probe started"); + } + [ConsoleCommand("rnt.clear")] private void CmdClear(ConsoleSystem.Arg arg) { @@ -351,11 +473,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 +489,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 +511,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 +1311,588 @@ 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; + // East, not north: north brings the escort within sense range of the keeper stand-in, and Rust's + // design then fights it instead of walking home. + Teleport(charge, c + new Vector3(35, 0, 0)); + float closest = float.MaxValue; + for (int t = 0; t < 20; t++) + { + yield return new WaitForSeconds(1f); + closest = Mathf.Min(closest, Flat(escort.transform.position, charge.transform.position)); + } + + Check("s5.escort.follows", closest < 8f, $"its charge moved 35 m; closest {closest:0.0} m in 20 s"); + Check("s5.escort.spared", !Knows(escort, charge), $"target={Knows(escort, charge)}"); + charge.Kill(); + for (int t = 0; t < 20 && _escortEnded.Count == 0; t++) + yield return new WaitForSeconds(1f); + Dictionary de = Describe(escort); + Check("s5.escort.ends", _escortEnded.Count == 1 && de != null && de["escort"] == null, $"hook {_escortEnded.Count}, escort={de?["escort"] ?? "null"}"); + float back = float.MaxValue; + for (int t = 0; t < 45 && back > 4f; t++) + { + yield return new WaitForSeconds(1f); + back = Flat(escort.transform.position, V(Describe(escort)["home"])); + } + + Check("s5.escort.home", back <= 4f, $"{back:0.0} m from its spot after its charge was gone (D269)"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the guard role ---- + BasePlayer guard = Spawn(d, "t_guard"); + yield return new WaitForSeconds(2f); + Vector3 post = V(Describe(guard)["home"]); + Teleport(guard, post + new Vector3(10, 0, 0)); + float off = float.MaxValue; + for (int t = 0; t < 25 && off > 2.5f; t++) + { + yield return new WaitForSeconds(1f); + off = Flat(guard.transform.position, post); + } + + Check("s5.guard.returns", off <= 2.5f, $"put 10 m off its post: {off:0.0} m from it"); + // It stops within 2 m of its post; let it arrive before measuring how still it holds. + yield return new WaitForSeconds(4f); + Vector3 held = guard.transform.position; + float drift = 0f; + for (int t = 0; t < 12; t++) + { + yield return new WaitForSeconds(1f); + drift = Mathf.Max(drift, Flat(guard.transform.position, held)); + } + + Check("s5.guard.holds", drift < 1.5f, $"moved {drift:0.0} m in 12 s at its post (a roamer would wander)"); + Api("RunicNpc_DespawnOwner", Owner); + yield return new WaitForSeconds(1f); + + // ---- the tether (D272) ---- + Plugin zm = plugins.Find("ZoneManager"); + if (zm == null) + Note("s5.tether skipped: ZoneManager is not loaded"); + else + { + // Within 160 m of the keeper, or the NPC sleeps (D235) and the test reads 0 m. + Vector3 centre = OnMesh(c); + zm.Call("CreateOrUpdateZone", Zone, new[] { "radius", "12", "name", "rnt tether" }, centre); + BasePlayer tethered = Spawn(centre, "t_roam"); + said = Api("RunicNpc_Tether", Id(tethered), "rnt_nozone"); + Check("s5.tether.refuseMissing", said != null && said.Contains("no zone"), said ?? "null"); + said = Api("RunicNpc_Tether", Id(tethered), Zone); + Check("s5.tether.set", said == null, said ?? "held"); + float widest = 0f; + for (int t = 0; t < 30; t++) + { + yield return new WaitForSeconds(1f); + widest = Mathf.Max(widest, Flat(tethered.transform.position, centre)); + } + + Check("s5.tether.holds", widest <= 13f, $"wander radius 15 in a 12 m zone: widest {widest:0.0} m from its centre in 30 s"); + Teleport(tethered, centre + new Vector3(25, 0, 0)); + float inside = float.MaxValue; + for (int t = 0; t < 25 && inside > 12f; t++) + { + yield return new WaitForSeconds(1f); + inside = Flat(tethered.transform.position, centre); + } + + Check("s5.tether.returns", inside <= 12f, $"put 25 m out: {inside:0.0} m from the centre"); + string placed = Api("RunicNpc_SetPlacement", "t_tether", JObject.FromObject(new { profile = "t_roam", position = new { centre.x, centre.y, centre.z }, tether = "rnt_nozone" })); + Check("s5.tether.placementRefused", placed != null && placed.Contains("no zone"), placed ?? "null"); + Api("RunicNpc_RemovePlacement", "t_tether"); + zm.Call("EraseZone", Zone); + Api("RunicNpc_DespawnOwner", Owner); + } + + // ---- PVE (D261, D271) ---- + BasePlayer player = StandIn(OnMesh(a + new Vector3(0, 0, 40)), 57); + BasePlayer unkill = Spawn(a, "t_unkill"); + BasePlayer harmless = Spawn(OnMesh(a + new Vector3(4, 0, 0)), "t_harmless"); + BasePlayer plain = Spawn(OnMesh(a + new Vector3(-4, 0, 0)), "t_roam"); + object o1 = Interface.CallHook("CanEntityTakeDamage", unkill, new HitInfo(player, unkill, Rust.DamageType.Bullet, 10f)); + object o2 = Interface.CallHook("CanEntityTakeDamage", plain, new HitInfo(player, plain, Rust.DamageType.Bullet, 10f)); + object o3 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f)); + object o4 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f)); + Check("s5.pve.answers", Equals(o1, false) && Equals(o2, true) && Equals(o3, false) && Equals(o4, true), $"unkillable {o1 ?? "null"}, plain {o2 ?? "null"}, harmless→player {o3 ?? "null"}, plain→player {o4 ?? "null"}"); + float hp = unkill.health; + unkill.Hurt(new HitInfo(player, unkill, Rust.DamageType.Bullet, 30f)); + float plainHp = plain.health; + plain.Hurt(new HitInfo(player, plain, Rust.DamageType.Bullet, 30f)); + Check("s5.pve.unkillable", Mathf.Approximately(unkill.health, hp) && plain.health < plainHp, $"unkillable {hp:0}→{unkill.health:0}, plain {plainHp:0}→{plain.health:0}"); + object h1 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f)); + object h2 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f)); + Check("s5.pve.harmless", Equals(h1, true) && h2 == null, $"harmless→player {h1 ?? "null"}, plain→player {h2 ?? "null"}"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- turrets (D259, D264, D267): the hook, then Outpost's own sentries ---- + BasePlayer ignoring = Spawn(b, "t_ignore"); + BasePlayer defaulting = Spawn(OnMesh(b + new Vector3(4, 0, 0)), "t_roam"); + object i1 = Interface.CallHook("CanBeTargeted", ignoring, (MonoBehaviour)null); + object i2 = Interface.CallHook("CanBeTargeted", defaulting, (MonoBehaviour)null); + Check("s5.turrets.ignore", Equals(i1, false) && i2 == null, $"ignore {i1 ?? "null"}, default {i2 ?? "null"}"); + Api("RunicNpc_DespawnOwner", Owner); + IEnumerator sentries = Sentries(); + while (sentries.MoveNext()) yield return sentries.Current; + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the kit's extras (D260, D265), each NPC 60 m from the next ---- + IEnumerator kit = KitExtras(f); + while (kit.MoveNext()) yield return kit.Current; + + int horrorAfter = HorrorLeft(); + Check("s5.noHorrorLeft", horrorAfter <= horrorBefore, $"Horror entities: {horrorBefore} before, {horrorAfter} after"); + Api("RunicNpc_SetFactions", new JArray()); + } + + /// + /// D264 and D267 on Outpost's (or Bandit Camp's) own sentries, and the org lead's condition: they + /// must never target Rust's own scientists or bandit guards. Ours are put in front of a sentry: + /// one always, one default, and a stock scientist beside them. + /// + private IEnumerator Sentries() + { + List turrets = BaseNetworkable.serverEntities.OfType().Where(t => t != null && !t.IsDestroyed).ToList(); + if (turrets.Count == 0) + { + Note("s5.sentry skipped: this map has no Outpost or Bandit Camp sentries"); + yield break; + } + + NPCAutoTurret turret = null; + Vector3 spot = Vector3.zero; + foreach (NPCAutoTurret t in turrets) + { + Vector3 ahead = t.transform.position + t.transform.forward * 12f; + NavMeshHit hit, start; + // The approach must be walkable too: ours start 45 m out and are walked in (a sentry by the + // water left the Carbon run's NPC unspawned). + if (SampleNavmesh(ahead, 4f, out hit) && Mathf.Abs(hit.position.y - t.transform.position.y) < 6f && + SampleNavmesh(t.transform.position + t.transform.forward * 45f, 3f, out start)) + { + turret = t; + spot = hit.position; + break; + } + } + + if (turret == null) + { + Check("s5.sentry.spot", false, $"no navmesh 12 m in front of any of {turrets.Count} sentries"); + yield break; + } + + Note($"s5.sentry: {turret.ShortPrefabName} at {turret.transform.position}, {turrets.Count} on the map"); + BasePlayer near = StandIn(spot + new Vector3(0, 0, 80), 58); + Vector3 side = Vector3.Cross(Vector3.up, turret.transform.forward).normalized; + // Spawned 45 m out and walked in: a turret notices only what ENTERS its trigger (stage 5 spike). + Vector3 outside = turret.transform.position + turret.transform.forward * 45f; + BasePlayer always = Spawn(OnMesh(outside), "t_always"); + BasePlayer plain = Spawn(OnMesh(outside + side * 4f), "t_roam"); + ScientistNPC stock = SpawnScientist(outside - side * 4f); + yield return new WaitForSeconds(1f); + Teleport(always, spot); + Teleport(plain, spot + side * 4f); + Teleport(stock, spot - side * 4f); + float alwaysHp = always != null ? always.health : 0f; + var targeted = new HashSet(); + bool alwaysTargeted = false; + int stockTargets = 0; + for (int t = 0; t < 120; t++) + { + yield return new WaitForSeconds(0.25f); + foreach (NPCAutoTurret any in turrets) + { + BaseCombatEntity target = any != null && !any.IsDestroyed ? any.target : null; + if (target == null) continue; + targeted.Add(target.GetType().Name); + if (target == always) alwaysTargeted = true; + if (target is BanditGuard || (target is ScientistNPC && target.GetType().Name != "RunicNpcPlayer")) stockTargets++; + } + } + + if (ReferenceEquals(always, null) || ReferenceEquals(plain, null) || ReferenceEquals(stock, null)) Note($"s5.sentry: spawned always={always != null} default={plain != null} stock={stock != null}"); + Check("s5.sentry.always", alwaysTargeted && (Gone(always) || always.health < alwaysHp), $"targeted={alwaysTargeted}, health {alwaysHp:0}→{(Gone(always) ? 0f : always.health):0}"); + Check("s5.sentry.default", Gone(plain) || plain.health >= plain.MaxHealth() - 0.5f, $"a default NPC beside it: {(Gone(plain) ? "dead" : plain.health.ToString("0"))} hp"); + Check("s5.sentry.noStockTargets", stockTargets == 0 && !Gone(stock), $"samples on a stock scientist or bandit guard: {stockTargets}; targets seen: {string.Join(", ", targeted.ToArray())} (the org lead's condition, D267)"); + Api("RunicNpc_DespawnOwner", Owner); + } + + private IEnumerator KitExtras(Vector3 f) + { + Vector3[] at = new Vector3[6]; + for (int i = 0; i < 6; i++) + at[i] = OnMesh(f + new Vector3(-150 + i * 60, 0, -60)); + + // Heal in a lull, one syringe used up; and a profile that did not opt in never heals. + BasePlayer healer = Spawn(at[0], "t_kit"); + BasePlayer plain = Spawn(OnMesh(at[0] + new Vector3(8, 0, 0)), "t_roam"); + Give(healer, "syringe.medical", 2); + Give(plain, "syringe.medical", 2); + yield return new WaitForSeconds(1f); + healer.health = healer.MaxHealth() * 0.3f; + plain.health = plain.MaxHealth() * 0.3f; + float plainHp = plain.health; + + // A grenadier, a rocketeer, a brawler and a flamer, each with a stand-in at its range. + BasePlayer grenadier = Spawn(at[1], "t_kit"); + Give(grenadier, "grenade.f1", 2); + BasePlayer gTarget = StandIn(OnMesh(at[1] + new Vector3(12, 0, 0)), 60); + Vector3 rAt, rTo; + if (!ClearLine(at[2], 25f, out rAt, out rTo)) { rAt = at[2]; rTo = at[2] + new Vector3(25, 0, 0); Note("s5.kit.rocket: no clear 25 m line found near its spot"); } + BasePlayer rocketeer = Spawn(rAt, "t_kit"); + Give(rocketeer, "rocket.launcher", 1); + Give(rocketeer, "ammo.rocket.basic", 2); + ScientistNPC rTarget = SpawnScientist(rTo); + BasePlayer brawler = Spawn(at[3], "t_kit"); + Give(brawler, "machete", 1); + BasePlayer mTarget = StandIn(OnMesh(at[3] + new Vector3(1.5f, 0, 0)), 62); + BasePlayer flamer = Spawn(at[4], "t_kit"); + Give(flamer, "flamethrower", 1); + Give(flamer, "lowgradefuel", 50); + // An empty tank, so the extra must burn the kit's fuel (D265: only extras are used up). + foreach (Item it in Items(flamer)) + if (it.GetHeldEntity() is FlameThrower) ((FlameThrower)it.GetHeldEntity()).ammo = 0; + BasePlayer near = StandIn(OnMesh(at[4] + new Vector3(4, 0, 0)), 63); + Sense(flamer, near); + + bool flameHeld = false; + for (int t = 0; t < 45; t++) + { + yield return new WaitForSeconds(1f); + if (t % 5 == 0) + { + if (!Gone(flamer)) Sense(flamer, near); + if (!Gone(grenadier)) Sense(grenadier, gTarget); + if (!Gone(brawler)) Sense(brawler, mTarget); + } + Item held = Gone(flamer) ? null : flamer.GetActiveItem(); + if (held == null || held.info.shortname != "flamethrower") continue; + // Its tank emptied while it is held, then one shot: the extra must burn the kit's fuel (D265). + FlameThrower tank = held.GetHeldEntity() as FlameThrower; + if (!flameHeld && tank != null) + { + tank.ammo = 0; + ((NPCPlayer)flamer).ShotTest(4f); + } + + flameHeld = true; + } + + Dictionary Used(BasePlayer x) => Gone(x) ? null : Describe(x)?["kitUsed"] as Dictionary; + int UsedN(BasePlayer x, string k) => Used(x) != null ? Convert.ToInt32(Used(x)[k]) : -1; + + Check("s5.kit.heal", UsedN(healer, "heals") > 0 && Count(healer, "syringe.medical") < 2, $"heals {UsedN(healer, "heals")}, syringes left {(Gone(healer) ? -1 : Count(healer, "syringe.medical"))}, health {(Gone(healer) ? 0 : healer.health):0}"); + Check("s5.kit.offByDefault", !Gone(plain) && plain.health <= plainHp + 0.5f && Count(plain, "syringe.medical") == 2, $"t_roam: health {plainHp:0}→{(Gone(plain) ? 0 : plain.health):0}, syringes {(Gone(plain) ? -1 : Count(plain, "syringe.medical"))}"); + Check("s5.kit.grenade", UsedN(grenadier, "throws") > 0 && Count(grenadier, "grenade.f1") < 2, $"throws {UsedN(grenadier, "throws")}, grenades left {(Gone(grenadier) ? -1 : Count(grenadier, "grenade.f1"))}"); + Check("s5.kit.rocket", (UsedN(rocketeer, "rockets") > 0 || Gone(rTarget)) && Count(rocketeer, "ammo.rocket.basic") < 2, $"rockets {UsedN(rocketeer, "rockets")}, left {(Gone(rocketeer) ? -1 : Count(rocketeer, "ammo.rocket.basic"))}"); + Check("s5.kit.melee", UsedN(brawler, "meleeHits") > 0, $"melee swings {UsedN(brawler, "meleeHits")}"); + Check("s5.kit.flame", flameHeld && !Gone(flamer) && Count(flamer, "lowgradefuel") < 50, $"held={flameHeld}, fuel left {(Gone(flamer) ? -1 : Count(flamer, "lowgradefuel"))}"); + Api("RunicNpc_DespawnOwner", Owner); + } + /// A route, as RunicNpc_Routes returns it. private sealed class Route {