// 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 2 plugin (docs/runicnpc/PLAN.md §9): the NPC, its profiles, placements /// and owners, and the API other plugins call. The in-game commands are stage 3's; until then /// profiles live in data/RunicNPC/profiles.json, and placements and routes are made /// through the API. /// /// /// /// 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 = 2; /// /// 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" }; 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; 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(); public bool IsSentry => Role == "sentry"; } 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(); } /// /// 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; } 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() { _profileFile = ReadData(DataProfiles); if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); _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 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}"); } /// 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") return $"role: '{p.Role}' is not roamer or sentry"; 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"; return null; } 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; } return null; } 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 { public string RnName = "Scientist"; public string ProfileName; public Profile Profile; public string Owner; public string PlacementId; public int Slot = -1; public Vector3 Home; 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(); 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); } // 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); } 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))); } else if (npc.MoveKind == "wander" || npc.MoveKind == "route") { // Rust's own roam and chase both need an AI zone's move points, so outside a // monument they do nothing (stage 1, Q3; stage 2 found the chase's Error branch). // A monument roamer keeps both of Rust's. Replace(npc.MoveKind == "wander" ? (BasicAIState)new WanderState() : new RouteState()); Replace(new PursueState()); } } 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); } 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.Home) <= 2f || now >= _homeBy) Sleep(); else if (!Navigator.Moving && !Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal)) Sleep(); return; default: if (!_playerNear && (CurrentState == null || !CurrentState.AgrresiveState)) { // D235: walk home first, then sleep. Nothing thinks on the way: no player // is within the sleep distance, so there is nothing to sense or fight. Rest = RestState.GoingHome; _homeBy = now + 120f; lastThinkTime = now; if (Sentry || !Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal)) Sleep(); return; } break; } if (!Sentry && npc.Profile.Ranges.Chase > 0f && CurrentState != null && CurrentState.AgrresiveState && Flat(npc.transform.position - npc.Home) > npc.Profile.Ranges.Chase) Leash(npc); base.Think(delta); } /// Past its chase range from home: forget the target and head back. public void Leash(RunicNpcPlayer npc) { Leashes++; 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); Navigator.SetDestination(npc.Home, 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) && 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(); } } /// 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 fromHome = goal - npc.Home; fromHome.y = 0f; bool clamped = chase > 0f && fromHome.magnitude > chase; if (clamped) goal = npc.Home + fromHome.normalized * chase; 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 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; } 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.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; 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; 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 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"; 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) { Vector2 offset = UnityEngine.Random.insideUnitCircle * Scatter; at = spot + new Vector3(offset.x, 0f, offset.y); } 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; } /// 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."; } #endregion #region Lifecycle and hooks private void Init() { _self = this; foreach (string hook in ExpectedHooks) _hookCounts[hook] = 0L; } private void OnServerInitialized() { MarkHook("OnServerInitialized"); ResolveFields(); LoadData(); RevalidateProfiles(); RebuildPlacements(); _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); } /// 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; 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 }; } 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 }).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; } string error = ValidatePlacement(data); 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(); 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)); return true; } 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; } string error = ValidateRoute(data); if (error != null) return error; _routeFile.Routes[name] = data; SaveRoutes(); 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)); #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); 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; _profileFile = ReadData(DataProfiles); if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); _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 } }