// Requires: Kits using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reflection; using System.Text; using System.Text.RegularExpressions; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core; using Oxide.Core.Plugins; using UnityEngine; using UnityEngine.AI; namespace Oxide.Plugins { /// /// RunicNPC — Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon. /// /// /// This is the stage 3 plugin (docs/runicnpc/PLAN.md §9): the NPC, its profiles, placements /// and owners, the API other plugins call (stage 2), and the commands admins use in game /// (stage 3, §5): /rnpc in chat and rnpc.<verb> in a console. /// /// /// /// Kits is required (D217): it is how every RunicNPC NPC is equipped, so the plugin /// declares it on the first line and neither framework loads RunicNPC without it. That line /// and requires_plugins in plugin.toml are two statements of one fact, and the /// PR check holds them equal. /// /// /// /// Our NPC is Rust's scientist with two components swapped (D223): the prefab's /// ScientistNPC and ScientistBrain are replaced, before the entity spawns, by /// and , carrying the prefab's /// authored fields across. That swap and the brain's states are what a Rust update can /// break, so they are kept in one place: the "the swap" and "the NPC" regions below. /// /// [Info("RunicNPC", "Runic Gateway", "0.0.0")] [Description("Runic Gateway's NPCs: profiles, placements and an API for other plugins.")] internal class RunicNPC : RustPlugin { [PluginReference] private Plugin Kits; /// /// The version of the API other plugins call (RunicNpc_*, PLAN.md §4). Declared /// twice, here and as api in plugin.toml, which the release copies into /// the tarball's manifest so the installer and the bridge can refuse a RunicNPC too old /// for them before it is loaded. The PR check holds the two equal. /// /// /// It moves when a call or a raised hook changes shape, not on every release: the /// release version says what was built, this says what a caller can rely on. /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// private const int ApiVersion = 4; /// /// Every hook this plugin implements. rnpc.status reports which have fired, because /// a hook that matches nothing is silent on both frameworks: this list is how a renamed /// one is noticed. The PR check holds it equal to the methods below. /// private static readonly string[] ExpectedHooks = { "OnServerInitialized", "OnServerSave", "OnEntityKill", "OnPluginUnloaded", "OnEntitySpawned", "OnEntityTakeDamage", "CanBeTargeted", "CanEntityTakeDamage" }; private readonly Dictionary _hookCounts = new Dictionary(); /// The loaded plugin, for the NPC and brain classes, which Rust instantiates. private static RunicNPC _self; private bool _ready; private bool _unloading; private static readonly Regex NameRule = new Regex("^[a-z0-9_-]{1,40}$"); private static readonly Regex OwnerRule = new Regex(@"^(run|plugin):\S{1,64}$"); private const string DataProfiles = "RunicNPC/profiles"; private const string DataPlacements = "RunicNPC/placements"; private const string DataRoutes = "RunicNPC/routes"; /// How far from a placement's spot its NPCs after the first are scattered. private const float Scatter = 3f; /// How far above or below a roamer's spot the navmesh it is put on may be. private const float MaxDrop = 2f; #region Config private PluginConfig _config; /// /// The plugin's config. No caps by default (D227): each is 0, meaning none, until an /// admin sets it. The cost warning is shown instead, wherever NPCs are added. /// private sealed class PluginConfig { [JsonProperty("caps")] public CapsConfig Caps = new CapsConfig(); /// /// Milliseconds per server frame the placement queue may spend spawning. Stage 1 /// measured about 5 ms per NPC, and 100 in one frame as a half-second hitch. /// [JsonProperty("spawnBudgetMs")] public float SpawnBudgetMs = 8f; } private sealed class CapsConfig { [JsonProperty("total")] public int Total; [JsonProperty("perOwner")] public int PerOwner; [JsonProperty("perProfile")] public int PerProfile; [JsonProperty("spawnsPerSecond")] public float SpawnsPerSecond; } protected override void LoadDefaultConfig() => _config = new PluginConfig(); protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); } catch (Exception e) { PrintError($"The config could not be read, so the defaults are used: {e.Message}"); _config = null; } if (_config == null) _config = new PluginConfig(); if (_config.Caps == null) _config.Caps = new CapsConfig(); SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_config, true); #endregion #region Data: profiles, placements, routes /// A position in a data file. public sealed class Pos { [JsonProperty("x")] public float X; [JsonProperty("y")] public float Y; [JsonProperty("z")] public float Z; [JsonIgnore] public Vector3 V => new Vector3(X, Y, Z); public static Pos Of(Vector3 v) => new Pos { X = v.x, Y = v.y, Z = v.z }; } /// How a roamer moves (D233): wander, monument or route:<name>. public sealed class Movement { [JsonProperty("mode")] public string Mode = "wander"; [JsonProperty("radius")] public float Radius = 20f; } public sealed class DamageTaken { [JsonProperty("head")] public float Head = 1f; [JsonProperty("body")] public float Body = 1f; [JsonProperty("legs")] public float Legs = 1f; } public sealed class Ranges { [JsonProperty("sense")] public float Sense = 30f; [JsonProperty("loseTarget")] public float LoseTarget = 40f; [JsonProperty("chase")] public float Chase = 40f; [JsonProperty("attack")] public float Attack = 30f; } /// A named description of an NPC, in the shape the org lead approved (D238). public sealed class Profile { [JsonProperty("names")] public List Names = new List(); [JsonProperty("kits")] public List Kits = new List(); [JsonProperty("prefab")] public string Prefab = "scientistnpc_roam"; [JsonProperty("role")] public string Role = "roamer"; [JsonProperty("movement")] public Movement Movement = new Movement(); [JsonProperty("health")] public float Health = 150f; [JsonProperty("damageDealt")] public float DamageDealt = 1f; [JsonProperty("damageTaken")] public DamageTaken DamageTaken = new DamageTaken(); [JsonProperty("aimCone")] public float AimCone = 2f; [JsonProperty("ranges")] public Ranges Ranges = new Ranges(); [JsonProperty("visionCone")] public float VisionCone = -0.8f; [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 { /// 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(); } /// /// An admin's (or the site's) persistent placement (D222). Only this is saved: its NPCs /// never are, and are spawned fresh from it at boot (§2). /// public sealed class Placement { [JsonProperty("profile")] public string Profile; [JsonProperty("position")] public Pos Position; [JsonProperty("yaw")] public float Yaw; [JsonProperty("count")] public int Count = 1; /// Seconds from a death to the respawn. [JsonProperty("respawn")] public float Respawn = 300f; /// each (the default) or group (D236). [JsonProperty("respawnMode")] public string RespawnMode = "each"; /// 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 { [JsonProperty("placements")] public Dictionary Placements = new Dictionary(); } /// /// Points an admin records in game (stage 3) and a route:<name> roamer walks in /// order (D233, D234). loop goes from the last point back to the first; otherwise the /// route is walked back and forth. /// public sealed class Route { [JsonProperty("points")] public List Points = new List(); [JsonProperty("loop")] public bool Loop = true; } private sealed class RouteFile { [JsonProperty("routes")] public Dictionary Routes = new Dictionary(); } private ProfileFile _profileFile = new ProfileFile(); private PlacementFile _placementFile = new PlacementFile(); private RouteFile _routeFile = new RouteFile(); /// The profiles that passed validation. Only these spawn. private readonly Dictionary _profiles = new Dictionary(); /// The profiles that did not, and why. private readonly Dictionary _refused = new Dictionary(); /// The one flag that marks placements for saving, on every change including removal (§2). private bool _placementsDirty; private T ReadData(string name) where T : class, new() { try { T value = Interface.Oxide.DataFileSystem.ReadObject(name); return value ?? new T(); } catch (Exception e) { PrintError($"data/{name}.json could not be read and is treated as empty: {e.Message}"); return new T(); } } private void LoadData() { ReadProfileFile(); _routeFile = ReadData(DataRoutes); if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary(); _placementFile = ReadData(DataPlacements); if (_placementFile.Placements == null) _placementFile.Placements = new Dictionary(); // Create the files on first boot: the plugin must make its own data directory, because one // made from outside the game (a panel's file API) is not writable by it (PLAN.md §1.5). SaveProfiles(); SaveRoutes(); 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); private void SavePlacements() { Interface.Oxide.DataFileSystem.WriteObject(DataPlacements, _placementFile); _placementsDirty = false; } /// Validates every profile in the file; only the valid ones are used. private void RevalidateProfiles() { _profiles.Clear(); _refused.Clear(); foreach (KeyValuePair kv in _profileFile.Profiles) { string error = NameRule.IsMatch(kv.Key) ? ValidateProfile(kv.Value) : "the name must be 1–40 of a-z, 0-9, _ and -"; if (error == null) _profiles[kv.Key] = kv.Value; else _refused[kv.Key] = error; } 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. private string ValidateProfile(Profile p) { if (p == null) return "it is empty"; if (p.Names == null || p.Names.Count == 0 || p.Names.Any(string.IsNullOrWhiteSpace)) return "names: give at least one, and no blank ones"; if (p.Kits == null || p.Kits.Count == 0) return "kits: give at least one; Kits is how an NPC is equipped (D217)"; foreach (string kit in p.Kits) if (!KitExists(kit)) 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" && 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"; if (p.DamageDealt < 0f) return "damageDealt: must not be negative"; if (p.DamageTaken == null || p.DamageTaken.Head < 0f || p.DamageTaken.Body < 0f || p.DamageTaken.Legs < 0f) return "damageTaken: head, body and legs must not be negative"; if (p.AimCone < 0f) return "aimCone: must not be negative"; if (p.Ranges == null || !(p.Ranges.Sense > 0f) || !(p.Ranges.Attack > 0f) || p.Ranges.LoseTarget < p.Ranges.Sense || p.Ranges.Chase < 0f) return "ranges: sense and attack above 0, loseTarget at least sense, chase not negative"; if (p.VisionCone < -1f || p.VisionCone > 1f) return "visionCone: between -1 and 1"; 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"; string kind, route; if (!ParseMode(m.Mode, out kind, out route)) return $"movement.mode: '{m.Mode}' is not wander, monument or route:"; if (kind == "wander" && !(m.Radius > 0f)) return "movement.radius: a wanderer's radius must be above 0"; return null; } private static bool ParseMode(string mode, out string kind, out string route) { kind = null; route = null; if (mode == "wander" || mode == "monument") { kind = mode; return true; } if (mode != null && mode.StartsWith("route:") && NameRule.IsMatch(mode.Substring(6))) { kind = "route"; route = mode.Substring(6); return true; } return false; } private string ValidatePlacement(Placement p) { if (p == null) return "it is empty"; if (p.Profile == null || !NameRule.IsMatch(p.Profile)) return "profile: a profile name"; if (p.Position == null || float.IsNaN(p.Position.X) || float.IsNaN(p.Position.Y) || float.IsNaN(p.Position.Z)) return "position: x, y and z"; if (p.Count < 1) return "count: at least 1"; if (!(p.Respawn >= 1f)) return "respawn: at least 1 second"; if (p.RespawnMode != "each" && p.RespawnMode != "group") return "respawnMode: each or group"; if (p.Movement != null) { string movement = ValidateMovement(p.Movement); 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"; for (int i = 0; i < r.Points.Count; i++) { NavMeshHit hit; if (r.Points[i] == null || !SampleNavmesh(r.Points[i].V, 3f, out hit)) return $"points[{i}] is not on Rust's navmesh"; } return null; } #endregion #region Rust's facts: kits, prefabs, the navmesh private bool KitExists(string kit) => Kits != null && !string.IsNullOrEmpty(kit) && Kits.Call("IsKit", kit) is bool b && b; private static Dictionary _prefabPaths; /// A scientist prefab's full path from its short name, read from the game's manifest. private static string ResolvePrefab(string shortName) { if (string.IsNullOrEmpty(shortName) || !shortName.StartsWith("scientistnpc_")) return null; if (_prefabPaths == null) { _prefabPaths = new Dictionary(); // prefabProperties lists every prefab; the manifest's `entities` does not include the NPCs. foreach (GameManifest.PrefabProperties prefab in GameManifest.Current.prefabProperties) { string path = prefab.name; if (!path.StartsWith("assets/rust.ai/agents/npcplayer/humannpc/") || !path.EndsWith(".prefab")) continue; string name = path.Substring(path.LastIndexOf('/') + 1); _prefabPaths[name.Substring(0, name.Length - ".prefab".Length)] = path; } } string full; return _prefabPaths.TryGetValue(shortName, out full) ? full : null; } /// /// Rust's own navmesh, the one a scientist walks (stage 1, Q4): its navigator is a Gen2 /// RustNavMeshAgent, and Unity's NavMesh queries see none of the open world. /// private static bool SampleNavmesh(Vector3 p, float radius, out NavMeshHit hit) => Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, radius, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas); /// False until the map's navmesh is built: 8–10 minutes on a map's first boot (stage 1, Q4). private static bool NavmeshReady() => Rust.Ai.Gen2.Nav.RustNavigation.Instance != null && Rust.Ai.Gen2.Nav.RustNavigation.Instance.IsDefaultNavmeshBuilt(); private static string Framework() => AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide"; #endregion #region The swap (D223, D232) // // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 2573b149-ba5d-458a-9b83-7f5f32c76afc. // Do not edit by hand: regenerate after a Rust update (D232). private static readonly string[] SwapNpcFields = { "ScientistNPC.RadioChatterEffects", "ScientistNPC.DeathEffects", "ScientistNPC.deathStatName", "ScientistNPC.IdleChatterRepeatRange", "ScientistNPC.radioChatterType", "HumanNPC.AdditionalLosBlockingLayer", "HumanNPC.LootSpawnSlots", "HumanNPC.aimConeScale", "HumanNPC.lastDismountTime", "NPCPlayer.MovementTickStartDelay", "NPCPlayer.VirtualInfoZone", "NPCPlayer.finalDestination", "NPCPlayer.loadouts", "NPCPlayer.movementMask", "NPCPlayer.LegacyNavigation", "NPCPlayer.NavAgent", "NPCPlayer.damageScale", "NPCPlayer.shortRange", "NPCPlayer.attackLengthMaxShortRangeScale", "BasePlayer.GestureViewModel", "BasePlayer.currentTeam", "BasePlayer.PetEntity", "BasePlayer.lastPlayerVisibility", "BasePlayer.eyeHistory", "BasePlayer.tickHistory", "BasePlayer.fallDamageEffect", "BasePlayer.drownEffect", "BasePlayer.playerFlags", "BasePlayer.Belt", "BasePlayer.weaponMoveSpeedScale", "BasePlayer.clothingBlocksAiming", "BasePlayer.clothingMoveSpeedReduction", "BasePlayer.clothingWaterSpeedBonus", "BasePlayer.clothingAccuracyBonus", "BasePlayer.equippingBlocked", "BasePlayer.eggVision", "BasePlayer.designingAIEntity", "BaseCombatEntity.spawnDeployableCorpseOnDeath", "BaseCombatEntity.deployableCorpsePrefab", "BaseCombatEntity.skeletonProperties", "BaseCombatEntity.baseProtection", "BaseCombatEntity.startHealth", "BaseCombatEntity.pickup", "BaseCombatEntity.repair", "BaseCombatEntity.ShowHealthInfo", "BaseCombatEntity.showHealthInfoThreshold", "BaseCombatEntity.lifestate", "BaseCombatEntity.sendsHitNotification", "BaseCombatEntity.sendsMeleeHitNotification", "BaseCombatEntity.markAttackerHostile", "BaseCombatEntity.faction", "BaseEntity.bounds", "BaseEntity.impactEffect", "BaseEntity.enableSaving", "BaseEntity.syncPosition", "BaseEntity.model", "BaseEntity.flags", "BaseEntity.HasBrain", "BaseNetworkable.postNetworkUpdateComponents", "BaseNetworkable.prefabID", "BaseNetworkable.globalBroadcast", "BaseNetworkable.globalNetworkBehavior", "BaseNetworkable.globalBuildingBlock", "BaseNetworkable.networkRange", }; private static readonly string[] SwapBrainFields = { "BaseAIBrain.SendClientCurrentState", "BaseAIBrain.UseQueuedMovementUpdates", "BaseAIBrain.AllowedToSleep", "BaseAIBrain.DefaultDesignSO", "BaseAIBrain.Designs", "BaseAIBrain.InstanceSpecificDesign", "BaseAIBrain.SenseRange", "BaseAIBrain.AttackRangeMultiplier", "BaseAIBrain.TargetLostRange", "BaseAIBrain.VisionCone", "BaseAIBrain.CheckVisionCone", "BaseAIBrain.CheckLOS", "BaseAIBrain.IgnoreNonVisionSneakers", "BaseAIBrain.IgnoreSneakersMaxDistance", "BaseAIBrain.IgnoreNonVisionMaxDistance", "BaseAIBrain.ListenRange", "BaseAIBrain.SenseTypes", "BaseAIBrain.HostileTargetsOnly", "BaseAIBrain.IgnoreSafeZonePlayers", "BaseAIBrain.MaxGroupSize", "BaseAIBrain.MemoryDuration", "BaseAIBrain.RefreshKnownLOS", "BaseAIBrain.CanBeBlinded", "BaseAIBrain.BlindDurationMultiplier", "BaseAIBrain.MovementTickStartDelay", "BaseAIBrain.ClientCurrentState", "BaseAIBrain.mainInterestPoint", "BaseAIBrain.UseAIDesign", "BaseAIBrain.Pet", "BaseAIBrain.CanUseHealingItems", "BaseAIBrain.HealChance", "BaseAIBrain.HealBelowHealthFraction", }; // private FieldInfo[] _npcFields; private FieldInfo[] _brainFields; private readonly List _fieldsMissing = new List(); private readonly List _fieldsAdded = new List(); /// /// Resolves the generated field list against the running game. A name Rust has since /// removed is reported, not fatal. On Carbon, where Rust's assembly is unmodified, the /// list is also checked against the rule it was generated with, so a field Rust has /// ADDED is named at boot (D232). On Oxide the rule cannot be applied: its patcher makes /// private fields public. /// private void ResolveFields() { _fieldsMissing.Clear(); _fieldsAdded.Clear(); _npcFields = ResolveFields(typeof(ScientistNPC), SwapNpcFields, _fieldsMissing); _brainFields = ResolveFields(typeof(ScientistBrain), SwapBrainFields, _fieldsMissing); if (Framework() == "carbon") { _fieldsAdded.AddRange(RuleFields(typeof(ScientistNPC)).Except(SwapNpcFields)); _fieldsAdded.AddRange(RuleFields(typeof(ScientistBrain)).Except(SwapBrainFields)); } if (_fieldsMissing.Count > 0) PrintWarning($"The swap's field list names fields this Rust no longer has: {string.Join(", ", _fieldsMissing)}. Regenerate it (tools/fieldlist)."); if (_fieldsAdded.Count > 0) PrintWarning($"Rust has fields the swap's list does not copy: {string.Join(", ", _fieldsAdded)}. Regenerate it (tools/fieldlist)."); } private static FieldInfo[] ResolveFields(Type type, string[] names, List missing) { var chain = new List(); for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType) chain.Add(t); var found = new List(); foreach (string name in names) { int dot = name.IndexOf('.'); string typeName = name.Substring(0, dot); Type owner = chain.FirstOrDefault(t => t.Name == typeName); FieldInfo f = owner?.GetField(name.Substring(dot + 1), BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); if (f == null) missing.Add(name); else found.Add(f); } return found.ToArray(); } /// tools/fieldlist's rule, applied at run time: only meaningful on an unmodified assembly. private static List RuleFields(Type type) { var names = new List(); for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType) { foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) { if (f.IsInitOnly || f.IsLiteral) continue; bool serialised = (f.IsPublic && (f.Attributes & FieldAttributes.NotSerialized) == 0) || f.IsDefined(typeof(SerializeField), false); if (serialised && !typeof(Delegate).IsAssignableFrom(f.FieldType)) names.Add($"{t.Name}.{f.Name}"); } } return names; } private static void CopyFields(FieldInfo[] fields, Component from, Component to) { foreach (FieldInfo f in fields) f.SetValue(to, f.GetValue(from)); } /// /// Makes one of our NPCs, not yet spawned: the prefab's scientist with its two components /// swapped for ours. Everything the brain reads once, as it starts, is set here (stage 1, /// Q3: Senses.Init copies the sense values a single time). /// private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, out string error) { error = null; BaseEntity made = GameManager.server.CreateEntity(ResolvePrefab(p.Prefab), at, Quaternion.Euler(0f, yaw, 0f), false); ScientistNPC stock = made as ScientistNPC; ScientistBrain stockBrain = stock != null ? stock.GetComponent() : null; if (stock == null || stock.GetType() != typeof(ScientistNPC) || stockBrain == null || stockBrain.GetType() != typeof(ScientistBrain)) { error = $"prefab '{p.Prefab}' is not a plain scientist (ScientistNPC with ScientistBrain)"; if (made != null) UnityEngine.Object.Destroy(made.gameObject); return null; } GameObject go = stock.gameObject; RunicNpcPlayer npc = go.AddComponent(); RunicNpcBrain brain = go.AddComponent(); CopyFields(_npcFields, stock, npc); CopyFields(_brainFields, stockBrain, brain); UnityEngine.Object.DestroyImmediate(stock, true); UnityEngine.Object.DestroyImmediate(stockBrain, true); npc.enableSaving = false; npc.startHealth = p.Health; npc.damageScale = p.DamageDealt; npc.aimConeScale = p.AimCone; brain.SenseRange = p.Ranges.Sense; brain.TargetLostRange = p.Ranges.LoseTarget; brain.VisionCone = p.VisionCone; // Our own sleep replaces an AI zone's (D235): a zone must never put ours to sleep. brain.AllowedToSleep = false; brain.Sentry = p.IsSentry; return npc; } #endregion #region The NPC /// /// 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, IAISenses { public string RnName = "Scientist"; public string ProfileName; public Profile Profile; public string Owner; public string PlacementId; public int Slot = -1; public Vector3 Home; public float HomeYaw; public string MoveKind = "wander"; public float MoveRadius = 20f; public string RouteName; public string Kit; public bool Died; public readonly Dictionary Contributors = new Dictionary(); public readonly HashSet ThresholdsFired = new HashSet(); public ulong NetId => net != null ? net.ID.Value : 0UL; public RunicNpcBrain RnBrain => Brain as RunicNpcBrain; public override string displayName => RnName; /// /// HumanNPC.AttackerInfo writes the prefab's short name as the killer on the victim's /// death screen, after BasePlayer's display name (stage 1, Q6). Ours puts the name back. /// public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info) { base.AttackerInfo(info); info.attackerName = displayName; } /// /// The profile's attack range replaces the weapon's. HumanNPC.TickAttack asks through /// IAIAttack, which this class re-implements, so the call lands here. /// public new bool IsTargetInRange(BaseEntity entity, out float dist) { dist = Vector3.Distance(entity.transform.position, transform.position); return dist <= EngagementRange(); } 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) { // A hit with no bone (fire, an explosion, a fall) reports every area at once, // (HitArea)(-1), so only an exact area counts as head or legs; the rest is body. HitArea area = info.boneArea; float scale = area == HitArea.Head ? Profile.DamageTaken.Head : area == HitArea.Leg || area == HitArea.Foot ? Profile.DamageTaken.Legs : Profile.DamageTaken.Body; 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 // protection and never past zero. A killing blow is credited in OnDied, which // base.Hurt reaches before it returns, so OnRunicNpcDied carries it. BasePlayer attacker = info?.InitiatorPlayer; ulong attackerId = attacker != null && !attacker.IsNpc ? (ulong)attacker.userID : 0UL; _pendingAttacker = attackerId; _healthBefore = health; base.Hurt(info); _pendingAttacker = 0UL; 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; private float _healthBefore; private void Credit(ulong id, float damage) { if (damage <= 0f) return; float sum; Contributors.TryGetValue(id, out sum); Contributors[id] = sum + damage; } public override void OnDied(HitInfo info) { if (_pendingAttacker != 0UL) Credit(_pendingAttacker, _healthBefore); Died = true; _self?.HandleDied(this, info); base.OnDied(info); } } /// /// Our brain: Rust's scientist brain, whose AI design still decides when to chase, fight /// and take cover, with three things of ours (D233, D235): /// /// /// the roam state replaced by our wander or route follower (a monument /// roamer keeps Rust's own); /// a sentry's moving states replaced by one that holds its spot and turns; /// sleep: past the profile's distance from every player it walks home, then /// stops thinking; a player in range wakes it. /// /// /// The design references states by type, so a replaced state keeps its events: the /// shooting still happens where Rust's design puts it. /// public class RunicNpcBrain : ScientistBrain { public bool Sentry; public enum RestState { Awake, GoingHome, Asleep } public RestState Rest = RestState.Awake; public int Leashes; private float _nextCheck; private bool _playerNear = true; private float _homeBy; private static readonly BasePlayer[] NearBuffer = new BasePlayer[4]; private static readonly AIState[] SentryHolds = { AIState.Roam, AIState.Chase, AIState.Combat, AIState.TakeCover, AIState.Cover, AIState.MoveTowards, AIState.MoveToVector3, AIState.FollowPath, AIState.NavigateHome, AIState.Flee, AIState.Blinded }; private static readonly HashSet Aggressive = new HashSet { AIState.Chase, AIState.Combat, AIState.TakeCover, AIState.Cover }; public override void AddStates() { base.AddStates(); RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer; if (npc == null) return; if (Sentry) { foreach (AIState type in SentryHolds) if (states.ContainsKey(type)) Replace(new HoldState(type, Aggressive.Contains(type))); return; } // 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 { 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) { states.Remove(state.StateType); AddState(state); } public override void Think(float delta) { RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer; // Rust's think manager can reach a brain before Unity has started it, and until then // it has no navigator and no state: leave that think to Rust. if (npc == null || npc.Profile == null || npc.IsDead() || Navigator == null) { base.Think(delta); return; } float now = Time.time; if (now >= _nextCheck) { _nextCheck = now + 2f; _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) { case RestState.Asleep: if (!_playerNear) { lastThinkTime = now; return; } Rest = RestState.Awake; ((IAISleepable)this).WakeAI(); break; case RestState.GoingHome: if (_playerNear) { Rest = RestState.Awake; break; } lastThinkTime = now; if (Flat(npc.transform.position - npc.Anchor) <= 2f || now >= _homeBy) Sleep(); else if (!Navigator.Moving && !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal)) Sleep(); return; default: 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.Anchor, BaseNavigator.NavigationSpeed.Normal)) Sleep(); return; } break; } if (!Sentry && npc.Profile.Ranges.Chase > 0f && CurrentState != null && CurrentState.AgrresiveState && 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 /// navmesh, as Rust's own navigator warps, and that spot is its home until it respawns. /// private void Reground(RunicNpcPlayer npc) { Vector3 at = npc.transform.position; if (OffMeshReason(at) == null) return; NavMeshHit hit; if (Navigator.Agent == null || !SampleNavmesh(at, Fallback, out hit)) return; Navigator.Stop(); Navigator.Agent.Warp(hit.position); npc.transform.position = hit.position; npc.Home = hit.position; Regrounded++; _self?.NoteRegrounded(npc, at, hit.position); } public int Regrounded; /// Past its chase range from home: forget the target and head back. public void Leash(RunicNpcPlayer npc) { Leashes++; npc.NpcTarget = null; npc.Engaged = false; Rust.Ai.SimpleAIMemory memory = Senses.Memory; memory.All.Clear(); memory.Players.Clear(); memory.Targets.Clear(); memory.Threats.Clear(); memory.LOS.Clear(); Events.Memory.Entity.Clear(); Senses.DelaySenseUpdate(5f); npc.NextSense = Time.time + 5f; Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Fast); } private void Sleep() { Rest = RestState.Asleep; Navigator.Stop(); ((IAISleepable)this).SleepAI(); } private static bool PlayerWithin(Vector3 at, float distance) => BaseEntity.Query.Server.GetPlayersInSphere(at, distance, NearBuffer, p => !p.IsNpc && p.IsAlive() && !p.IsSleeping()) > 0; private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude; } /// D233 wander: a walkable point within the radius of home, walk there, finish. public class WanderState : BaseAIBrain.BasicAIState { private StateStatus _status = StateStatus.Error; public WanderState() : base(AIState.Roam) { } public override void StateEnter(BaseAIBrain brain, BaseEntity entity) { base.StateEnter(brain, entity); _status = StateStatus.Error; RunicNpcPlayer npc = entity as RunicNpcPlayer; if (npc == null) return; for (int i = 0; i < 6; i++) { 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; return; } } } public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) { base.StateThink(delta, brain, entity); if (_status == StateStatus.Error) return _status; return brain.Navigator.Moving ? StateStatus.Running : StateStatus.Finished; } public override void StateLeave(BaseAIBrain brain, BaseEntity entity) { base.StateLeave(brain, entity); brain.Navigator.Stop(); } } /// /// 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 { private int _index = -1; private int _step = 1; private StateStatus _status = StateStatus.Error; public int Index => _index; public RouteState() : base(AIState.Roam) { } public override void StateEnter(BaseAIBrain brain, BaseEntity entity) { base.StateEnter(brain, entity); _status = StateStatus.Error; RunicNpcPlayer npc = entity as RunicNpcPlayer; Route route = npc != null ? _self?.RouteFor(npc) : null; if (route == null) return; if (_index < 0 || _index >= route.Points.Count) _index = Nearest(route, entity.transform.position); _status = Go(brain, route) ? StateStatus.Running : StateStatus.Error; } public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) { base.StateThink(delta, brain, entity); if (_status == StateStatus.Error) return _status; RunicNpcPlayer npc = entity as RunicNpcPlayer; Route route = npc != null ? _self?.RouteFor(npc) : null; if (route == null) return _status = StateStatus.Error; if (_index >= route.Points.Count) _index = Nearest(route, entity.transform.position); if (!brain.Navigator.Moving || Vector3.Distance(entity.transform.position, route.Points[_index].V) < 1.5f) { Advance(route); if (!Go(brain, route)) return _status = StateStatus.Error; } return StateStatus.Running; } public override void StateLeave(BaseAIBrain brain, BaseEntity entity) { base.StateLeave(brain, entity); brain.Navigator.Stop(); } /// Sets off for the current point, skipping any Rust cannot path to. private bool Go(BaseAIBrain brain, Route route) { for (int tries = 0; tries < route.Points.Count; tries++) { if (brain.Navigator.SetDestination(route.Points[_index].V, BaseNavigator.NavigationSpeed.Slow)) return true; Advance(route); } return false; } private void Advance(Route route) { int n = route.Points.Count; if (route.Loop) { _index = (_index + 1) % n; return; } if (_index + _step < 0 || _index + _step >= n) _step = -_step; _index += _step; } private static int Nearest(Route route, Vector3 at) { int best = 0; float bestDistance = float.MaxValue; for (int i = 0; i < route.Points.Count; i++) { float d = (route.Points[i].V - at).sqrMagnitude; if (d < bestDistance) { bestDistance = d; best = i; } } return best; } } /// /// Our chase, for a wander or route roamer: close to three quarters of the attack /// range, never past the chase range from home. A target that stays out of reach while ours /// stands at that edge for 5 s is given up (the leash), and ours walks home. /// public class PursueState : BaseAIBrain.BasicAIState { private float _next; private float _edgeSince = -1f; public PursueState() : base(AIState.Chase) { AgrresiveState = true; } public override void StateEnter(BaseAIBrain brain, BaseEntity entity) { base.StateEnter(brain, entity); _next = 0f; _edgeSince = -1f; } public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) { base.StateThink(delta, brain, entity); BaseEntity target = brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot); RunicNpcPlayer npc = entity as RunicNpcPlayer; if (target == null || npc == null || npc.Profile == null) return StateStatus.Error; Vector3 here = entity.transform.position; Vector3 there = target.transform.position; float distance = Vector3.Distance(here, there); if (distance <= 10f || brain.Senses.Memory.IsLOS(target)) brain.Navigator.SetFacingDirectionEntity(target); else brain.Navigator.ClearFacingDirectionOverride(); if (Time.time < _next) return StateStatus.Running; _next = Time.time + UnityEngine.Random.Range(0.5f, 1f); float stopAt = Mathf.Max(2f, npc.Profile.Ranges.Attack * 0.75f); if (distance <= stopAt) { brain.Navigator.Stop(); _edgeSince = -1f; return StateStatus.Running; } Vector3 goal = there - (there - here).normalized * stopAt; float chase = npc.Profile.Ranges.Chase; Vector3 anchor = npc.Anchor; Vector3 fromHome = goal - anchor; fromHome.y = 0f; bool clamped = chase > 0f && fromHome.magnitude > chase; if (clamped) 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) { if (_edgeSince < 0f) _edgeSince = Time.time; if (Time.time - _edgeSince >= 5f) { (brain as RunicNpcBrain)?.Leash(npc); return StateStatus.Error; } } else _edgeSince = -1f; NavMeshHit hit; if (!SampleNavmesh(goal, 3f, out hit)) return StateStatus.Error; brain.Navigator.SetDestination(hit.position, distance > 10f ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal); return StateStatus.Running; } public override void StateLeave(BaseAIBrain brain, BaseEntity entity) { base.StateLeave(brain, entity); brain.Navigator.Stop(); brain.Navigator.ClearFacingDirectionOverride(); } } /// /// A sentry's stand-in for every state that would move it: it stops, and turns to face the /// design's current target. The design's attack events still run in it. /// public class HoldState : BaseAIBrain.BasicAIState { public HoldState(AIState type, bool aggressive) : base(type) { AgrresiveState = aggressive; } public override void StateEnter(BaseAIBrain brain, BaseEntity entity) { base.StateEnter(brain, entity); brain.Navigator.Stop(); } public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) { base.StateThink(delta, brain, entity); BaseEntity target = brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot); if (target != null) brain.Navigator.SetFacingDirectionEntity(target); else brain.Navigator.ClearFacingDirectionOverride(); return StateStatus.Running; } public override void StateLeave(BaseAIBrain brain, BaseEntity entity) { base.StateLeave(brain, entity); brain.Navigator.ClearFacingDirectionOverride(); } } #endregion #region Factions, sensing and the fight (stage 5) // Rust's own design still owns every fight with a player. What is ours: whom else an NPC fights // (D231, D254, D255), its allies' alarm (D256), the clan or team it serves (D257), escorts (D258, // D269), turrets (D259, D264, D267), the kit's extras (D260), PVE (D261, D271) and the tether (D272). // An NPC with no faction settings, no ally and no escort costs one dictionary look-up a think. /// Rust's scientists (not ours), for the profiles hostile to scientists. private readonly HashSet _scientists = new HashSet(); /// Rust's animals, old and new, for the profiles hostile to animals. private readonly HashSet _animals = new HashSet(); /// Our live NPCs by profile, for the profiles hostile to another profile. private readonly Dictionary> _byProfile = new Dictionary>(); /// Our NPCs with an ally or an escort: the only ones a damage event is checked for (D257, D269). private readonly HashSet _defenders = new HashSet(); /// Moves whenever profiles or the faction table change, so each NPC recomputes whom it hunts. private int _huntsVersion; private static bool IsScientist(BaseEntity e) => e is ScientistNPC && !(e is RunicNpcPlayer); private static bool IsAnimal(BaseEntity e) => e is BaseNpc || e is Rust.Ai.Gen2.BaseNPC2; private void Track(BaseNetworkable entity) { BaseCombatEntity e = entity as BaseCombatEntity; if (e == null || e is RunicNpcPlayer) return; if (IsScientist(e)) _scientists.Add(e); else if (IsAnimal(e)) _animals.Add(e); } private void Untrack(BaseNetworkable entity) { BaseCombatEntity e = entity as BaseCombatEntity; if (e == null) return; _scientists.Remove(e); _animals.Remove(e); } private void TrackWorld() { _scientists.Clear(); _animals.Clear(); foreach (BaseNetworkable e in BaseNetworkable.serverEntities) Track(e); } private void RebuildHunts() => _huntsVersion++; /// A profile's faction, or profile:<name> when it has none: its allies are then its own (D256). private static string FactionOf(string profileName, Profile p) => string.IsNullOrEmpty(p.Faction) ? "profile:" + profileName : p.Faction; private static string FactionOf(RunicNpcPlayer npc) => FactionOf(npc.ProfileName, npc.Profile); /// /// How a profile treats a faction: its own exceptions first (D254), then its own faction /// (allied), then the table, both ways (D268); neutral when nothing says otherwise. /// private string RelationOf(string profileName, Profile p, string otherFaction, string otherProfile) { string r; if (otherProfile != null && p.Relations.TryGetValue("profile:" + otherProfile, out r)) return r; if (p.Relations.TryGetValue(otherFaction, out r)) return r; string mine = FactionOf(profileName, p); if (mine == otherFaction) return "allied"; return _factionTable.TryGetValue(PairKey(mine, otherFaction), out r) ? r : "neutral"; } /// How one of ours treats an entity: a relation for NPCs and animals, null for anything else. private string RelationTo(RunicNpcPlayer npc, BaseEntity e) { RunicNpcPlayer other = e as RunicNpcPlayer; if (other != null) return other.Profile == null ? null : RelationOf(npc.ProfileName, npc.Profile, FactionOf(other), other.ProfileName); if (IsScientist(e)) return RelationOf(npc.ProfileName, npc.Profile, "scientists", null); if (IsAnimal(e)) return RelationOf(npc.ProfileName, npc.Profile, "animals", null); return null; } /// D256: an ally is the same faction, or the same profile when there is no faction. private static bool SameSide(RunicNpcPlayer a, RunicNpcPlayer b) => a.Profile != null && b.Profile != null && FactionOf(a) == FactionOf(b); /// The registry keys an NPC hunts: scientists, animals and ours:<profile>. Null for none (D255). private HashSet HuntsOf(RunicNpcPlayer npc) { if (npc.HuntsVersion == _huntsVersion) return npc.Hunts; npc.HuntsVersion = _huntsVersion; var hunts = new HashSet(); if (RelationOf(npc.ProfileName, npc.Profile, "scientists", null) == "hostile") hunts.Add("scientists"); if (RelationOf(npc.ProfileName, npc.Profile, "animals", null) == "hostile") hunts.Add("animals"); foreach (KeyValuePair kv in _profiles) if (RelationOf(npc.ProfileName, npc.Profile, FactionOf(kv.Key, kv.Value), kv.Key) == "hostile") hunts.Add("ours:" + kv.Key); npc.Hunts = hunts.Count > 0 ? hunts : null; return npc.Hunts; } private IEnumerable Registry(string key) { if (key == "scientists") return _scientists; if (key == "animals") return _animals; HashSet ours; return key.StartsWith("ours:") && _byProfile.TryGetValue(key.Substring(5), out ours) ? ours.Cast() : null; } private void Index(RunicNpcPlayer npc, bool add) { HashSet set; if (!_byProfile.TryGetValue(npc.ProfileName, out set)) { if (!add) return; _byProfile[npc.ProfileName] = set = new HashSet(); } if (add) set.Add(npc); else { set.Remove(npc); _defenders.Remove(npc); } } private void NoteDefender(RunicNpcPlayer npc) { if (npc.HasAlly || npc.Escort != null) _defenders.Add(npc); else _defenders.Remove(npc); } /// How far from its anchor ours may fight an NPC (D263): its chase range, or for a sentry its attack range. private static float LeashOf(RunicNpcPlayer npc) => npc.Profile.IsSentry ? npc.Profile.Ranges.Attack : npc.Profile.Ranges.Chase > 0f ? npc.Profile.Ranges.Chase : npc.Profile.Ranges.Sense; private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude; // ---- the fight ---- /// LOS tests a sense may spend: the nearest few candidates only (stage 5 spike). private const int LosBudget = 3; /// Rust's own line-of-sight mask for an NPC's shots. private const int LosMask = 1218519041; private static readonly BaseCombatEntity[] Best = new BaseCombatEntity[LosBudget]; private static readonly float[] BestD2 = new float[LosBudget]; /// /// Our own fight, after Rust's think (stage 5). With no player to fight and a target of ours — /// sensed (D231, D263), learned from an ally (D256), or whoever hurt it or its charge (D257, /// D268) — it faces it, closes in within its leash and its zone, and hands Rust's own attack /// routine the target, so every shot is Rust's. /// private void Fight(RunicNpcPlayer npc, RunicNpcBrain brain, float delta) { float now = Time.time; HashSet hunts = HuntsOf(npc); BaseCombatEntity t = npc.NpcTarget; Vector3 anchor = npc.Anchor; float leash = LeashOf(npc); if (t != null && (t.IsDestroyed || t.IsDead() || Spares(npc, t) || Vector3.Distance(npc.transform.position, t.transform.position) > Mathf.Max(npc.Profile.Ranges.LoseTarget, leash) + 10f || Flat(t.transform.position - anchor) > leash + Mathf.Max(npc.Profile.Ranges.Attack, 10f))) t = npc.NpcTarget = null; if (hunts != null && now >= npc.NextSense && TurretOf(t) == null) { npc.NextSense = now + 1f; long s0 = Stopwatch.GetTimestamp(); BaseCombatEntity found = Sense(npc, hunts, anchor, leash); SenseMs += (Stopwatch.GetTimestamp() - s0) * 1000.0 / Stopwatch.Frequency; SenseRuns++; if (found != null) t = npc.NpcTarget = found; } bool player = brain.FightingAPlayer(); npc.Engaged = t != null && !player; if (npc.Profile.KitUse.Any) KitTick(npc, brain, npc.Engaged ? t : PlayerTarget(brain), now); if (!npc.Engaged) return; if (now >= npc.NextLos) { npc.NextLos = now + 0.5f; npc.TargetLos = Los(npc, t); } brain.Navigator.SetFacingDirectionEntity(t); float distance = Vector3.Distance(npc.transform.position, t.transform.position); if (!npc.Profile.IsSentry) { if (!npc.TargetLos || distance > npc.EngagementRange() * 0.9f) { if (now >= npc.NextMove) { npc.NextMove = now + 1f; Vector3 goal = t.transform.position; Vector3 fromAnchor = goal - anchor; fromAnchor.y = 0f; if (fromAnchor.magnitude > leash) goal = anchor + fromAnchor.normalized * leash; NavMeshHit hit; if ((npc.Tether == null || InTether(npc, goal)) && SampleNavmesh(goal, 3f, out hit)) brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Fast); else if (brain.Navigator.Moving) brain.Navigator.Stop(); } } else if (brain.Navigator.Moving) brain.Navigator.Stop(); } if (now >= npc.KitBusyUntil) npc.AttackTick(delta, t, npc.TargetLos); } /// The player Rust's design is fighting, for the kit's extras (D260). private static BaseEntity PlayerTarget(RunicNpcBrain brain) { if (brain.CurrentState == null || !brain.CurrentState.AgrresiveState) return null; return brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot); } public static double SenseMs; public static long SenseRuns; /// /// The nearest hostile within its sense range AND its leash around its anchor (D263), from the /// registry rather than a physics query: distances first, then line of sight on the nearest /// few only. Stage 5's spike measured this at 1.5–2.5 ms a think in a 100-against-100 fight. /// private BaseCombatEntity Sense(RunicNpcPlayer npc, HashSet hunts, Vector3 anchor, float leash) { for (int i = 0; i < LosBudget; i++) { Best[i] = null; BestD2[i] = float.MaxValue; } Vector3 at = npc.transform.position; float sense = npc.Profile.IsSentry ? Mathf.Min(npc.Profile.Ranges.Sense, npc.Profile.Ranges.Attack) : npc.Profile.Ranges.Sense; float r2 = sense * sense; float leash2 = leash * leash; foreach (string key in hunts) { IEnumerable set = Registry(key); if (set == null) continue; foreach (BaseCombatEntity e in set) { if (e == null || e.IsDestroyed || e == npc || e.IsDead()) continue; Vector3 p = e.transform.position; float d2 = (p - at).sqrMagnitude; if (d2 > r2 || d2 >= BestD2[LosBudget - 1]) continue; Vector3 fromAnchor = p - anchor; if (fromAnchor.x * fromAnchor.x + fromAnchor.z * fromAnchor.z > leash2) continue; int k = LosBudget - 1; while (k > 0 && BestD2[k - 1] > d2) { Best[k] = Best[k - 1]; BestD2[k] = BestD2[k - 1]; k--; } Best[k] = e; BestD2[k] = d2; } } for (int i = 0; i < LosBudget && Best[i] != null; i++) if ((npc.Tether == null || InTether(npc, Best[i].transform.position)) && Los(npc, Best[i])) return Best[i]; return null; } /// /// A line from its eyes to the target's centre that counts the target's own collider as seen. /// Not Rust's CanSeeTarget, even for a scientist or one of ours: its rays are unbounded, /// so on open ground they run past the target into the terrain behind it, and an NPC 25 m off /// read as unseen until ours had walked right up to it (stage 5 build). Players are Rust's /// design's to see; this is only for what our own fight takes on. /// private static bool Los(RunicNpcPlayer npc, BaseCombatEntity t) { RaycastHit hit; if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore)) return true; BaseEntity e = hit.GetEntity(); return e != null && (e == t || e.EqualNetID(t) || (e.HasParent() && e.GetParentEntity() == t)); } /// /// The turret behind a hit. A flame turret hurts through its fireballs, whose creator is the /// turret or the fireball that spawned them, so the chain is followed (stage 5 spike). /// private static BaseCombatEntity TurretOf(BaseEntity e) { for (int i = 0; i < 6 && e is FireBall; i++) e = e.creatorEntity == e ? null : e.creatorEntity; return e is AutoTurret || e is FlameTurret || e is GunTrap ? (BaseCombatEntity)e : null; } /// /// Whom one of ours never targets: whom it escorts (D269), its allied clan's, team's or player's /// people (D257), and NPCs it is allied to (D254). /// private bool Spares(RunicNpcPlayer npc, BaseEntity e) { if (e == null) return false; if (npc.Escort != null && (e == npc.Escort || e.EqualNetID(npc.Escort))) return true; BasePlayer p = e as BasePlayer; if (p != null && !p.IsNpc) return npc.HasAlly && IsAllyMember(npc, p); return npc.Profile != null && (e is RunicNpcPlayer || IsScientist(e) || IsAnimal(e)) && RelationTo(npc, e) == "allied"; } // ---- hurt: shoot back (D259, D268) and raise the alarm (D256) ---- /// Called from the NPC's own Hurt, after Rust's. private void NpcHurt(RunicNpcPlayer npc, HitInfo info) { BaseEntity source = info.Initiator; BaseCombatEntity attacker = TurretOf(source) ?? TurretOf(info.Weapon != null ? info.Weapon.GetParentEntity() : null) ?? source as BaseCombatEntity; if (attacker == null || attacker == npc || attacker.IsDestroyed || attacker.IsDead()) return; RunicNpcPlayer ours = attacker as RunicNpcPlayer; if (ours != null && SameSide(npc, ours)) return; // an ally's fire or grenade (D266): no fight among friends. BasePlayer player = attacker as BasePlayer; bool real = player != null && !player.IsNpc; if (real && npc.HasAlly && IsAllyMember(npc, player)) return; // D268: whoever shot it is fought back, whatever the table says. Rust's design already learns // a player (HumanNPC.Hurt); an NPC, an animal or a turret is ours to fight. if (!real && (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed)) npc.NpcTarget = attacker; Alarm(npc, attacker, real); } /// D256: every ally within the profile's alert radius learns the attacker, seen or not. private void Alarm(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real) { float radius = npc.Profile.AlertRadius; float now = Time.time; if (radius <= 0f || now < npc.NextAlert) return; npc.NextAlert = now + 1f; float r2 = radius * radius; Vector3 at = npc.transform.position; foreach (RunicNpcPlayer other in _npcs.Values) { if (other == npc || other.IsDestroyed || other.IsDead() || other.Profile == null || !SameSide(npc, other)) continue; if ((other.transform.position - at).sqrMagnitude > r2) continue; Learn(other, attacker, real); Alarms++; } } public static long Alarms, Defences; /// Tells one of ours about an attacker: Rust's memory for a player, our own target for anything else. private void Learn(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real) { if (Spares(npc, attacker)) return; npc.RnBrain?.Rouse(); if (real) { BaseAIBrain brain = npc.Brain; if (brain?.Senses?.Memory != null) brain.Senses.Memory.SetKnown(attacker, npc, brain.Senses); return; } if (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed) npc.NpcTarget = attacker; } // ---- allies: a clan, a team or a player (D257, D270) ---- private sealed class Members { public HashSet Ids = new HashSet(); public float ReadAt = float.NegativeInfinity; } private readonly Dictionary _members = new Dictionary(); /// Whether a player belongs to whom one of ours is allied to. private bool IsAllyMember(RunicNpcPlayer npc, BasePlayer p) { if (npc.AllyPlayer != 0UL && (ulong)p.userID == npc.AllyPlayer) return true; if (npc.AllyClan != 0L && p.clanId == npc.AllyClan) return true; return npc.AllyTeam != 0UL && p.currentTeam == npc.AllyTeam; } /// /// Whether a Steam id belongs to whom one of ours is allied to: for what they own (D257), when /// the owner may be offline. Clan and team rosters are re-read every five seconds at most. /// private bool IsAllyMember(RunicNpcPlayer npc, ulong id) { if (id == 0UL) return false; if (npc.AllyPlayer == id) return true; if (npc.AllyClan != 0L && Roster("clan:" + npc.AllyClan).Contains(id)) return true; return npc.AllyTeam != 0UL && Roster("team:" + npc.AllyTeam).Contains(id); } private HashSet Roster(string key) { Members m; if (!_members.TryGetValue(key, out m)) _members[key] = m = new Members(); float now = Time.realtimeSinceStartup; if (now - m.ReadAt < 5f) return m.Ids; m.ReadAt = now; m.Ids.Clear(); if (key.StartsWith("team:")) { RelationshipManager.PlayerTeam team = RelationshipManager.ServerInstance != null ? RelationshipManager.ServerInstance.FindTeam(ulong.Parse(key.Substring(5))) : null; if (team?.members != null) foreach (ulong id in team.members) m.Ids.Add(id); } else { IClan clan = ReadClan(long.Parse(key.Substring(5))); if (clan?.Members != null) foreach (ClanMember member in clan.Members) m.Ids.Add(member.SteamId); } return m.Ids; } /// A clan by id from the game's own clans, or null. Never waits: a clan not cached is read only if the read is instant. private static IClan ReadClan(long clanId) { IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null; if (backend == null) return null; IClan clan; if (backend.TryGet(clanId, out clan) && clan != null) return clan; System.Threading.Tasks.ValueTask> read = backend.Get(clanId); return read.IsCompleted && read.Result.IsSuccess ? read.Result.Value : null; } /// /// D257 and D269: a damage event against whom ours escort or serve. The attacker is fought by /// every defender within its leash of the victim; a player, by Rust's own design. /// private void Defend(BaseCombatEntity victim, HitInfo info) { BaseEntity source = info.Initiator; BaseCombatEntity attacker = TurretOf(source) ?? source as BaseCombatEntity; if (attacker == null || attacker == victim || attacker.IsDead()) return; BasePlayer victimPlayer = victim as BasePlayer; BasePlayer attackerPlayer = attacker as BasePlayer; bool real = attackerPlayer != null && !attackerPlayer.IsNpc; float now = Time.time; foreach (RunicNpcPlayer d in _defenders) { if (d == null || d.IsDestroyed || d.IsDead() || d.Profile == null || d == attacker || now < d.NextDefend) continue; bool charge = d.Escort != null && (victim == d.Escort || victim.EqualNetID(d.Escort)); if (!charge && d.HasAlly) { charge = victimPlayer != null && !victimPlayer.IsNpc ? IsAllyMember(d, victimPlayer) : victim.OwnerID != 0UL && IsAllyMember(d, victim.OwnerID); } if (!charge) continue; // The attacker is not fought if it is one of the side being defended. if (real && d.HasAlly && IsAllyMember(d, attackerPlayer)) continue; RunicNpcPlayer ours = attacker as RunicNpcPlayer; if (ours != null && SameSide(d, ours)) continue; float reach = Mathf.Max(LeashOf(d), d.Profile.AlertRadius); if (Flat(victim.transform.position - d.Anchor) > reach) continue; d.NextDefend = now + 0.5f; Learn(d, attacker, real); Defences++; } } // ---- escort (D258, D269) ---- /// /// Sets whom one of ours escorts, or clears it with null. Null on success, else why not: a /// sentry never moves, and a dead or disconnected player cannot be escorted. /// private string SetEscort(RunicNpcPlayer npc, BaseEntity target) { if (target == null) { npc.Escort = null; NoteDefender(npc); return null; } if (npc.Profile.IsSentry) return $"{npc.RnName} is a sentry and never moves, so it cannot escort"; if (target == npc || target.IsDestroyed) return "there is nothing to escort"; BasePlayer p = target as BasePlayer; if (p != null && (!p.IsConnected || p.IsDead()) && !p.IsNpc) return $"{p.displayName} is not on the server"; BaseCombatEntity c = target as BaseCombatEntity; if (c != null && c.IsDead()) return "it is dead"; npc.Escort = target; npc.NpcTarget = null; NoteDefender(npc); npc.RnBrain?.Rouse(); return null; } /// D269: whom it escorts died or left the server; it walks back to its spot. private void CheckEscort(RunicNpcPlayer npc) { // Unity's == says a destroyed entity is null, which is exactly the case to catch here. BaseEntity e = npc.Escort; if (ReferenceEquals(e, null)) return; bool destroyed = e == null || e.IsDestroyed; BasePlayer p = destroyed ? null : e as BasePlayer; BaseCombatEntity c = destroyed ? null : e as BaseCombatEntity; bool gone = destroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected); if (!gone) return; string who = p != null ? p.displayName : destroyed ? "its charge" : e.ShortPrefabName; npc.Escort = null; NoteDefender(npc); npc.RnBrain?.Navigator?.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal); Interface.CallHook("OnRunicNpcEscortEnded", npc, npc.ProfileName, npc.Owner); Puts($"{npc.RnName} ({npc.Owner}) no longer escorts {who}, who is gone; it walks back to its spot."); } // ---- the tether (D272) ---- [PluginReference] private Plugin ZoneManager; private bool _tetherWarned; /// /// Whether a point is inside one of ours' zone. With ZoneManager gone the NPC is not held, /// and the log says so once, rather than every NPC freezing where it stands. /// private bool InTether(RunicNpcPlayer npc, Vector3 at) { if (npc.Tether == null) return true; if (ZoneManager == null) { if (!_tetherWarned) PrintWarning("ZoneManager is not loaded, so no NPC is held inside its zone."); _tetherWarned = true; return true; } object inside = ZoneManager.Call("IsPositionInZone", npc.Tether, at); return !(inside is bool) || (bool)inside; } /// Null when a zone can hold NPCs at a point, else why not. private string TetherRefusal(string zone, Vector3 at) { if (zone == null) return null; if (ZoneManager == null) return "tether: ZoneManager is not loaded on this server"; if (ZoneManager.Call("CheckZoneID", zone) == null) return $"tether: this server has no zone '{zone}'"; object inside = ZoneManager.Call("IsPositionInZone", zone, at); if (inside is bool && !(bool)inside) return $"tether: the spot is outside the zone '{zone}'"; return null; } // ---- the kit's extras (D260, D265) ---- /// The first item in its inventory whose held entity is a , or null. private static Item FindHeld(RunicNpcPlayer npc) where T : HeldEntity { foreach (ItemContainer container in new[] { npc.inventory.containerBelt, npc.inventory.containerMain }) { if (container == null) continue; foreach (Item item in container.itemList) if (item != null && item.GetHeldEntity() is T) return item; } return null; } /// Puts an item in its hands; only the belt can be held, so it is moved there first. private static bool Hold(RunicNpcPlayer npc, Item item) { if (item == null) return false; if (npc.GetActiveItem() == item) return true; if (item.parent != npc.inventory.containerBelt && !item.MoveToContainer(npc.inventory.containerBelt)) return false; npc.UpdateActiveItem(item.uid); return true; } /// Back to the main weapon (the belt's first slot, Rust's own rule). private static void MainWeapon(RunicNpcPlayer npc) { Item main = npc.inventory.containerBelt?.GetSlot(0); if (main != null && npc.GetActiveItem() != main) npc.EquipWeapon(true); } private static bool IsMain(RunicNpcPlayer npc, Item item) => item != null && npc.inventory.containerBelt?.GetSlot(0) == item; /// /// D260: each extra the profile opts into, when the moment calls for it. Rust's AI uses none of /// them in open ground (stage 5 spike), so these are ours: heal in a lull, melee close in, /// the flamethrower within its reach, a grenade at 5–20 m, a rocket at 15–80 m. /// private void KitTick(RunicNpcPlayer npc, RunicNpcBrain brain, BaseEntity target, float now) { KitUse k = npc.Profile.KitUse; if (now < npc.KitBusyUntil) return; if (k.Heal && npc.health < npc.MaxHealth() * 0.5f && now - npc.LastHurtAt > 3f && now >= npc.NextHeal) { npc.NextHeal = now + (Heal(npc) ? 6f : 15f); if (now < npc.KitBusyUntil) return; } if (target == null || target.IsDestroyed) { MainWeapon(npc); return; } float distance = Vector3.Distance(npc.transform.position, target.transform.position); if (k.Melee && distance <= 2.5f) { Item melee = FindHeld(npc); if (melee != null && !IsMain(npc, melee) && Hold(npc, melee)) { brain.Navigator.SetFacingDirectionEntity(target); if (npc.MeleeAttack()) npc.MeleeHits++; return; } } if (k.Flamethrower && distance <= 7f) { Item flame = FindHeld(npc); if (flame != null && !IsMain(npc, flame) && HasFuel(npc, flame) && Hold(npc, flame)) return; // Rust's own attack tick fires what it holds. } if (k.Grenades && distance > 5f && distance < 20f && now >= npc.NextThrow) { npc.NextThrow = now + 10f; Item thrown = FindHeld(npc); if (thrown != null && (thrown.parent == npc.inventory.containerBelt || thrown.MoveToContainer(npc.inventory.containerBelt)) && npc.TryUseThrownWeapon(thrown, target, 10f)) { // Rust's own throw: aims for 1.5 s, throws, uses one up, and re-equips the main weapon. npc.Throws++; npc.KitBusyUntil = now + 2.5f; return; } } if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket) { npc.NextRocket = now + 2f; // A real player's line of sight is the one Rust's design shoots on; an NPC (a scientist, one of // ours) or an animal is never in Rust's player memory, so it is ours. BaseCombatEntity aim = target as BaseCombatEntity; BasePlayer human = aim as BasePlayer; bool seen = human != null && !human.IsNpc ? brain.Senses?.Memory != null && brain.Senses.Memory.IsLOS(aim) : aim != null && Los(npc, aim); if (seen && FireRocket(npc, target)) { npc.KitBusyUntil = now + 1.5f; return; } } Item held = npc.GetActiveItem(); if (held != null && !IsMain(npc, held)) { HeldEntity h = held.GetHeldEntity() as HeldEntity; bool keep = (h is BaseMelee && distance <= 4f) || (h is FlameThrower && distance <= 9f); if (!keep) MainWeapon(npc); } } /// D260: one syringe, medkit or bandage, used up, its health given at once. private bool Heal(RunicNpcPlayer npc) { Item item = FindHeld(npc); ItemModConsumable consumable = item?.info.GetComponent(); if (consumable == null) return false; float amount = 0f; foreach (ItemModConsumable.ConsumableEffect effect in consumable.effects) if (effect.type == MetabolismAttribute.Type.Health || effect.type == MetabolismAttribute.Type.HealthOverTime) amount += effect.amount; if (amount <= 0f) return false; Hold(npc, item); npc.SignalBroadcast(BaseEntity.Signal.Attack); item.UseItem(1); npc.Heal(amount); npc.Heals++; npc.KitBusyUntil = Time.time + 1.5f; timer.Once(1.2f, () => { if (npc != null && !npc.IsDestroyed && !npc.IsDead()) MainWeapon(npc); }); return true; } /// /// D260: a rocket from the kit's launcher, one rocket used up. Rust's AI never fires a launcher /// (stage 5 spike), so the rocket is launched here, from its eyes, on an arc that lands on the /// target, and credited to the NPC. /// private bool FireRocket(RunicNpcPlayer npc, BaseEntity target) { Item launcher = FindHeld(npc); if (launcher == null) return false; Item ammo = null; ItemModProjectile projectile = null; foreach (ItemContainer container in new[] { npc.inventory.containerMain, npc.inventory.containerBelt }) { if (container == null) continue; foreach (Item item in container.itemList) { ItemModProjectile p = item?.info.GetComponent(); if (p != null && p.projectileObject != null && p.projectileObject.isValid && item.info.shortname.StartsWith("ammo.rocket")) { ammo = item; projectile = p; break; } } if (ammo != null) break; } if (ammo == null || !Hold(npc, launcher)) return false; Vector3 from = npc.eyes.position + npc.eyes.BodyForward() * 1f; Vector3 to = target.CenterPoint(); BaseEntity rocket = GameManager.server.CreateEntity(projectile.projectileObject.resourcePath, from); ServerProjectile mover = rocket != null ? rocket.GetComponent() : null; if (mover == null) { if (rocket != null) UnityEngine.Object.Destroy(rocket.gameObject); return false; } Vector3 direction = Arc(from, to, mover.speed, -Physics.gravity.y * mover.gravityModifier); npc.SetAimDirection(direction); rocket.SetCreatorEntity(npc); rocket.OwnerID = 0UL; mover.InitializeVelocity(direction * mover.speed); rocket.SendMessage("SetDamageScale", npc.damageScale, SendMessageOptions.DontRequireReceiver); rocket.Spawn(); ammo.UseItem(1); launcher.GetHeldEntity()?.SignalBroadcast(BaseEntity.Signal.Attack, string.Empty); npc.Rockets++; npc.NextRocket = Time.time + 10f; timer.Once(1.2f, () => { if (npc != null && !npc.IsDestroyed && !npc.IsDead()) MainWeapon(npc); }); return true; } /// The direction that lands a projectile of this speed and gravity on the point; straight at it when out of reach. private static Vector3 Arc(Vector3 from, Vector3 to, float speed, float gravity) { Vector3 delta = to - from; Vector3 flat = new Vector3(delta.x, 0f, delta.z); float x = flat.magnitude; if (gravity <= 0f || x < 0.5f) return delta.normalized; float y = delta.y; float v2 = speed * speed; float root = v2 * v2 - gravity * (gravity * x * x + 2f * y * v2); if (root < 0f) return delta.normalized; float angle = Mathf.Atan((v2 - Mathf.Sqrt(root)) / (gravity * x)); return (flat.normalized * Mathf.Cos(angle) + Vector3.up * Mathf.Sin(angle)).normalized; } private static bool HasFuel(RunicNpcPlayer npc, Item flameItem) { FlameThrower f = flameItem.GetHeldEntity() as FlameThrower; return f != null && (f.ammo > 0 || (f.fuelType != null && npc.inventory.FindItemByItemName(f.fuelType.shortname) != null)); } /// /// Called from ShotTest. D265: the main weapon is endless, as in Rust. A flamethrower held as an /// EXTRA burns the kit's fuel: an empty tank is refilled from the inventory, and with none left /// the NPC goes back to its main weapon. False means "no shot now". /// private bool FuelExtra(RunicNpcPlayer npc) { Item held = npc.GetActiveItem(); FlameThrower f = held?.GetHeldEntity() as FlameThrower; if (f == null || IsMain(npc, held) || f.ammo > 0) return true; Item fuel = f.fuelType != null ? npc.inventory.FindItemByItemName(f.fuelType.shortname) : null; if (fuel == null) { MainWeapon(npc); return false; } int take = Mathf.Min(fuel.amount, f.maxAmmo); fuel.UseItem(take); f.ammo = take; f.SendNetworkUpdateImmediate(); return true; } // ---- turrets (D259, D264, D267) ---- /// /// turrets: ignore: no auto turret, flame turret or shotgun trap targets it. Answers only /// for our own NPCs, and null for everything else. /// private object CanBeTargeted(BaseCombatEntity entity, MonoBehaviour turret) { MarkHook("CanBeTargeted"); RunicNpcPlayer npc = entity as RunicNpcPlayer; if (npc?.Profile == null || npc.Profile.Turrets != "ignore") return null; return false; } private HarmonyLib.Harmony _harmony; private const string HarmonyId = "runicnpc.sentries"; private string _sentryPatch = "not tried"; /// /// D267: Outpost's and Bandit Camp's sentries refuse every scientist in Ignore and /// IsEntityHostile, before any Oxide or Carbon hook runs, so turrets: always is a /// Harmony patch on those two. Each answers "hostile" for our always NPCs alone and runs /// Rust's own method for everything else, Rust's scientists and bandit guards included. /// private void PatchSentries() { try { const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; MethodInfo ignore = typeof(NPCAutoTurret).GetMethod("Ignore", any, null, new[] { typeof(BasePlayer) }, null); MethodInfo hostile = typeof(NPCAutoTurret).GetMethod("IsEntityHostile", any, null, new[] { typeof(BaseCombatEntity) }, null); if (ignore == null || hostile == null) { _sentryPatch = $"not patched: this Rust has no NPCAutoTurret.{(ignore == null ? "Ignore" : "IsEntityHostile")}"; PrintWarning($"turrets: always cannot reach the safe-zone sentries ({_sentryPatch})."); return; } _harmony = new HarmonyLib.Harmony(HarmonyId); _harmony.Patch(ignore, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.IgnorePrefix)))); _harmony.Patch(hostile, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.HostilePrefix)))); _sentryPatch = "patched"; } catch (Exception e) { _sentryPatch = "failed: " + e.Message; PrintWarning($"turrets: always cannot reach the safe-zone sentries: Harmony could not patch them ({e.Message})."); } } private void UnpatchSentries() { try { _harmony?.UnpatchAll(HarmonyId); } catch (Exception e) { PrintWarning($"Harmony could not unpatch the sentries: {e.Message}"); } _harmony = null; } /// The two prefixes. Each answers for our always NPCs only; Rust's method runs for anything else. public static class SentryPatch { public static long Answered; private static bool Always(BaseEntity e) { RunicNpcPlayer npc = e as RunicNpcPlayer; return npc != null && npc.Profile != null && npc.Profile.Turrets == "always" && !npc.IsDead(); } public static bool IgnorePrefix(BasePlayer __0, ref bool __result) { if (!Always(__0)) return true; __result = false; Answered++; return false; } public static bool HostilePrefix(BaseCombatEntity __0, ref bool __result) { if (!Always(__0)) return true; __result = true; Answered++; return false; } } // ---- PVE (D261, D271) ---- /// /// TruePVE's and NextGenPVE's question. Ours are "allowed" both ways with players, as Rust's /// scientists are (D261), unless the profile turns a direction off (D271). Null for anything /// that is not one of ours facing a player. /// private object CanEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { MarkHook("CanEntityTakeDamage"); if (entity == null || info == null) return null; RunicNpcPlayer victim = entity as RunicNpcPlayer; BasePlayer attacker = info.InitiatorPlayer; if (victim?.Profile != null && attacker != null && !attacker.IsNpc) return victim.Profile.HurtByPlayers; RunicNpcPlayer from = info.Initiator as RunicNpcPlayer; BasePlayer player = entity as BasePlayer; if (from?.Profile != null && player != null && !player.IsNpc) return from.Profile.HurtsPlayers; return null; } /// /// Every damage event. Two jobs only: a harmless profile's NPC never hurts a player (D271), /// and whom ours escort or serve are defended (D257, D269). Null otherwise. /// private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { MarkHook("OnEntityTakeDamage"); if (entity == null || info == null) return null; RunicNpcPlayer from = info.Initiator as RunicNpcPlayer; BasePlayer player = entity as BasePlayer; if (from?.Profile != null && !from.Profile.HurtsPlayers && player != null && !player.IsNpc) return true; if (_defenders.Count > 0) Defend(entity, info); return null; } private void OnEntitySpawned(BaseNetworkable entity) { MarkHook("OnEntitySpawned"); if (_ready) Track(entity); } #endregion #region Registry, owners, spawning /// Every live NPC of ours, by net id. private readonly Dictionary _npcs = new Dictionary(); private sealed class Slot { public RunicNpcPlayer Npc; public float DiedAt = float.NegativeInfinity; public bool Queued; } private sealed class PlacementState { public string Id; public Placement Data; public Slot[] Slots; public string Waiting; public string LastError; /// Set while its spot is off the navmesh and its NPCs stand on the nearest (D239). public string Note; } private readonly Dictionary _placements = new Dictionary(); private sealed class SpawnRequest { public PlacementState Placement; public int Slot; } private readonly Queue _queue = new Queue(); private bool _pumpScheduled; private bool _navmeshWaitLogged; private float _tokens; private float _tokensAt; private int CountOwner(string owner) => _npcs.Values.Count(n => n.Owner == owner); private int CountProfile(string profile) => _npcs.Values.Count(n => n.ProfileName == profile); /// Null when a cap allows one more; otherwise which cap, for the caller. private string CapRefusal(string owner, string profile) { CapsConfig caps = _config.Caps; if (caps.Total > 0 && _npcs.Count >= caps.Total) return $"the server's cap of {caps.Total} RunicNPC NPCs is reached"; if (caps.PerOwner > 0 && CountOwner(owner) >= caps.PerOwner) return $"the cap of {caps.PerOwner} per owner is reached for {owner}"; if (caps.PerProfile > 0 && CountProfile(profile) >= caps.PerProfile) return $"the cap of {caps.PerProfile} per profile is reached for '{profile}'"; return null; } /// The spawn-rate cap, a token bucket; always true when no rate is set. private bool TakeSpawnToken() { float rate = _config.Caps.SpawnsPerSecond; if (rate <= 0f) return true; float now = Time.realtimeSinceStartup; _tokens = Mathf.Min(Mathf.Max(1f, rate), _tokens + (now - _tokensAt) * rate); _tokensAt = now; if (_tokens < 1f) return false; _tokens -= 1f; return true; } private static Profile Effective(Profile profile, JObject overrides) { JObject merged = JObject.FromObject(profile); if (overrides != null) merged.Merge(overrides, new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Replace }); return merged.ToObject(); } /// /// Spawns one NPC now, or returns null with the reason. Every path into the world comes /// through here: the API, and the placement queue. /// private RunicNpcPlayer Spawn(string profileName, Profile p, Vector3 at, float yaw, string owner, Movement movementOverride, string placementId, int slot, out string error) { if (!_ready || _unloading) { error = "RunicNPC is not ready"; return null; } if (!NavmeshReady()) { error = "the map's navmesh is not built yet"; return null; } error = CapRefusal(owner, profileName); if (error != null) return null; Movement movement = movementOverride ?? p.Movement; string kind, route; ParseMode(movement.Mode, out kind, out route); if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) { error = $"route '{route}' does not exist"; return null; } Vector3 position = at; if (!p.IsSentry) { // Rust's sampler reaches further down than across: without the height check, a spot // on a roof finds the ground below it (stage 2). NavMeshHit hit; if (!SampleNavmesh(at, Scatter, out hit) || Mathf.Abs(hit.position.y - at.y) > MaxDrop) { error = "the spot is not on Rust's navmesh, and only a sentry may stand off it (D219)"; return null; } position = hit.position; } if (!TakeSpawnToken()) { error = $"the spawn-rate cap of {_config.Caps.SpawnsPerSecond}/s is reached"; return null; } RunicNpcPlayer npc = CreateNpc(p, position, yaw, out error); if (npc == null) return null; npc.RnName = p.Names[UnityEngine.Random.Range(0, p.Names.Count)]; npc.ProfileName = profileName; npc.Profile = p; npc.Owner = owner; npc.PlacementId = placementId; npc.Slot = slot; npc.Home = position; npc.HomeYaw = yaw; npc.MoveKind = kind; npc.MoveRadius = movement.Radius; npc.RouteName = route; // CreateEntity(..., startActive: false) leaves the GameObject inactive and skips exactly this // call, which GameManager.CreatePrefab makes when active is true. Without it the entity still // spawns and thinks, but Unity never starts the brain: no design, no navigator, no state // (stage 1, Q1). npc.gameObject.AwakeFromInstantiate(); npc.Spawn(); npc.InitializeHealth(p.Health, p.Health); npc.Kit = p.Kits[UnityEngine.Random.Range(0, p.Kits.Count)]; npc.inventory.Strip(); object given = Kits?.Call("GiveKit", npc, npc.Kit); if (given is string) PrintWarning($"Kits did not give '{npc.Kit}' to {npc.RnName} ({profileName}): {given}"); npc.EquipWeapon(); _npcs[npc.NetId] = npc; Index(npc, true); Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner); return npc; } /// Removes one NPC from the registry, however it went: death, despawn or unload. private void Unregister(RunicNpcPlayer npc) { if (!_npcs.Remove(npc.NetId)) return; Index(npc, false); if (npc.PlacementId != null) { PlacementState ps; if (_placements.TryGetValue(npc.PlacementId, out ps) && npc.Slot >= 0 && npc.Slot < ps.Slots.Length && ps.Slots[npc.Slot].Npc == npc) { ps.Slots[npc.Slot].Npc = null; ps.Slots[npc.Slot].DiedAt = Time.realtimeSinceStartup; } } if (!npc.Died && !_unloading) Interface.CallHook("OnRunicNpcDespawned", npc, npc.Owner); } private int Despawn(IEnumerable npcs) { int n = 0; foreach (RunicNpcPlayer npc in npcs.ToList()) { if (npc == null || npc.IsDestroyed) continue; Unregister(npc); npc.Kill(); n++; } return n; } /// /// A death. It is unregistered at once, so a placement's respawn delay counts from the death, /// not from whenever the body's entity is finally removed. /// private void HandleDied(RunicNpcPlayer npc, HitInfo info) { var contributors = new Dictionary(npc.Contributors); Unregister(npc); Interface.CallHook("OnRunicNpcDied", npc, npc.ProfileName, npc.Owner, info, contributors); } private void NoteRegrounded(RunicNpcPlayer npc, Vector3 from, Vector3 to) { string text = $"its ground went from under {npc.RnName} ({from.y - to.y:0.0} m), who now stands on the nearest navmesh until it respawns"; PlacementState ps; if (npc.PlacementId != null && _placements.TryGetValue(npc.PlacementId, out ps)) ps.Note = text; Puts($"{npc.Owner}: {text}."); } private void CheckThresholds(RunicNpcPlayer npc) { float fraction = npc.health / npc.MaxHealth(); foreach (float t in npc.Profile.HealthThresholds.OrderByDescending(x => x)) if (fraction <= t && npc.ThresholdsFired.Add(t)) Interface.CallHook("OnRunicNpcHealth", npc, npc.ProfileName, t); } private Route RouteFor(RunicNpcPlayer npc) { Route route; return npc.RouteName != null && _routeFile.Routes.TryGetValue(npc.RouteName, out route) && route.Points.Count > 0 ? route : null; } #endregion #region Placements private void RebuildPlacements() { foreach (PlacementState ps in _placements.Values) Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); _placements.Clear(); _queue.Clear(); foreach (KeyValuePair kv in _placementFile.Placements) { string error = ValidatePlacement(kv.Value); if (error != null) { PrintWarning($"Placement '{kv.Key}' is ignored: {error}"); continue; } _placements[kv.Key] = NewState(kv.Key, kv.Value); } } private static PlacementState NewState(string id, Placement data) { var ps = new PlacementState { Id = id, Data = data, Slots = new Slot[data.Count] }; for (int i = 0; i < ps.Slots.Length; i++) ps.Slots[i] = new Slot(); return ps; } /// Why a placement cannot spawn right now, or null (D237: it waits, it is not removed). private string WaitReason(Placement data) { Profile p; if (!_profiles.TryGetValue(data.Profile, out p)) return _refused.ContainsKey(data.Profile) ? $"profile '{data.Profile}' is refused: {_refused[data.Profile]}" : $"profile '{data.Profile}' is missing"; Movement movement = data.Movement ?? p.Movement; string kind, route; 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; } /// Once a second: what each placement is waiting for, and which slots are due. private void TickPlacements() { if (_placementsDirty) SavePlacements(); if (!_ready || _unloading) return; float now = Time.realtimeSinceStartup; bool queued = false; foreach (PlacementState ps in _placements.Values) { string waiting = WaitReason(ps.Data); if (waiting != null) { if (ps.Waiting != waiting) PrintWarning($"Placement '{ps.Id}' waits: {waiting}"); ps.Waiting = waiting; Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); foreach (Slot s in ps.Slots) { s.DiedAt = float.NegativeInfinity; s.Queued = false; } continue; } if (ps.Waiting != null) Puts($"Placement '{ps.Id}' no longer waits."); ps.Waiting = null; if (ps.Data.RespawnMode == "group") { if (ps.Slots.All(s => s.Npc == null && !s.Queued) && now >= ps.Slots.Max(s => s.DiedAt) + ps.Data.Respawn) for (int i = 0; i < ps.Slots.Length; i++) queued |= Enqueue(ps, i); } else { for (int i = 0; i < ps.Slots.Length; i++) if (ps.Slots[i].Npc == null && !ps.Slots[i].Queued && now >= ps.Slots[i].DiedAt + ps.Data.Respawn) queued |= Enqueue(ps, i); } } if (queued) SchedulePump(); } private bool Enqueue(PlacementState ps, int slot) { ps.Slots[slot].Queued = true; _queue.Enqueue(new SpawnRequest { Placement = ps, Slot = slot }); return true; } private void SchedulePump() { if (_pumpScheduled || _unloading) return; _pumpScheduled = true; NextFrame(Pump); } private void PumpLater(float seconds) { _pumpScheduled = true; timer.Once(seconds, () => { _pumpScheduled = false; SchedulePump(); }); } /// /// Spawns queued placement slots within the frame budget, and carries the rest to the next /// frame (stage 1, Q5). Nothing spawns until the map's navmesh is built (Q4). /// private void Pump() { _pumpScheduled = false; if (_unloading) return; if (!NavmeshReady()) { if (!_navmeshWaitLogged) Puts($"Waiting for the map's navmesh before spawning {_queue.Count} placement NPC(s); on a map's first boot this takes minutes."); _navmeshWaitLogged = true; PumpLater(5f); return; } Stopwatch clock = Stopwatch.StartNew(); while (_queue.Count > 0 && clock.Elapsed.TotalMilliseconds < _config.SpawnBudgetMs) { SpawnRequest r = _queue.Peek(); if (_config.Caps.SpawnsPerSecond > 0f && _tokens < 1f && !WouldHaveToken()) { PumpLater(0.25f); return; } _queue.Dequeue(); Slot slot = r.Placement.Slots.Length > r.Slot ? r.Placement.Slots[r.Slot] : null; if (slot == null || !_placements.ContainsKey(r.Placement.Id) || _placements[r.Placement.Id] != r.Placement) continue; slot.Queued = false; if (slot.Npc != null || r.Placement.Waiting != null) continue; SpawnSlot(r.Placement, r.Slot); } if (_queue.Count > 0) SchedulePump(); } private bool WouldHaveToken() { float rate = _config.Caps.SpawnsPerSecond; return _tokens + (Time.realtimeSinceStartup - _tokensAt) * rate >= 1f; } private void SpawnSlot(PlacementState ps, int slot) { Placement data = ps.Data; Profile p = _profiles[data.Profile]; Vector3 spot = data.Position.V; Vector3 at = spot; if (slot > 0) { // A scattered spot that leaves a small floor takes the placement's own spot instead. Vector2 offset = UnityEngine.Random.insideUnitCircle * Scatter; at = spot + new Vector3(offset.x, 0f, offset.y); if (!p.IsSentry && OffMeshReason(at) != null) at = spot; } // D239: the spot was on the navmesh when it was placed. If the world has changed under it (a // player-built floor destroyed), the NPC stands on the nearest navmesh until the spot returns. string off = p.IsSentry ? null : OffMeshReason(at); NavMeshHit nearest; if (off != null && SampleNavmesh(at, Fallback, out nearest)) { string note = $"its spot is off the navmesh now ({off}), so its NPCs stand on the nearest, {Meters(at, nearest.position)} away"; if (ps.Note != note) PrintWarning($"Placement '{ps.Id}': {note}."); ps.Note = note; at = nearest.position; } else if (off == null && slot == 0) ps.Note = null; string error; RunicNpcPlayer npc = Spawn(data.Profile, p, at, data.Yaw, "placement:" + ps.Id, data.Movement, ps.Id, slot, out error); if (npc == null) { if (ps.LastError != error) PrintWarning($"Placement '{ps.Id}' could not spawn: {error}. It retries after its respawn delay."); ps.LastError = error; ps.Slots[slot].DiedAt = Time.realtimeSinceStartup; return; } 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. private int PlannedTotal() => _placements.Values.Sum(ps => ps.Slots.Length) + _npcs.Values.Count(n => n.PlacementId == null); #endregion #region The cost warning (D227) /// /// What NPCs cost the server, from stage 1's measurements on both /// frameworks (PLAN.md §9, stage 1, Q5). The rig ran about 50 fps empty, so the warning /// says what NPCs ADD to a frame, not an fps. /// private static string CostWarning(int total) { if (total <= 0) return "RunicNPC: no NPCs on this server."; float hundreds = total / 100f; string estimate = total > 100 ? " (measured up to 100; beyond that this is an estimate)" : ""; 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. 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 #region Lifecycle and hooks private void Init() { _self = this; foreach (string hook in ExpectedHooks) _hookCounts[hook] = 0L; RegisterVerbs(); } private void OnServerInitialized() { MarkHook("OnServerInitialized"); ResolveFields(); 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), " + $"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}"); TickPlacements(); } private void OnServerSave() { MarkHook("OnServerSave"); if (_placementsDirty) SavePlacements(); } private void OnEntityKill(BaseNetworkable entity) { MarkHook("OnEntityKill"); RunicNpcPlayer npc = entity as RunicNpcPlayer; if (npc != null) Unregister(npc); else Untrack(entity); } /// A plugin's NPCs die with it (§2: plugin:<name>). private void OnPluginUnloaded(Plugin plugin) { MarkHook("OnPluginUnloaded"); if (plugin == null || plugin == this) return; string owner = "plugin:" + plugin.Name; int n = Despawn(_npcs.Values.Where(x => x.Owner == owner)); if (n > 0) Puts($"{plugin.Name} unloaded: its {n} NPC(s) were removed."); } private void Unload() { _unloading = true; UnpatchSentries(); Despawn(_npcs.Values); _npcs.Clear(); if (_placementsDirty) SavePlacements(); _self = null; } private void MarkHook(string name) { long count; _hookCounts.TryGetValue(name, out count); _hookCounts[name] = count + 1; } #endregion #region The API (PLAN.md §4, docs/runicnpc/API.md) // Every call is private: Oxide's Call reaches a non-public method by name, and a public one only // with [HookMethod] (the PR check holds this). Each returns a value or null and says why in the log. private int RunicNpc_ApiVersion() => ApiVersion; private BasePlayer RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides) { string error = null; RunicNpcPlayer npc = null; Profile p; if (owner == null || !OwnerRule.IsMatch(owner)) error = "owner must be run: or plugin: (placement: owners are RunicNPC's own)"; else if (profile == null || !_profiles.TryGetValue(profile, out p)) error = profile != null && _refused.ContainsKey(profile) ? $"profile '{profile}' is refused: {_refused[profile]}" : $"no profile '{profile}'"; else { Profile effective = p; if (overrides != null) { try { effective = Effective(p, overrides); error = ValidateProfile(effective); } catch (Exception e) { error = "overrides could not be applied: " + e.Message; } } if (error == null) npc = Spawn(profile, effective, at, 0f, owner, null, null, -1, out error); } if (npc == null) PrintWarning($"RunicNpc_Spawn('{profile}', {owner}) refused: {error}"); return npc; } private int RunicNpc_Despawn(ulong netId) { RunicNpcPlayer npc; return _npcs.TryGetValue(netId, out npc) ? Despawn(new[] { npc }) : 0; } private int RunicNpc_DespawnOwner(string owner) => owner == null ? 0 : Despawn(_npcs.Values.Where(n => n.Owner == owner)); private List> RunicNpc_List(string owner) => _npcs.Values.Where(n => owner == null || n.Owner == owner).Select(Describe).ToList(); private static Dictionary Describe(RunicNpcPlayer n) { Vector3 p = n.transform.position; RunicNpcBrain brain = n.RnBrain; return new Dictionary { ["netId"] = n.NetId, ["profile"] = n.ProfileName, ["name"] = n.RnName, ["owner"] = n.Owner, ["placement"] = n.PlacementId, ["kit"] = n.Kit, ["position"] = new Dictionary { ["x"] = p.x, ["y"] = p.y, ["z"] = p.z }, ["home"] = new Dictionary { ["x"] = n.Home.x, ["y"] = n.Home.y, ["z"] = n.Home.z }, ["health"] = n.health, ["maxHealth"] = n.MaxHealth(), ["movement"] = n.MoveKind == "route" ? "route:" + n.RouteName : n.MoveKind, ["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, ["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 } }; } private JObject RunicNpc_Profiles() { JObject all = JObject.FromObject(_profileFile); all["refused"] = JObject.FromObject(_refused); return all; } /// /// Replaces the whole profile set, as a site does on every push (D221), and marks the /// server managed. Returns the profiles refused and why; the rest are in use at once. /// private Dictionary RunicNpc_SetProfiles(JObject all) { Dictionary profiles; try { profiles = all != null ? all.ToObject>() : new Dictionary(); } catch (Exception e) { return new Dictionary { ["*"] = "the profile set could not be read: " + e.Message }; } _profileFile.Profiles = profiles; _profileFile.Managed = true; SaveProfiles(); RevalidateProfiles(); TickPlacements(); return new Dictionary(_refused); } private List> RunicNpc_Placements() => _placements.Values.Select(ps => new Dictionary { ["id"] = ps.Id, ["placement"] = JObject.FromObject(ps.Data), ["alive"] = ps.Slots.Count(s => s.Npc != null), ["waiting"] = ps.Waiting, ["lastError"] = ps.LastError, ["note"] = ps.Note }).ToList(); /// Adds or replaces a placement (D222). Null on success; otherwise why not. private string RunicNpc_SetPlacement(string id, JObject placement) { if (id == null || !NameRule.IsMatch(id)) return "id: 1–40 of a-z, 0-9, _ and -"; Placement data; try { data = placement?.ToObject(); } catch (Exception e) { return "the placement could not be read: " + e.Message; } return SetPlacement(id, data); } /// /// Adds or replaces a placement, for the API and rnpc place alike. A roamer's spot must /// be on Rust's navmesh when it is set (D219); if the world changes under it later, its NPCs /// stand on the nearest navmesh instead (D239, ). /// private string SetPlacement(string id, Placement data) { string error = ValidatePlacement(data); if (error != null) return error; Profile profile; if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !profile.IsSentry) { string off = OffMeshReason(data.Position.V); if (off != null) 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)); _placementFile.Placements[id] = data; _placements[id] = NewState(id, data); _placementsDirty = true; Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}"); TickPlacements(); PlacementChanged(id, "set"); return null; } private bool RunicNpc_RemovePlacement(string id) { PlacementState ps; if (id == null || !_placements.TryGetValue(id, out ps)) return false; _placements.Remove(id); _placementFile.Placements.Remove(id); _placementsDirty = true; Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); PlacementChanged(id, "removed"); return true; } /// /// API 3 (D249): creates a placement and names it as rnpc place does (D241, D246). A /// position without y is a point on a map: it is put on the ground there (terrain and /// rock, never a building, D245) and then checked against the navmesh like any other. /// Answers {id, position, built, cost}, or {error}. /// private Dictionary RunicNpc_AddPlacement(JObject placement) { Placement data; try { data = placement?.ToObject(); } catch (Exception e) { return new Dictionary { ["error"] = "the placement could not be read: " + e.Message }; } if (data != null && data.Position != null && placement["position"]?["y"] == null) { Vector3 ground; string off = GroundAt(data.Position.X, data.Position.Z, out ground); if (off != null) return new Dictionary { ["error"] = off }; data.Position = Pos.Of(ground); } string id; bool built; string error = CreatePlacement(data, out id, out built); if (error != null) { PrintWarning($"RunicNpc_AddPlacement('{data?.Profile}') refused: {error}"); return new Dictionary { ["error"] = error }; } return new Dictionary { ["id"] = id, ["position"] = new Dictionary { ["x"] = data.Position.X, ["y"] = data.Position.Y, ["z"] = data.Position.Z }, ["built"] = built, ["cost"] = CostWarning(PlannedTotal()) }; } /// API 3 (D249): renames a placement, as rnpc rename does. Null on success. private string RunicNpc_RenamePlacement(string from, string to) => from == null || to == null ? "Name both placements." : RenamePlacement(from, to); /// API 3 (D249): respawns a placement's NPCs now, as rnpc respawn. How many, or -1 if there is no such placement. private int RunicNpc_RespawnPlacement(string id) { PlacementState ps; if (id == null || !_placements.TryGetValue(id, out ps)) return -1; int n = RespawnNow(ps); TickPlacements(); return n; } /// /// The ground under a point of the map: terrain or rock, never anything built or a tree (D245: /// a roof is an in-game placement). Null with the spot, or the reason there is none. /// private static string GroundAt(float x, float z, out Vector3 ground) { ground = Vector3.zero; float half = World.Size / 2f; if (float.IsNaN(x) || float.IsNaN(z) || Mathf.Abs(x) > half || Mathf.Abs(z) > half) return $"({x:0}, {z:0}) is off the map"; RaycastHit hit; if (Physics.Raycast(new Vector3(x, 1000f, z), Vector3.down, out hit, 2000f, MapGroundMask, QueryTriggerInteraction.Ignore)) ground = hit.point; else ground = new Vector3(x, TerrainMeta.HeightMap.GetHeight(new Vector3(x, 0f, z)), z); if (TerrainMeta.WaterMap != null && TerrainMeta.WaterMap.GetHeight(ground) > ground.y + 0.5f) return $"({x:0}, {z:0}) is under water"; return null; } /// /// API 3 (D249): raised whenever a placement is set, removed or renamed, from the API or in /// game alike. change is set, removed or renamed (with previous). /// private static void PlacementChanged(string id, string change, string previous = null) => Interface.CallHook("OnRunicNpcPlacementChanged", id, change, previous); private JObject RunicNpc_Routes() => JObject.FromObject(_routeFile.Routes); /// Adds or replaces a route (D234). Every point must be on Rust's navmesh. private string RunicNpc_SetRoute(string name, JObject route) { if (name == null || !NameRule.IsMatch(name)) return "name: 1–40 of a-z, 0-9, _ and -"; Route data; try { data = route?.ToObject(); } catch (Exception e) { return "the route could not be read: " + e.Message; } return SetRoute(name, data); } private string SetRoute(string name, Route data) { string error = ValidateRoute(data); if (error != null) return error; _routeFile.Routes[name] = data; SaveRoutes(); TickPlacements(); return null; } private bool RunicNpc_RemoveRoute(string name) { if (name == null || !_routeFile.Routes.Remove(name)) return false; SaveRoutes(); return true; } private bool RunicNpc_IsRunicNpc(BaseEntity entity) => entity is RunicNpcPlayer; private string RunicNpc_ProfileOf(BaseEntity entity) => (entity as RunicNpcPlayer)?.ProfileName; /// 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) // `/rnpc ` in chat and `rnpc.` in a console run the same code. Each verb sits behind a // permission; the server console has them all. `runicnpc.admin` includes `runicnpc.place`. private const string PermPlace = "runicnpc.place"; private const string PermAdmin = "runicnpc.admin"; /// How far an admin's look reaches, for place, info and remove. private const float LookReach = 100f; /// How far below or around a spot the navmesh is looked for when saying why it is off it. private const float Fallback = 20f; private static readonly int GroundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default", "Tree"); private static readonly int BuiltMask = LayerMask.GetMask("Construction", "Deployed"); /// What a map point is put down on (D245): terrain and rock, never a building or a tree. private static readonly int MapGroundMask = LayerMask.GetMask("Terrain", "World"); /// Who ran a command, and where the answer goes. private sealed class Caller { /// Null for the server console. public BasePlayer Player; public bool Chat; public readonly List Lines = new List(); public string Key => Player != null ? Player.UserIDString : "server"; public void Say(string line) => Lines.Add(line); } private sealed class Verb { public string Permission; public string Usage; public Action Run; public bool ConsoleOnly; } private Dictionary _verbs; /// A route being recorded, per admin (D240). Kept in memory only: a reload discards it. private sealed class Recording { public string Name; public readonly List Points = new List(); } private readonly Dictionary _recordings = new Dictionary(); private void RegisterVerbs() { permission.RegisterPermission(PermPlace, this); permission.RegisterPermission(PermAdmin, this); _verbs = new Dictionary { ["place"] = new Verb { Permission = PermPlace, Usage = "place [count= respawn= mode=each|group move=wander|monument|route: radius=]", Run = (c, a) => CmdPlace(c, a, false) }, ["here"] = new Verb { Permission = PermPlace, Usage = "here [the same options]", Run = (c, a) => CmdPlace(c, a, true) }, ["remove"] = new Verb { Permission = PermPlace, Usage = "remove [placement]", Run = CmdRemove }, ["rename"] = new Verb { Permission = PermPlace, Usage = "rename ", Run = CmdRename }, ["near"] = new Verb { Permission = PermPlace, Usage = "near [radius]", Run = CmdNear }, ["info"] = new Verb { Permission = PermPlace, Usage = "info", Run = CmdInfo }, ["profiles"] = new Verb { Permission = PermPlace, Usage = "profiles", Run = CmdProfiles }, ["path"] = new Verb { Permission = PermPlace, Usage = "path record | point | undo | save [loop|back] | cancel | list | delete ", Run = CmdPath }, ["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 }, ["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 } }; } private bool Allowed(Caller c, string perm) => c.Player == null || permission.UserHasPermission(c.Player.UserIDString, PermAdmin) || (perm == PermPlace && permission.UserHasPermission(c.Player.UserIDString, PermPlace)); private void Dispatch(Caller c, string verb, string[] args) { Verb v; if (verb == null || verb == "help" || !_verbs.TryGetValue(verb, out v)) { if (verb != null && verb != "help") c.Say($"There is no '{verb}'."); string prefix = c.Chat ? "/rnpc " : "rnpc."; List mine = _verbs.Where(kv => Allowed(c, kv.Value.Permission) && !(c.Chat && kv.Value.ConsoleOnly)) .Select(kv => prefix + kv.Value.Usage).ToList(); if (mine.Count == 0) c.Say("You have no RunicNPC permissions."); else c.Lines.AddRange(mine); return; } if (!Allowed(c, v.Permission)) { c.Say($"You need the {v.Permission} permission."); return; } if (c.Chat && v.ConsoleOnly) { c.Say($"'{verb}' is a console command: open the console (F1) and type rnpc.{verb}."); return; } if (!_ready) { c.Say("RunicNPC is still starting."); return; } try { v.Run(c, args); } catch (Exception e) { c.Say($"'{verb}' failed: {e.Message}"); PrintError($"rnpc {verb} {string.Join(" ", args)}: {e}"); } } [ChatCommand("rnpc")] private void CmdChat(BasePlayer player, string command, string[] args) { var c = new Caller { Player = player, Chat = true }; Dispatch(c, args.Length > 0 ? args[0].ToLowerInvariant() : null, args.Skip(1).ToArray()); if (c.Lines.Count > 0) player.ChatMessage(string.Join("\n", c.Lines.ToArray())); } private void RunConsole(ConsoleSystem.Arg arg, string verb) { BasePlayer player = arg.Player(); if (arg.Connection != null && player == null) return; var c = new Caller { Player = player }; // Rust's Args element type has changed between versions (string, then StringView), so // read each through GetString, which both have. int count = arg.Args?.Length ?? 0; Dispatch(c, verb, Enumerable.Range(0, count).Select(i => arg.GetString(i)).ToArray()); arg.ReplyWith(string.Join(Environment.NewLine, c.Lines.ToArray())); } [ConsoleCommand("rnpc.place")] private void CcPlace(ConsoleSystem.Arg arg) => RunConsole(arg, "place"); [ConsoleCommand("rnpc.here")] private void CcHere(ConsoleSystem.Arg arg) => RunConsole(arg, "here"); [ConsoleCommand("rnpc.remove")] private void CcRemove(ConsoleSystem.Arg arg) => RunConsole(arg, "remove"); [ConsoleCommand("rnpc.rename")] private void CcRename(ConsoleSystem.Arg arg) => RunConsole(arg, "rename"); [ConsoleCommand("rnpc.near")] private void CcNear(ConsoleSystem.Arg arg) => RunConsole(arg, "near"); [ConsoleCommand("rnpc.info")] private void CcInfo(ConsoleSystem.Arg arg) => RunConsole(arg, "info"); [ConsoleCommand("rnpc.profiles")] private void CcProfiles(ConsoleSystem.Arg arg) => RunConsole(arg, "profiles"); [ConsoleCommand("rnpc.path")] private void CcPath(ConsoleSystem.Arg arg) => RunConsole(arg, "path"); [ConsoleCommand("rnpc.tp")] private void CcTp(ConsoleSystem.Arg arg) => RunConsole(arg, "tp"); [ConsoleCommand("rnpc.respawn")] private void CcRespawn(ConsoleSystem.Arg arg) => RunConsole(arg, "respawn"); [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 ---- /// The ground, a floor or a roof the admin is looking at, within . private static bool LookSpot(BasePlayer player, out Vector3 spot) { RaycastHit hit; if (Physics.Raycast(player.eyes.HeadRay(), out hit, LookReach, GroundMask, QueryTriggerInteraction.Ignore)) { spot = hit.point; return true; } spot = Vector3.zero; return false; } /// /// Our NPC the admin is looking at: the nearest whose body is within a metre of the line of /// sight. It reads positions, not colliders, so it finds an NPC behind a bush too. /// private RunicNpcPlayer LookNpc(BasePlayer player) { Ray ray = player.eyes.HeadRay(); RunicNpcPlayer best = null; float bestAlong = float.MaxValue; foreach (RunicNpcPlayer npc in _npcs.Values) { if (npc == null || npc.IsDestroyed) continue; Vector3 body = npc.transform.position + Vector3.up * 1f; Vector3 to = body - ray.origin; float along = Vector3.Dot(to, ray.direction); if (along <= 0f || along > LookReach) continue; float off = (to - ray.direction * along).magnitude; if (off <= 1.2f && along < bestAlong) { best = npc; bestAlong = along; } } return best; } /// Null when a roamer may stand here (on Rust's navmesh, within 2 m up or down); otherwise why not. private static string OffMeshReason(Vector3 spot) { NavMeshHit hit; if (SampleNavmesh(spot, Scatter, out hit) && Mathf.Abs(hit.position.y - spot.y) <= MaxDrop) return null; if (!SampleNavmesh(spot, Fallback, out hit)) { float above = spot.y - TerrainMeta.HeightMap.GetHeight(spot); return above > MaxDrop ? $"it is {above:0.0} m above the ground, with no navmesh within {Fallback:0} m" : $"there is no navmesh within {Fallback:0} m"; } float dy = spot.y - hit.position.y; if (dy > MaxDrop) return $"it is {dy:0.0} m above the navmesh"; if (dy < -MaxDrop) return $"it is {-dy:0.0} m below the navmesh"; return $"it is {new Vector2(hit.position.x - spot.x, hit.position.z - spot.z).magnitude:0.0} m from the navmesh"; } private static Rust.Ai.Gen2.RustNavMeshPath _path; /// /// Whether an NPC can walk from one point to the other on Rust's navmesh. A route leg that /// fails this leaves its NPC standing at the last point it reached (stage 3: a route recorded /// up a hill a player walked, and the NPC never left its first point). /// private static bool Walkable(Vector3 from, Vector3 to) { if (_path == null) _path = new Rust.Ai.Gen2.RustNavMeshPath(); return Rust.Ai.Gen2.RustNavMeshHelpers.CalculatePath(from, to, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas, _path) && _path.status == NavMeshPathStatus.PathComplete; } /// D239: a spot on something players built, which can be destroyed under the NPC. private static bool IsPlayerBuilt(Vector3 spot) { RaycastHit hit; if (!Physics.Raycast(spot + Vector3.up * 0.5f, Vector3.down, out hit, 2.5f, BuiltMask, QueryTriggerInteraction.Ignore)) return false; BaseEntity entity = hit.GetEntity(); return entity != null && (entity is StabilityEntity || entity.OwnerID != 0UL); } private static string Meters(Vector3 a, Vector3 b) => $"{Vector3.Distance(a, b):0} m"; private static bool TryFloat(string s, out float f) => float.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out f) && !float.IsNaN(f) && !float.IsInfinity(f); // ---- place and here (D239, D241, D242) ---- private sealed class PlaceOptions { public int? Count; public float? Respawn; public string Mode; public string Move; public float? Radius; public Vector3? At; public float? Yaw; public string Tether; } /// /// D242's key=value options, in any order. The server console has no position, so it /// alone may also give at=x,y,z and yaw=. /// private static string ParseOptions(Caller c, string[] args, int from, out PlaceOptions o) { o = new PlaceOptions(); var seen = new HashSet(); for (int i = from; i < args.Length; i++) { int eq = args[i].IndexOf('='); if (eq <= 0) 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)) return $"{key}= is given twice"; float f; switch (key) { case "count": int n; if (!int.TryParse(value, out n) || n < 1) return "count= must be a whole number, at least 1"; o.Count = n; break; case "respawn": if (!TryFloat(value, out f) || f < 1f) return "respawn= is in seconds, at least 1"; o.Respawn = f; break; case "mode": if (value != "each" && value != "group") return "mode= is each or group"; o.Mode = value; break; case "move": string kind, route; if (!ParseMode(value, out kind, out route)) return "move= is wander, monument or route:"; o.Move = value; break; case "radius": if (!TryFloat(value, out f) || f <= 0f) return "radius= is in metres, above 0"; o.Radius = f; break; case "at": string[] xyz = value.Split(','); float x, y, z; if (c.Player != null) return "at= is for the server console; in game, look at the spot or stand on it"; if (xyz.Length != 3 || !TryFloat(xyz[0], out x) || !TryFloat(xyz[1], out y) || !TryFloat(xyz[2], out z)) return "at= is x,y,z"; o.At = new Vector3(x, y, z); break; case "yaw": if (c.Player != null) return "yaw= is for the server console"; 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= tether="; } } return null; } /// D241: the profile's name and the first free number. private string NextPlacementId(string profile) { string stem = profile.Length > 34 ? profile.Substring(0, 34) : profile; for (int n = 1; ; n++) { string id = $"{stem}-{n}"; if (!_placementFile.Placements.ContainsKey(id) && !_placements.ContainsKey(id)) return id; } } /// /// Checks, names and sets a NEW placement: rnpc place, rnpc here and /// RunicNpc_AddPlacement alike, so a placement made from a website is named and refused /// exactly as one made in game (D241, D246, D249). Null on success, else the sentence to show. /// private string CreatePlacement(Placement data, out string id, out bool built) { id = null; built = false; Profile p; if (data == null || data.Profile == null || !_profiles.TryGetValue(data.Profile, out p)) { string name = data?.Profile; return name != null && _refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them."; } string error = ValidatePlacement(data); if (error != null) return "Not placed: " + error; string kind, route; ParseMode((data.Movement ?? p.Movement).Mode, out kind, out route); if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) return $"There is no route '{route}'. Record it first: rnpc path record {route}"; if (!NavmeshReady()) return "The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly."; if (!p.IsSentry) { string off = OffMeshReason(data.Position.V); if (off != null) return $"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219)."; built = IsPlayerBuilt(data.Position.V); } string next = NextPlacementId(data.Profile); error = SetPlacement(next, data); if (error != null) return "Not placed: " + error; id = next; return null; } private void CmdPlace(Caller c, string[] args, bool here) { if (args.Length < 1) { c.Say($"Usage: {_verbs[here ? "here" : "place"].Usage}"); return; } string name = args[0]; Profile p; if (!_profiles.TryGetValue(name, out p)) { c.Say(_refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them."); return; } PlaceOptions o; string error = ParseOptions(c, args, 1, out o); if (error != null) { c.Say(error); return; } Vector3 spot; float yaw; if (o.At.HasValue) { spot = o.At.Value; yaw = o.Yaw ?? 0f; } else if (c.Player == null) { c.Say("From the server console, give the spot as at=x,y,z."); return; } else if (here) { spot = c.Player.transform.position; yaw = c.Player.eyes.rotation.eulerAngles.y; } else { if (!LookSpot(c.Player, out spot)) { c.Say($"Look at the ground, a floor or a roof within {LookReach:0} m."); return; } // It faces the admin who placed it. Vector3 back = c.Player.transform.position - spot; yaw = back.sqrMagnitude > 0.01f ? Quaternion.LookRotation(new Vector3(back.x, 0f, back.z)).eulerAngles.y : 0f; } Movement movement = null; if (o.Move != null || o.Radius.HasValue) { if (p.IsSentry) { c.Say($"'{name}' is a sentry and never moves: drop move= and radius=."); return; } movement = new Movement { Mode = o.Move ?? p.Movement.Mode, Radius = o.Radius ?? p.Movement.Radius }; error = ValidateMovement(movement); if (error != null) { c.Say(error); return; } } var data = new Placement { Profile = name, Position = Pos.Of(spot), Yaw = yaw, Count = o.Count ?? 1, Respawn = o.Respawn ?? 300f, RespawnMode = o.Mode ?? "each", 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); if (error != null) { c.Say(error); return; } string kind, route; 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}{(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())); } // ---- remove, rename, near, info, profiles ---- /// A placement named in the command, or that of the NPC the admin is looking at. private PlacementState NamedOrLooked(Caller c, string[] args, int at) { PlacementState ps; if (args.Length > at) { if (_placements.TryGetValue(args[at], out ps)) return ps; c.Say($"There is no placement '{args[at]}'. rnpc near lists those around you."); return null; } if (c.Player == null) { c.Say("Name the placement."); return null; } RunicNpcPlayer npc = LookNpc(c.Player); if (npc == null) { c.Say("Look at one of RunicNPC's NPCs, or name the placement."); return null; } if (npc.PlacementId == null || !_placements.TryGetValue(npc.PlacementId, out ps)) { c.Say($"{npc.RnName} is not from a placement: it belongs to {npc.Owner}. rnpc clear {npc.Owner} removes that owner's NPCs."); return null; } return ps; } private void CmdRemove(Caller c, string[] args) { PlacementState ps = NamedOrLooked(c, args, 0); if (ps == null) return; int alive = ps.Slots.Count(s => s.Npc != null); RunicNpc_RemovePlacement(ps.Id); c.Say($"Removed {ps.Id} and its {alive} NPC(s). {CostWarning(PlannedTotal())}"); } private void CmdRename(Caller c, string[] args) { if (args.Length != 2) { c.Say("Usage: rnpc rename "); return; } string error = RenamePlacement(args[0], args[1]); c.Say(error ?? $"Renamed {args[0]} to {args[1]}."); } private string RenamePlacement(string from, string to) { PlacementState ps; if (!_placements.TryGetValue(from, out ps)) return $"There is no placement '{from}'."; if (!NameRule.IsMatch(to)) return "A name is 1–40 of a-z, 0-9, _ and -."; if (_placements.ContainsKey(to) || _placementFile.Placements.ContainsKey(to)) return $"'{to}' is taken."; _placements.Remove(from); _placementFile.Placements.Remove(from); ps.Id = to; _placements[to] = ps; _placementFile.Placements[to] = ps.Data; foreach (Slot slot in ps.Slots) { if (slot.Npc == null) continue; slot.Npc.PlacementId = to; slot.Npc.Owner = "placement:" + to; } _placementsDirty = true; Puts($"Placement '{from}' renamed to '{to}'."); PlacementChanged(to, "renamed", from); return null; } private void CmdNear(Caller c, string[] args) { float radius = 50f; if (args.Length > 0 && (!TryFloat(args[0], out radius) || radius <= 0f)) { c.Say("Usage: rnpc near [radius in metres]"); return; } if (c.Player == null) { c.Say("From the server console there is no 'near': rnpc.status counts everything."); return; } Vector3 here = c.Player.transform.position; List places = _placements.Values.Where(ps => Vector3.Distance(ps.Data.Position.V, here) <= radius) .OrderBy(ps => Vector3.Distance(ps.Data.Position.V, here)).ToList(); List loose = _npcs.Values.Where(n => n.PlacementId == null && Vector3.Distance(n.transform.position, here) <= radius) .OrderBy(n => Vector3.Distance(n.transform.position, here)).ToList(); if (places.Count == 0 && loose.Count == 0) { c.Say($"Nothing of RunicNPC's within {radius:0} m."); return; } foreach (PlacementState ps in places) { Placement d = ps.Data; string move = d.Movement != null ? $" · move {d.Movement.Mode}" : ""; string state = ps.Waiting != null ? $" · waiting: {ps.Waiting}" : ps.LastError != null ? $" · {ps.LastError}" : ps.Note != null ? $" · {ps.Note}" : ""; c.Say($"{ps.Id} · {d.Count} × {d.Profile} ({ps.Slots.Count(s => s.Npc != null)} alive) · {Meters(d.Position.V, here)} · respawn {d.Respawn:0} s {d.RespawnMode}{move}{state}"); } foreach (RunicNpcPlayer n in loose) c.Say($"{n.RnName} ({n.ProfileName}) · {n.Owner} · {Meters(n.transform.position, here)}"); } private void CmdInfo(Caller c, string[] args) { if (c.Player == null) { c.Say("Info is for the NPC you look at, in game."); return; } RunicNpcPlayer npc = LookNpc(c.Player); if (npc == null) { c.Say("Look at one of RunicNPC's NPCs."); return; } RunicNpcBrain brain = npc.RnBrain; BaseEntity target = npc.GetBestTarget(); string targetText = target == null ? "none" : $"{(target is BasePlayer ? ((BasePlayer)target).displayName : target.ShortPrefabName)} {Meters(target.transform.position, npc.transform.position)}"; string move = npc.Profile.IsSentry ? "holds its spot" : npc.MoveKind == "route" ? "route:" + npc.RouteName : npc.MoveKind == "wander" ? $"wander {npc.MoveRadius:0} m" : npc.MoveKind; c.Say($"{npc.RnName} · profile {npc.ProfileName} ({npc.Profile.Role}) · {(npc.PlacementId != null ? "placement " + npc.PlacementId : npc.Owner)}"); 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) { if (_profiles.Count == 0 && _refused.Count == 0) { c.Say(_profileFile.Managed ? "This server has no profiles yet; its website manages them." : "This server has no profiles yet. rnpc.profile create makes one."); return; } c.Say(_profileFile.Managed ? "Profiles (managed by this server's website):" : "Profiles:"); foreach (KeyValuePair kv in _profiles.OrderBy(kv => kv.Key)) { Profile p = kv.Value; c.Say($"{kv.Key} · {p.Role}{(p.IsSentry ? "" : " " + p.Movement.Mode)} · {p.Health:0} hp · kits {string.Join(", ", p.Kits.ToArray())}"); } foreach (KeyValuePair kv in _refused.OrderBy(kv => kv.Key)) c.Say($"{kv.Key} · refused: {kv.Value}"); } // ---- tp, respawn, clear ---- private void CmdTp(Caller c, string[] args) { if (c.Player == null) { c.Say("Teleporting is for a player."); return; } PlacementState ps; if (args.Length != 1 || !_placements.TryGetValue(args[0], out ps)) { c.Say(args.Length == 1 ? $"There is no placement '{args[0]}'." : "Usage: rnpc tp "); return; } c.Player.Teleport(ps.Data.Position.V + Vector3.up * 0.2f); c.Say($"At {ps.Id}."); } /// A placement's NPCs, despawned and spawned fresh now, without waiting for the delay. private int RespawnNow(PlacementState ps) { Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); foreach (Slot s in ps.Slots) s.DiedAt = float.NegativeInfinity; ps.LastError = null; return ps.Slots.Length; } private void CmdRespawn(Caller c, string[] args) { if (args.Length != 1) { c.Say("Usage: rnpc respawn "); return; } List which; if (args[0] == "all") which = _placements.Values.ToList(); else { PlacementState ps; if (!_placements.TryGetValue(args[0], out ps)) { c.Say($"There is no placement '{args[0]}'."); return; } which = new List { ps }; } int n = which.Sum(RespawnNow); TickPlacements(); List waiting = which.Where(ps => ps.Waiting != null).Select(ps => $"{ps.Id} waits: {ps.Waiting}").ToList(); c.Say($"Respawning {n} NPC(s) from {which.Count} placement(s)."); c.Lines.AddRange(waiting); } private void CmdClear(Caller c, string[] args) { if (args.Length != 1 || !(OwnerRule.IsMatch(args[0]) || args[0].StartsWith("placement:"))) { c.Say("Usage: rnpc clear , e.g. run:, plugin: or placement:"); return; } int n = Despawn(_npcs.Values.Where(x => x.Owner == args[0])); c.Say(args[0].StartsWith("placement:") ? $"Removed {n} NPC(s) of {args[0]}; the placement stays and respawns them after its delay (rnpc remove deletes it)." : $"Removed {n} NPC(s) of {args[0]}."); } // ---- path: recording a route point by point (D240) ---- private void CmdPath(Caller c, string[] args) { string sub = args.Length > 0 ? args[0].ToLowerInvariant() : null; Recording rec; _recordings.TryGetValue(c.Key, out rec); switch (sub) { case "record": if (args.Length != 2 || !NameRule.IsMatch(args[1])) { c.Say("Usage: rnpc path record ; a name is 1–40 of a-z, 0-9, _ and -."); return; } _recordings[c.Key] = new Recording { Name = args[1] }; c.Say($"Recording '{args[1]}'{(_routeFile.Routes.ContainsKey(args[1]) ? " (saving replaces the route of that name)" : "")}. " + "Walk, and at each point type rnpc path point."); return; case "point": if (rec == null) { c.Say("Nothing is being recorded: rnpc path record first."); return; } Vector3 at; if (c.Player != null && args.Length == 1) at = c.Player.transform.position; else { PlaceOptions o = null; string error = args.Length == 2 ? ParseOptions(c, args, 1, out o) : "from the server console, give the point as at=x,y,z"; if (error == null && (o == null || !o.At.HasValue)) error = "give the point as at=x,y,z"; if (error != null) { c.Say(error); return; } at = o.At.Value; } string off = OffMeshReason(at); if (off != null) { c.Say($"Refused: {off}."); return; } NavMeshHit hit; SampleNavmesh(at, Scatter, out hit); if (rec.Points.Count > 0 && !Walkable(rec.Points[rec.Points.Count - 1], hit.position)) { c.Say($"Refused: an NPC cannot walk here from point {rec.Points.Count}; Rust's navmesh finds no path " + "(a player can climb what an NPC cannot). Add a point between them, or choose another spot."); return; } rec.Points.Add(hit.position); string gap = rec.Points.Count > 1 ? $", {Meters(rec.Points[rec.Points.Count - 2], hit.position)} from the last" : ""; c.Say($"Point {rec.Points.Count} at ({hit.position.x:0}, {hit.position.y:0}, {hit.position.z:0}){gap}."); return; case "undo": if (rec == null || rec.Points.Count == 0) { c.Say("There is no point to undo."); return; } rec.Points.RemoveAt(rec.Points.Count - 1); c.Say($"Dropped the last point; {rec.Points.Count} left."); return; case "save": if (rec == null) { c.Say("Nothing is being recorded."); return; } string how = args.Length > 1 ? args[1].ToLowerInvariant() : "loop"; if (how != "loop" && how != "back") { c.Say("Usage: rnpc path save [loop|back]: loop returns from the last point to the first; back walks it back and forth."); return; } if (rec.Points.Count < 2) { c.Say($"A route needs at least two points; '{rec.Name}' has {rec.Points.Count}."); return; } if (how == "loop" && !Walkable(rec.Points[rec.Points.Count - 1], rec.Points[0])) { c.Say($"An NPC cannot walk from the last point back to the first, so '{rec.Name}' cannot loop. " + "Add points that close the loop, or save it as back (back and forth)."); return; } var route = new Route { Loop = how == "loop", Points = rec.Points.Select(Pos.Of).ToList() }; string refused = SetRoute(rec.Name, route); if (refused != null) { c.Say("Not saved: " + refused); return; } _recordings.Remove(c.Key); int users = _placements.Values.Count(ps => ps.Data.Movement != null && ps.Data.Movement.Mode == "route:" + rec.Name); c.Say($"Saved '{rec.Name}': {route.Points.Count} points, {(route.Loop ? "loops" : "back and forth")}. " + $"Place a patrol on it: rnpc place move=route:{rec.Name}{(users > 0 ? $" ({users} placement(s) already use it)" : "")}"); return; case "cancel": c.Say(_recordings.Remove(c.Key) ? $"Discarded '{rec.Name}'." : "Nothing is being recorded."); return; case "list": if (_routeFile.Routes.Count == 0) c.Say("No routes yet: rnpc path record ."); foreach (KeyValuePair kv in _routeFile.Routes.OrderBy(kv => kv.Key)) { int n = _placements.Values.Count(ps => ps.Data.Movement != null && ps.Data.Movement.Mode == "route:" + kv.Key) + _placements.Values.Count(ps => ps.Data.Movement == null && _profiles.ContainsKey(ps.Data.Profile) && _profiles[ps.Data.Profile].Movement.Mode == "route:" + kv.Key); string where = c.Player != null ? $" · first point {Meters(kv.Value.Points[0].V, c.Player.transform.position)}" : ""; c.Say($"{kv.Key} · {kv.Value.Points.Count} points · {(kv.Value.Loop ? "loop" : "back and forth")} · {n} placement(s){where}"); } if (rec != null) c.Say($"Recording: '{rec.Name}', {rec.Points.Count} point(s), not saved."); return; case "delete": if (args.Length != 2) { c.Say("Usage: rnpc path delete "); return; } if (!RunicNpc_RemoveRoute(args[1])) { c.Say($"There is no route '{args[1]}'."); return; } TickPlacements(); int waiting = _placements.Values.Count(ps => ps.Waiting != null && ps.Waiting.Contains($"route '{args[1]}'")); c.Say($"Deleted '{args[1]}'.{(waiting > 0 ? $" {waiting} placement(s) on it now wait for a route of that name." : "")}"); return; default: c.Say("Usage: rnpc " + _verbs["path"].Usage); return; } } // ---- profile: standalone edits from a console (D221) ---- private void CmdProfile(Caller c, string[] args) { string sub = args.Length > 0 ? args[0].ToLowerInvariant() : null; if (sub == "list") { CmdProfiles(c, args); return; } if (sub == null || args.Length < 2) { c.Say("Usage: rnpc." + _verbs["profile"].Usage); return; } string name = args[1]; Profile existing; _profileFile.Profiles.TryGetValue(name, out existing); if (sub == "show") { c.Say(existing == null ? $"There is no profile '{name}'." : JsonConvert.SerializeObject(existing, Formatting.Indented)); if (existing != null && _refused.ContainsKey(name)) c.Say($"It is refused: {_refused[name]}"); return; } if (_profileFile.Managed) { c.Say("This server's profiles are managed by its website (D221): edit them there."); return; } switch (sub) { case "create": if (!NameRule.IsMatch(name)) { c.Say("A name is 1–40 of a-z, 0-9, _ and -."); return; } if (existing != null) { c.Say($"'{name}' exists: rnpc.profile set {name} changes it."); return; } Profile made = new Profile { Names = new List { "Scientist" } }; if (args.Length > 2) { Profile from; if (!args[2].StartsWith("from=") || !_profileFile.Profiles.TryGetValue(args[2].Substring(5), out from)) { c.Say(args[2].StartsWith("from=") ? $"There is no profile '{args[2].Substring(5)}'." : "Usage: rnpc.profile create [from=]"); return; } made = JObject.FromObject(from).ToObject(); } _profileFile.Profiles[name] = made; break; case "set": if (existing == null) { c.Say($"There is no profile '{name}'."); return; } if (args.Length < 4) { c.Say("Usage: rnpc.profile set , e.g. health 200, ranges.sense 40, kits a,b, names Bob,Old Jim"); return; } string error = SetProfileField(name, existing, args[2], string.Join(" ", args.Skip(3).ToArray())); if (error != null) { c.Say(error); return; } break; case "delete": if (existing == null) { c.Say($"There is no profile '{name}'."); return; } _profileFile.Profiles.Remove(name); break; default: c.Say("Usage: rnpc." + _verbs["profile"].Usage); return; } SaveProfiles(); RevalidateProfiles(); TickPlacements(); if (sub == "delete") { int waiting = _placements.Values.Count(ps => ps.Data.Profile == name); c.Say($"Deleted '{name}'.{(waiting > 0 ? $" Its {waiting} placement(s) wait for a profile of that name (D237)." : "")}"); } else c.Say(_refused.ContainsKey(name) ? $"Saved '{name}', but it cannot spawn yet: {_refused[name]}" : $"Saved '{name}'; it is in use."); } /// One field of a profile, by its JSON path (health, ranges.sense, kits). 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 ? $"A profile has no field '{field}'. Fields: {string.Join(", ", Flatten(o).ToArray())}" : $"'{field}' has fields of its own: set them one at a time, e.g. {field}.{((JObject)target).Properties().First().Name}"; JToken replacement; float f; if (target is JArray) { string[] parts = value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray(); if (field == "healthThresholds") { var numbers = new JArray(); foreach (string s in parts) { if (!TryFloat(s, out f)) return $"'{s}' is not a number"; numbers.Add(f); } replacement = numbers; } else replacement = new JArray(parts.Cast().ToArray()); } else if (target.Type == JTokenType.Float || target.Type == JTokenType.Integer) { 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); target.Replace(replacement); _profileFile.Profiles[name] = o.ToObject(); return null; } private static IEnumerable Flatten(JObject o, string prefix = "") { foreach (JProperty prop in o.Properties()) { if (prop.Value is JObject) foreach (string inner in Flatten((JObject)prop.Value, prefix + prop.Name + ".")) yield return inner; else yield return prefix + prop.Name; } } #endregion #region Console [ConsoleCommand("rnpc.status")] private void CmdStatus(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; var fired = new List(); var silent = new List(); foreach (string hook in ExpectedHooks) { long count; _hookCounts.TryGetValue(hook, out count); if (count > 0L) fired.Add($"{hook}={count}"); else silent.Add(hook); } string nl = Environment.NewLine; var sb = new StringBuilder(); sb.Append($"RunicNPC {Version} api={ApiVersion} hooks={ExpectedHooks.Length} fired={fired.Count} silent={silent.Count}").Append(nl); sb.Append($"fired: {(fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)")}").Append(nl); sb.Append($"silent: {(silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)")}").Append(nl); sb.Append($"framework={Framework()} navmesh={(NavmeshReady() ? "ready" : "building")} " + $"swap fields: npc={_npcFields?.Length ?? 0}/{SwapNpcFields.Length} brain={_brainFields?.Length ?? 0}/{SwapBrainFields.Length} " + $"missing={(_fieldsMissing.Count > 0 ? string.Join(",", _fieldsMissing) : "-")} added={(_fieldsAdded.Count > 0 ? string.Join(",", _fieldsAdded) : "-")}").Append(nl); IEnumerable brains = _npcs.Values.Select(n => n.RnBrain).Where(b => b != null).ToList(); sb.Append($"npcs={_npcs.Count} awake={brains.Count(b => b.Rest == RunicNpcBrain.RestState.Awake)} " + $"goingHome={brains.Count(b => b.Rest == RunicNpcBrain.RestState.GoingHome)} asleep={brains.Count(b => b.Rest == RunicNpcBrain.RestState.Asleep)} " + $"owners: {string.Join(" ", _npcs.Values.GroupBy(n => n.Owner).Select(g => $"{g.Key}={g.Count()}").ToArray())}").Append(nl); sb.Append($"profiles={_profiles.Count} managed={_profileFile.Managed} refused: " + $"{(_refused.Count > 0 ? string.Join("; ", _refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()) : "-")}").Append(nl); 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())); arg.ReplyWith(sb.ToString()); } /// Re-reads profiles and routes after a hand edit of their files (standalone, D221). [ConsoleCommand("rnpc.reload")] private void CmdReload(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; ReadProfileFile(); _routeFile = ReadData(DataRoutes); if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary(); RevalidateProfiles(); TickPlacements(); arg.ReplyWith($"RunicNPC: {_profiles.Count} profile(s) in use, {_refused.Count} refused, {_routeFile.Routes.Count} route(s)."); } #endregion } }