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..514071e 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,1043 @@ 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; + } + + /// + /// Rust's CanSeeTarget for a player; for anything else (which Rust answers "seen" without + /// a ray), a line from its eyes that counts the target's own collider as seen. + /// + private static bool Los(RunicNpcPlayer npc, BaseCombatEntity t) + { + BasePlayer p = t as BasePlayer; + if (p != null) + return npc.CanSeeTarget(p); + RaycastHit hit; + if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore)) + return true; + 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) + { + BaseEntity e = npc.Escort; + if (e == null) + return; + BasePlayer p = e as BasePlayer; + BaseCombatEntity c = e as BaseCombatEntity; + bool gone = e.IsDestroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected); + if (!gone) + return; + string who = p != null ? p.displayName : 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.TargetLos) + { + npc.NextRocket = now + 10f; + if (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++; + 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 +2927,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 +2948,7 @@ namespace Oxide.Plugins npc.EquipWeapon(); _npcs[npc.NetId] = npc; + Index(npc, true); Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner); return npc; } @@ -1425,6 +2958,7 @@ namespace Oxide.Plugins { if (!_npcs.Remove(npc.NetId)) return; + Index(npc, false); if (npc.PlacementId != null) { @@ -1532,6 +3066,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 +3241,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 +3266,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 +3290,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 +3312,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 +3331,7 @@ namespace Oxide.Plugins private void Unload() { _unloading = true; + UnpatchSentries(); Despawn(_npcs.Values); _npcs.Clear(); if (_placementsDirty) @@ -1873,7 +3420,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 +3510,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 +3672,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 +3851,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 +3942,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 +4049,7 @@ namespace Oxide.Plugins public float? Radius; public Vector3? At; public float? Yaw; + public string Tether; } /// @@ -2392,7 +4064,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 +4108,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 +4256,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 +4279,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 +4434,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 +4938,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 +4990,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 +5056,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 +5074,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', () => {