From 21d3c477dcb3228be45d71689afdcdc367bd47ee Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 1 Oct 2026 07:20:59 -0500 Subject: [PATCH] feat: API 4, factions, escort, allies, tether, turrets, kit extras and PVE (stage 5) Stage 5's behaviour (docs runicnpc/PLAN.md, D253-D272): - profiles gain faction, relations (exceptions), alertRadius, turrets, hurtByPlayers, hurtsPlayers and kitUse, and the guard role; the faction table rides in profiles.json, one row per pair, both ways (D254, D268); - our own sensing and fight beside Rust's design: only the factions a profile hunts, within its leash of its anchor (D263), the target made Horror for the length of our own shot, and Rust's attack routine; - shooting back at whoever hurt it (D268), the alert radius (D256), clan, team or player allies that are never targeted and are defended with what they own (D257), escort as an order that walks home when its charge is gone (D269), and a placement's tether through ZoneManager (D272); - turrets: ignore through CanBeTargeted, and always through a Harmony patch on the safe-zone sentries for our always NPCs only (D267); - the kit's extras, each opted into and used up: heal, melee, flamethrower, grenades and rockets (D260, D265); - TruePVE/NextGenPVE answered both ways unless a profile turns a direction off, and the opt-out enforced by RunicNPC itself (D261, D271); - API 4: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether and the OnRunicNpcEscortEnded hook; rnpc follow, rnpc.faction and tether=. Compiled and loaded on both rigs (Oxide and Carbon); the sentry patch applies on both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin.toml | 12 +- plugin/RunicNPC.cs | 1904 ++++++++++++++++++++++++++++++++++- scripts/checkPlugin.js | 12 +- scripts/checkPlugin.test.js | 6 +- 4 files changed, 1895 insertions(+), 39 deletions(-) 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', () => {