diff --git a/README.md b/README.md index 1e522c3..f03544c 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicG website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is [`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). -> **Status: stage 0.** The plugin loads, answers its API version, and reports on itself. It spawns -> nothing yet. Stage 1 is a measuring spike; the NPC and its API arrive in stage 2. +> **Status: stage 2.** The NPC, its profiles, placements and routes, and the API other plugins call. +> The in-game `/rnpc` commands arrive in stage 3; until then profiles are edited in +> `data/RunicNPC/profiles.json` (then `rnpc.reload`), and placements and routes are made through the API. ## Requirements @@ -53,9 +54,10 @@ Every call is prefixed `RunicNpc_` and reached through `Call`: int api = RunicNPC?.Call("RunicNpc_ApiVersion") ?? 0; ``` -Stage 0 has only `RunicNpc_ApiVersion()`. The full API is planned in PLAN.md §4 and will be -documented as `docs/runicnpc/API.md` in stage 2. The API version moves when a call or a raised hook -changes shape, not on every release. +The API is version 2, documented in +[`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md): +spawning and removing NPCs by owner, profiles, placements, routes, the cost warning, and the hooks it raises. +The API version moves when a call or a raised hook changes shape, not on every release. ## Repository layout @@ -64,7 +66,22 @@ changes shape, not on every release. | `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. | | `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). | -| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`) and `RunicNpcHarness.cs`, the stage 1 measurement plugin (`docs/runicnpc/PLAN.md` §9). | +| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | + +## After a Rust update: the swap's field list + +Our NPC is Rust's scientist with two components swapped, and the swap copies a fixed list of fields (D232), +generated from Rust's unmodified assembly. `rnpc.status` reports it as `swap fields: npc=64/64 brain=32/32 +missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since +the list was made. To regenerate it: + +```bash +node tools/managed.js carbon # the Carbon rig's RustDedicated_Data/Managed +dotnet run --project tools/fieldlist -- # rewrites the block in plugin/RunicNPC.cs +``` + +Use the Carbon rig: Oxide's patcher makes Rust's private fields public, so its assembly no longer says which +fields Rust itself serialises. ## Releases diff --git a/plugin.toml b/plugin.toml index 7d3515f..062206a 100644 --- a/plugin.toml +++ b/plugin.toml @@ -22,8 +22,9 @@ # a RunicNPC too old for the bridge it is pairing with. Declaring it here is what # lets a bundle check the pair BEFORE an operator installs it. # -# Current: 1 — `RunicNpc_ApiVersion()` and nothing else (stage 0). -api = 1 +# 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else. +# Current: 2 — stage 2's whole API, PLAN.md §4, documented in docs/runicnpc/API.md. +api = 2 # ── Framework floors ───────────────────────────────────────────────────────── # diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index edfd207..201f7c5 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -2,6 +2,17 @@ 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 { @@ -9,9 +20,10 @@ namespace Oxide.Plugins /// RunicNPC — Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon. /// /// - /// This is the stage 0 plugin (docs/runicnpc/PLAN.md §9): it loads, answers its API version, - /// and reports on itself. It spawns nothing. Everything it will do is planned in that - /// document, stage by stage. + /// 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. /// /// /// @@ -20,11 +32,21 @@ namespace Oxide.Plugins /// 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 @@ -34,28 +56,1631 @@ namespace Oxide.Plugins /// /// 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 = 1; + private const int ApiVersion = 2; /// - /// Every hook this plugin declares. Both frameworks bind a hook by name and arity - /// through reflection, and neither says a word when a name matches nothing, so the - /// plugin counts its own: rnpc.status lists the ones that have never fired. The - /// list is seeded at zero, because a hook that never fired is exactly the one a - /// dictionary that learns names as they arrive could never report. + /// 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" + "OnServerInitialized", + "OnServerSave", + "OnEntityKill", + "OnPluginUnloaded" }; private readonly Dictionary _hookCounts = new Dictionary(); - // ---- lifecycle ---- + /// 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; } @@ -63,7 +1688,52 @@ namespace Oxide.Plugins private void OnServerInitialized() { MarkHook("OnServerInitialized"); - Puts($"RunicNPC {Version} loaded, API {ApiVersion}. Stage 0: it spawns nothing yet."); + 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) @@ -73,23 +1743,210 @@ namespace Oxide.Plugins _hookCounts[name] = count + 1; } - // ---- the API (PLAN.md §4) ---- - // - // Every call is private and prefixed `RunicNpc_`. Private because Oxide's `Call` reaches a - // non-public method by name, and a PUBLIC one only when it carries [HookMethod] — the trap - // HumanNPC's RefreshNPC and RemoveNPC fell into (PLAN.md §1.2). Prefixed because `Call` - // matches by name alone, and no hook of any other plugin may ever match one of ours. + #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. - /// The API's version. A caller that needs more refuses this RunicNPC and says so. private int RunicNpc_ApiVersion() => ApiVersion; - // ---- console ---- + 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; + } /// - /// What this RunicNPC is and which of its hooks have fired. Answers over RCON as well - /// as at the console, and is the first thing to ask for when a server says it has no - /// RunicNPC. + /// 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) { @@ -98,26 +1955,55 @@ namespace Oxide.Plugins 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 firedText = fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)"; - string silentText = silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)"; + 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); - arg.ReplyWith( - $"RunicNPC {Version} api={ApiVersion} hooks={ExpectedHooks.Length} " + - $"fired={fired.Count} silent={silent.Count}" + Environment.NewLine + - $"fired: {firedText}" + Environment.NewLine + - $"silent: {silentText}"); + 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 } } diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index 7d35559..0a4a2c1 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -179,7 +179,11 @@ function check(source, toml) { } const methods = readMethods(source) - const hooks = methods.filter((x) => HOOK_NAME.test(x.name)) + // An `override` is never a hook: a hook is a plugin method the framework finds by name, and the + // plugin class overrides nothing shaped like one. What does override `On*` methods is the NPC's + // own classes (RunicNpcPlayer.OnDied overrides Rust's ScientistNPC), and Rust, not the + // framework, calls those. + const hooks = methods.filter((x) => HOOK_NAME.test(x.name) && !/\boverride\b/.test(x.returns)) const hookNames = new Set(hooks.map((x) => x.name)) // 1. Every hook the plugin implements is one `rnpc.status` can report on. diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js index f86785f..c7628cb 100644 --- a/scripts/checkPlugin.test.js +++ b/scripts/checkPlugin.test.js @@ -126,6 +126,25 @@ test('a method that is not shaped like a hook is left alone', () => { assert.ok(HOOK_NAME.test('CanBeTargeted')) }) +test('an override shaped like a hook belongs to the NPC classes, not the framework, and is left alone', () => { + const methods = ` private void OnServerInitialized() + { + } + + public class RunicNpcPlayer : ScientistNPC + { + public override void OnDied(HitInfo info) + { + } + + protected override void OnStateChanged() + { + } + }` + + assert.deepEqual(check(source({ methods }), toml()), []) +}) + // ── The API ──────────────────────────────────────────────────────────────── test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => { @@ -287,12 +306,13 @@ test('the parser actually reads the real plugin, rather than quietly matching no // Raise these floors as the plugin grows; they are what stops a regex that // matches nothing from passing every case above. - assert.ok(methods.length >= 5, `only found ${methods.length} methods in the real plugin`) + assert.ok(methods.length >= 40, `only found ${methods.length} methods in the real plugin`) assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser') assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser') const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name) assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call') + assert.ok(api.includes('RunicNpc_Spawn'), 'RunicNpc_Spawn was not found as an API call') assert.ok(readExpectedHooks(real).length >= 1) assert.deepEqual(readRequires(real), ['Kits']) diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs new file mode 100644 index 0000000..a533d7c --- /dev/null +++ b/tools/RunicNpcTest.cs @@ -0,0 +1,802 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Oxide.Core; +using Oxide.Core.Plugins; +using UnityEngine; +using UnityEngine.AI; + +namespace Oxide.Plugins +{ + /// + /// RunicNPC's stage 2 test harness (docs/runicnpc/PLAN.md §9, stage 2). NEVER SHIPPED: it lives + /// in tools/ and is loaded on a rig by hand. It drives RunicNPC only through its API and hooks, + /// as any other plugin would, and prints one line per assertion: + /// + /// [rnt] <group>.<check>: PASS|FAIL <detail> + /// + /// Every line is also kept in data/RunicNpcTest.json, read back over the panel. + /// + /// + /// rnt.run <group> runs one group: api, hooks, move, + /// sentry, sleep, place, or all for each in turn. + /// rnt.run place leaves two placements behind for the restart check, and + /// rnt.after checks, after a reload or restart, that every placement is back and nothing + /// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and + /// rnt.clear removes everything the harness made. + /// + /// + /// + /// It needs the hidden Kits kit rnhrevolver (stage 1's: a revolver, pistol ammo and + /// burlap). It REPLACES RunicNPC's profiles with its own test set, which marks the server + /// managed: back up data/RunicNPC/ first and restore it afterwards. + /// + /// + /// + /// Targets and wake-ups are stand-ins, stage 1's fake players: player.prefab with + /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. + /// + /// + [Info("RunicNpcTest", "Runic Gateway", "0.2.0")] + [Description("RunicNPC stage 2 tests. A developer tool: never ship it.")] + internal class RunicNpcTest : RustPlugin + { + [PluginReference] private Plugin RunicNPC; + + private const string Kit = "rnhrevolver"; + private const string Owner = "plugin:RunicNpcTest"; + private const string PlayerPrefab = "assets/prefabs/player/player.prefab"; + private const ulong StandInId = 11400000101UL; + + private readonly List _lines = new List(); + private readonly HashSet _standIns = new HashSet(); + private readonly List _built = new List(); + private Coroutine _running; + private int _pass, _fail; + + // What RunicNPC's hooks told us. + private readonly List _spawned = new List(); + private readonly List _died = new List(); + private readonly List _health = new List(); + private readonly List _despawned = new List(); + private readonly Dictionary _lastContributors = new Dictionary(); + private int _hitsOnStandIns; + + private static readonly MethodInfo LifeStoryStart = + typeof(BasePlayer).GetMethod("LifeStoryStart", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + // ---- output ---- + + private void Check(string name, bool ok, string detail = "") + { + if (ok) _pass++; else _fail++; + string line = $"{name}: {(ok ? "PASS" : "FAIL")} {detail}"; + _lines.Add(line); + Puts("[rnt] " + line); + Interface.Oxide.DataFileSystem.WriteObject(Name, _lines); + } + + private void Note(string line) + { + _lines.Add("# " + line); + Puts("[rnt] # " + line); + Interface.Oxide.DataFileSystem.WriteObject(Name, _lines); + } + + // ---- lifecycle ---- + + private void Unload() + { + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + ClearWorld(); + } + + private void ClearWorld() + { + foreach (BasePlayer p in _standIns.ToList()) + if (p != null && !p.IsDestroyed) p.Kill(); + _standIns.Clear(); + foreach (BaseEntity e in _built) + if (e != null && !e.IsDestroyed) e.Kill(); + _built.Clear(); + } + + // ---- RunicNPC's API, typed ---- + + private T Api(string call, params object[] args) + { + object r = RunicNPC?.Call(call, args); + return r is T ? (T)r : default(T); + } + + private BasePlayer Spawn(Vector3 at, string profile, JObject overrides = null, string owner = Owner) => + Api("RunicNpc_Spawn", at, profile, owner, overrides); + + private List> List(string owner) => + Api>>("RunicNpc_List", owner) ?? new List>(); + + private Dictionary Describe(BasePlayer npc) => + npc == null ? null : List(null).FirstOrDefault(d => (ulong)d["netId"] == npc.net.ID.Value); + + private static Vector3 V(object o) + { + var d = (Dictionary)o; + return new Vector3(Convert.ToSingle(d["x"]), Convert.ToSingle(d["y"]), Convert.ToSingle(d["z"])); + } + + private static float Flat(Vector3 a, Vector3 b) => new Vector2(a.x - b.x, a.z - b.z).magnitude; + + // ---- places ---- + + private static bool SampleNavmesh(Vector3 p, float r, out NavMeshHit hit) => + Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, r, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas); + + private static MonumentInfo[] _monuments; + + /// The map's monuments. (TerrainMeta.Path.Monuments is internal in Rust's own assembly.) + private static MonumentInfo[] Monuments() => _monuments ?? (_monuments = UnityEngine.Object.FindObjectsOfType()); + + /// A flat, dry spot on the navmesh far from every monument; fixed seed, so reruns land in the same field. + private static bool FindField(float clear, int seed, out Vector3 at) + { + var rng = new System.Random(seed); + float half = World.Size / 2f - 300f; + for (int i = 0; i < 4000; i++) + { + var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half); + p.y = TerrainMeta.HeightMap.GetHeight(p); + if (p.y < TerrainMeta.WaterMap.GetHeight(p) + 1f || TerrainMeta.HeightMap.GetSlope(p) > 12f) continue; + if (Monuments().Any(m => Vector3.Distance(m.transform.position, p) < clear)) continue; + NavMeshHit hit; + if (!SampleNavmesh(p, 2f, out hit)) continue; + // The whole 35 m around it must be walkable too, for the wander and route tests. + bool open = true; + for (int k = 0; k < 8 && open; k++) + { + float a = k * Mathf.PI / 4f; + NavMeshHit h2; + open = SampleNavmesh(hit.position + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * 35f, 2f, out h2); + } + + if (!open) continue; + at = hit.position; + return true; + } + + at = Vector3.zero; + return false; + } + + /// A monument with an AI zone, where Rust's own roam has move points to follow. + private static bool FindMonument(out Vector3 at, out string name) + { + foreach (MonumentInfo m in Monuments().OrderBy(m => m.name.Contains("military") ? 0 : 1)) + { + if (m == null) continue; + AIInformationZone zone = AIInformationZone.GetForPoint(m.transform.position, false); + if (zone == null) continue; + NavMeshHit hit; + if (!SampleNavmesh(m.transform.position, 20f, out hit)) continue; + at = hit.position; + name = System.IO.Path.GetFileNameWithoutExtension(m.name); + return true; + } + + at = Vector3.zero; + name = null; + return false; + } + + private BasePlayer StandIn(Vector3 at, int index) + { + BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, at) as BasePlayer; + if (p == null) return null; + ulong id = StandInId + (ulong)index; + p.userID = id; + p.UserIDString = id.ToString(); + p.displayName = "RntStandIn" + index; + p.enableSaving = false; + p.Spawn(); + p.EndSleeping(); + LifeStoryStart?.Invoke(p, null); + _standIns.Add(p); + return p; + } + + private void RemoveStandIns() + { + foreach (BasePlayer p in _standIns.ToList()) + if (p != null && !p.IsDestroyed) p.Kill(); + _standIns.Clear(); + } + + // ---- hooks: RunicNPC's, counted; and the stand-ins made invulnerable ---- + + private void OnRunicNpcSpawned(BasePlayer npc, string profile, string owner) => _spawned.Add($"{profile}/{owner}"); + + private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) + { + _died.Add($"{profile}/{owner}"); + _lastContributors.Clear(); + foreach (KeyValuePair kv in contributors) _lastContributors[kv.Key] = kv.Value; + } + + private void OnRunicNpcHealth(BasePlayer npc, string profile, float fraction) => _health.Add($"{profile}@{fraction}"); + + private void OnRunicNpcDespawned(BasePlayer npc, string owner) => _despawned.Add(owner); + + private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info) + { + BasePlayer p = victim as BasePlayer; + if (p == null || !_standIns.Contains(p) || info == null) return; + if (info.Initiator is BasePlayer && ((BasePlayer)info.Initiator).IsNpc) _hitsOnStandIns++; + info.damageTypes.ScaleAll(0f); + } + + // ---- the profiles and routes the tests use ---- + + private static JObject Prof(string movement = "wander", float radius = 15f, string role = "roamer", string kit = Kit, string name = "TRoam") => + JObject.FromObject(new + { + names = new[] { name }, + kits = new[] { kit }, + prefab = "scientistnpc_roam", + role, + movement = new { mode = movement, radius }, + health = 250f, + damageDealt = 1f, + damageTaken = new { head = 2f, body = 0.5f, legs = 1f }, + aimCone = 2f, + ranges = new { sense = 30f, loseTarget = 40f, chase = 40f, attack = 30f }, + visionCone = -0.8f, + sleepDistance = 160f, + healthThresholds = new[] { 0.5f } + }); + + private Dictionary SetTestProfiles(bool withEach = true) + { + var all = new JObject + { + ["t_roam"] = Prof(), + ["t_sentry"] = Prof(role: "sentry", name: "TSentry"), + ["t_mon"] = Prof("monument", name: "TMon"), + ["t_route"] = Prof("route:t_loop", name: "TRoute"), + ["t_bad"] = Prof(kit: "rnt_nokit", name: "TBad") + }; + JObject leash = Prof(radius: 4f, name: "TLeash"); + leash["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 10f, attack = 4f }); + all["t_leash"] = leash; + if (withEach) + all["t_each"] = Prof(name: "TEach"); + return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "no answer" }; + } + + private string SetLoop(Vector3 centre) + { + var points = new JArray(); + foreach (Vector3 o in new[] { new Vector3(12, 0, 12), new Vector3(-12, 0, 12), new Vector3(-12, 0, -12), new Vector3(12, 0, -12) }) + { + NavMeshHit hit; + SampleNavmesh(centre + o, 3f, out hit); + points.Add(JObject.FromObject(new { x = hit.position.x, y = hit.position.y, z = hit.position.z })); + } + + return Api("RunicNpc_SetRoute", "t_loop", JObject.FromObject(new { points, loop = true })); + } + + // ---- commands ---- + + [ConsoleCommand("rnt.run")] + private void CmdRun(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + string group = arg.GetString(0, "all"); + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + _running = ServerMgr.Instance.StartCoroutine(Run(group)); + arg.ReplyWith($"rnt: running {group}"); + } + + [ConsoleCommand("rnt.after")] + private void CmdAfter(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + After(); + } + + /// Spawns n plugin-owned NPCs, for the check that a plugin's NPCs die with it. + [ConsoleCommand("rnt.spawn")] + private void CmdSpawn(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 field; + if (!FindField(300f, 2024, out field)) return; + int n = 0; + for (int i = 0; i < arg.GetInt(0, 3); i++) + if (Spawn(field + new Vector3(i * 2f, 0f, -30f), "t_roam") != null) n++; + arg.ReplyWith($"rnt: spawned {n} as {Owner}"); + } + + [ConsoleCommand("rnt.clear")] + private void CmdClear(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + ClearWorld(); + Api("RunicNpc_DespawnOwner", Owner); + foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2" }) + Api("RunicNpc_RemovePlacement", id); + Api("RunicNpc_RemoveRoute", "t_loop"); + arg.ReplyWith("rnt: cleared"); + } + + private IEnumerator Run(string group) + { + _pass = _fail = 0; + Note($"run {group} on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}"); + + Vector3 field; + if (!FindField(300f, 2024, out field)) + { + Check("setup.field", false, "no open field on the navmesh"); + yield break; + } + + Dictionary refused = SetTestProfiles(); + Check("setup.profiles", refused.Count == 1 && refused.ContainsKey("t_bad"), "refused: " + string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray())); + string route = SetLoop(field + new Vector3(60f, 0f, 0f)); + Check("setup.route", route == null, route ?? "t_loop set"); + + string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "place" } : new[] { group }; + foreach (string g in groups) + { + IEnumerator inner = g == "api" ? Api(field) + : g == "hooks" ? Hooks(field) + : g == "move" ? Move(field) + : g == "sentry" ? Sentry(field) + : g == "sleep" ? Sleep(field) + : g == "place" ? Place(field) + : null; + if (inner == null) + { + Check("setup.group", false, "no group " + g); + continue; + } + + while (inner.MoveNext()) yield return inner.Current; + Api("RunicNpc_DespawnOwner", Owner); + RemoveStandIns(); + yield return new WaitForSeconds(1f); + } + + Note($"done {group}: {_pass} pass, {_fail} fail"); + _running = null; + } + + // ---- api: every call's answer ---- + + private IEnumerator Api(Vector3 field) + { + Check("api.version", Api("RunicNpc_ApiVersion") == 2, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + JObject profiles = Api("RunicNpc_Profiles"); + Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); + Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]); + string warning = Api("RunicNpc_CostWarning", 10); + Check("api.costWarning", warning != null && warning.Contains("NPC(s)"), warning); + + BasePlayer npc = Spawn(field, "t_roam"); + Check("api.spawn", npc != null, npc != null ? npc.GetType().Name : "null"); + if (npc == null) yield break; + yield return new WaitForSeconds(2f); + + Check("api.isRunicNpc", Api("RunicNpc_IsRunicNpc", npc), ""); + Check("api.profileOf", Api("RunicNpc_ProfileOf", npc) == "t_roam", Api("RunicNpc_ProfileOf", npc)); + Check("api.name", npc.displayName == "TRoam", npc.displayName); + Check("api.health", Mathf.Approximately(npc.health, 250f) && Mathf.Approximately(npc.MaxHealth(), 250f), $"{npc.health}/{npc.MaxHealth()}"); + Check("api.notSaved", !npc.enableSaving, "enableSaving=" + npc.enableSaving); + Check("api.isNpc", npc.IsNpc, "IsNpc=" + npc.IsNpc); + HeldEntity held = npc.GetHeldEntity(); + Check("api.kit", held != null && held.ShortPrefabName.Contains("revolver"), held != null ? held.ShortPrefabName : "nothing held"); + Dictionary d = Describe(npc); + Check("api.list", d != null && (string)d["owner"] == Owner && (string)d["movement"] == "wander", d != null ? $"{d["owner"]} {d["movement"]} {d["state"]}" : "not listed"); + + BasePlayer ov = Spawn(field + new Vector3(4, 0, 0), "t_roam", JObject.FromObject(new { health = 500f, names = new[] { "Ov" } })); + yield return new WaitForSeconds(0.5f); + Check("api.overrides", ov != null && ov.displayName == "Ov" && Mathf.Approximately(ov.MaxHealth(), 500f), ov != null ? $"{ov.displayName} {ov.MaxHealth()}" : "null"); + + Check("api.refuse.placementOwner", Spawn(field, "t_roam", null, "placement:x") == null, ""); + Check("api.refuse.badOwner", Spawn(field, "t_roam", null, "nobody") == null, ""); + Check("api.refuse.unknownProfile", Spawn(field, "nope") == null, ""); + Check("api.refuse.refusedProfile", Spawn(field, "t_bad") == null, ""); + Check("api.refuse.badOverride", Spawn(field, "t_roam", JObject.FromObject(new { health = -5f })) == null, ""); + Check("api.refuse.offMesh", Spawn(field + Vector3.up * 40f, "t_roam") == null, "40 m in the air"); + + int listed = List(Owner).Count; + Check("api.listOwner", listed == 2, "listed " + listed); + Check("api.despawn", Api("RunicNpc_Despawn", ov.net.ID.Value) == 1, ""); + Check("api.despawnOwner", Api("RunicNpc_DespawnOwner", Owner) == 1, ""); + Check("api.gone", List(Owner).Count == 0, "listed " + List(Owner).Count); + } + + // ---- hooks: spawned, health threshold with contributors, damage by body part, died, despawned ---- + + private IEnumerator Hooks(Vector3 field) + { + _spawned.Clear(); _died.Clear(); _health.Clear(); _despawned.Clear(); + BasePlayer shooter = StandIn(field + new Vector3(0, 0, 60), 0); + BasePlayer npc = Spawn(field, "t_roam"); + yield return new WaitForSeconds(1f); + Check("hooks.spawned", _spawned.Contains("t_roam/" + Owner), string.Join(",", _spawned.ToArray())); + + // 100 bullet damage with no bone: body, scaled 0.5 → 50 (then Rust's own protection). + float before = npc.health; + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 100f)); + float body = before - npc.health; + Check("hooks.damageTaken.body", body > 5f && body <= 50.5f, $"took {body:0.0} of 100 (body scale 0.5)"); + + // To below half: the 0.5 threshold fires, once. + while (npc.health / npc.MaxHealth() > 0.45f) + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 60f)); + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 1f)); + Check("hooks.health", _health.Count == 1 && _health[0] == "t_roam@0.5", string.Join(",", _health.ToArray())); + + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f)); + yield return new WaitForSeconds(1f); + Check("hooks.died", _died.Contains("t_roam/" + Owner), string.Join(",", _died.ToArray())); + Check("hooks.contributors", _lastContributors.ContainsKey(StandInId) && _lastContributors[StandInId] > 240f && _lastContributors[StandInId] <= 250.5f, + string.Join(",", _lastContributors.Select(kv => kv.Key + "=" + kv.Value.ToString("0")).ToArray())); + Check("hooks.diedNotDespawned", _despawned.Count == 0, "despawned: " + string.Join(",", _despawned.ToArray())); + Check("hooks.unregisteredOnDeath", List(Owner).Count == 0, "listed " + List(Owner).Count); + + BasePlayer second = Spawn(field, "t_roam"); + yield return new WaitForSeconds(0.5f); + Api("RunicNpc_Despawn", second.net.ID.Value); + Check("hooks.despawned", _despawned.Count == 1 && _despawned[0] == Owner, string.Join(",", _despawned.ToArray())); + } + + // ---- move: wander stays within its radius, a route visits every point, a monument roams, a leash holds ---- + + private IEnumerator Move(Vector3 field) + { + // A stand-in 100 m off keeps them awake (inside 160 m) without being sensed (30 m). + StandIn(field + new Vector3(0, 0, 100), 1); + + BasePlayer wander = Spawn(field, "t_roam"); + BasePlayer route = Spawn(field + new Vector3(60, 0, 0), "t_route"); + Vector3 monument; + string monumentName; + BasePlayer mon = null; + if (FindMonument(out monument, out monumentName)) + { + StandIn(monument + new Vector3(0, 0, 100), 2); + mon = Spawn(monument, "t_mon"); + } + + Vector3 home = wander.transform.position; + Vector3 monHome = mon != null ? mon.transform.position : Vector3.zero; + float wanderMax = 0f, wanderMoved = 0f, monMax = 0f; + Route loop = JObject.FromObject(Api("RunicNpc_Routes")["t_loop"]).ToObject(); + var reached = new bool[loop.points.Count]; + var order = new List(); + + for (int t = 0; t < 90; t++) + { + yield return new WaitForSeconds(1f); + if (wander != null && !wander.IsDestroyed) + { + float d = Flat(wander.transform.position, home); + wanderMax = Mathf.Max(wanderMax, d); + wanderMoved = Mathf.Max(wanderMoved, d); + } + + if (route != null && !route.IsDestroyed) + for (int i = 0; i < loop.points.Count; i++) + if (Flat(route.transform.position, loop.points[i].V) < 2.5f) + { + reached[i] = true; + if (order.Count == 0 || order[order.Count - 1] != i) order.Add(i); + } + + if (mon != null && !mon.IsDestroyed) + monMax = Mathf.Max(monMax, Flat(mon.transform.position, monHome)); + } + + Check("move.wander.moves", wanderMoved > 2f, $"furthest {wanderMoved:0.0} m from its spot"); + Check("move.wander.radius", wanderMax <= 15f + 4f, $"furthest {wanderMax:0.0} m, radius 15"); + Check("move.route.everyPoint", reached.All(x => x), "reached " + string.Join("", reached.Select(x => x ? "1" : "0").ToArray()) + " order " + string.Join(">", order.Select(x => x.ToString()).ToArray())); + bool inOrder = true; + for (int i = 1; i < order.Count; i++) + if (order[i] != (order[i - 1] + 1) % loop.points.Count) inOrder = false; + Check("move.route.inOrder", order.Count >= 3 && inOrder, string.Join(">", order.Select(x => x.ToString()).ToArray())); + if (mon != null) + Check("move.monument.roams", monMax > 5f, $"{monumentName}: furthest {monMax:0.0} m (Rust's own roam)"); + else + Check("move.monument.roams", false, "no monument with an AI zone"); + + Api("RunicNpc_DespawnOwner", Owner); + RemoveStandIns(); + + // The leash: chase 10 m from home. A stand-in 25 m off draws it out; past 10 m it must turn back. + BasePlayer leash = Spawn(field, "t_leash"); + yield return new WaitForSeconds(1f); + Vector3 leashHome = leash.transform.position; + StandIn(leashHome + new Vector3(25, 0, 0), 3); + float leashMax = 0f; + for (int t = 0; t < 40; t++) + { + yield return new WaitForSeconds(1f); + if (leash == null || leash.IsDestroyed) break; + leashMax = Mathf.Max(leashMax, Flat(leash.transform.position, leashHome)); + } + + Dictionary d2 = Describe(leash); + int leashes = d2 != null ? Convert.ToInt32(d2["leashes"]) : -1; + Check("move.chase.pursues", leashMax >= 6f, $"went {leashMax:0.0} m toward a target 25 m off (attack 4, chase 10)"); + Check("move.chase.bounded", leashMax <= 10f + 4f, $"furthest {leashMax:0.0} m, chase 10"); + Check("move.leash.givesUp", leashes >= 1, $"gave up {leashes} time(s) on a target it could not reach"); + } + + // ---- sentry: holds its spot, shoots, and may stand off the navmesh ---- + + private IEnumerator Sentry(Vector3 field) + { + BasePlayer sentry = Spawn(field, "t_sentry"); + yield return new WaitForSeconds(1f); + Vector3 spot = sentry.transform.position; + _hitsOnStandIns = 0; + StandIn(spot + new Vector3(12, 0, 0), 4); + float moved = 0f; + for (int t = 0; t < 40; t++) + { + yield return new WaitForSeconds(1f); + moved = Mathf.Max(moved, Flat(sentry.transform.position, spot)); + } + + Check("sentry.holds", moved < 1f, $"moved {moved:0.00} m in 40 s with a target 12 m off"); + Check("sentry.shoots", _hitsOnStandIns > 0, $"{_hitsOnStandIns} hit(s) on the stand-in"); + Dictionary d = Describe(sentry); + Note("sentry state: " + (d != null ? d["state"] : "?")); + Api("RunicNpc_DespawnOwner", Owner); + RemoveStandIns(); + + // A player-built floor 4 m up, then a spot truly off the navmesh. + BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", field + new Vector3(0, 4f, 20f)); + if (floor == null) + { + Check("sentry.offMesh.floor", false, "the floor prefab made nothing"); + yield break; + } + + try + { + floor.enableSaving = false; + // An unsupported floor collapses at once under Rust's stability; grounded, it stays up. + StabilityEntity stable = floor as StabilityEntity; + if (stable != null) stable.grounded = true; + floor.Spawn(); + } + catch (Exception e) + { + Check("sentry.offMesh.floor", false, e.ToString()); + yield break; + } + + _built.Add(floor); + + // Stage 1 read a new floor as off the navmesh, sampling in the frame it spawned. How long + // does Rust's navmesh take to cover it? + Vector3 top = floor.transform.position + Vector3.up * 0.1f; + float spawnedAt = Time.realtimeSinceStartup; + bool covered = false; + int frames = 0; + while (!covered && Time.realtimeSinceStartup - spawnedAt < 10f) + { + NavMeshHit onTop; + covered = SampleNavmesh(top, 1f, out onTop) && Mathf.Abs(onTop.position.y - top.y) < 0.6f; + if (!covered) + { + frames++; + yield return null; + } + } + + if (floor == null || floor.IsDestroyed) + { + Check("sentry.floor", false, "the floor did not stay up"); + yield break; + } + + Check("sentry.floor.meshCovers", covered, covered + ? $"the navmesh covered a new floor 4 m up after {Time.realtimeSinceStartup - spawnedAt:0.00} s ({frames} frames)" + : "not covered within 10 s"); + BasePlayer roamer = Spawn(top, "t_roam"); + Check("sentry.floor.roamerStandsOnIt", !covered || (roamer != null && Mathf.Abs(roamer.transform.position.y - top.y) < 0.6f), + roamer != null ? $"a roamer placed at y={roamer.transform.position.y:0.00}, floor {top.y:0.00}" : "refused"); + if (roamer != null) Api("RunicNpc_Despawn", roamer.net.ID.Value); + + // Truly off the navmesh: 40 m up. A roamer is refused; a sentry is placed and stays. + Vector3 air = field + new Vector3(0f, 40f, -20f); + Check("sentry.offMesh.roamerRefused", Spawn(air, "t_roam") == null, "40 m up"); + BasePlayer up = Spawn(air, "t_sentry"); + Check("sentry.offMesh.sentryPlaced", up != null, ""); + if (up != null) + { + StandIn(field + new Vector3(0, 0, 100), 5); + yield return new WaitForSeconds(20f); + bool there = up != null && !up.IsDestroyed && Vector3.Distance(up.transform.position, air) < 1.5f; + Check("sentry.offMesh.stays", there, up != null && !up.IsDestroyed ? $"{Vector3.Distance(up.transform.position, air):0.00} m from its spot after 20 s" : "gone"); + } + } + + // ---- sleep: walk home past 160 m of any player, then sleep; wake when one comes back ---- + + private IEnumerator Sleep(Vector3 field) + { + BasePlayer keeper = StandIn(field + new Vector3(0, 0, 100), 6); + BasePlayer npc = Spawn(field, "t_roam", JObject.FromObject(new { movement = new { mode = "wander", radius = 30f } })); + yield return new WaitForSeconds(1f); + Vector3 home = npc.transform.position; + + // Let it wander off its spot. + float away = 0f; + for (int t = 0; t < 60 && away < 8f; t++) + { + yield return new WaitForSeconds(1f); + away = Flat(npc.transform.position, home); + } + + Check("sleep.awayFirst", away >= 3f, $"{away:0.0} m from home before the players leave"); + keeper.Kill(); + _standIns.Remove(keeper); + + string rest = ""; + bool sawGoingHome = false; + float maxStep = 0f; + Vector3 last = npc.transform.position; + for (int t = 0; t < 90; t++) + { + yield return new WaitForSeconds(1f); + maxStep = Mathf.Max(maxStep, Flat(npc.transform.position, last)); + last = npc.transform.position; + rest = (string)Describe(npc)["rest"]; + if (rest == "GoingHome") sawGoingHome = true; + if (rest == "Asleep") break; + } + + float fromHome = Flat(npc.transform.position, home); + Check("sleep.walksHome", sawGoingHome || away < 3f, "saw GoingHome=" + sawGoingHome); + Check("sleep.asleep", rest == "Asleep", "rest=" + rest); + Check("sleep.atHome", fromHome <= 3f, $"{fromHome:0.0} m from home"); + Check("sleep.walkedNotTeleported", maxStep < 8f, $"largest step {maxStep:0.0} m per second"); + + // Still for 10 s asleep. + Vector3 sleptAt = npc.transform.position; + yield return new WaitForSeconds(10f); + Check("sleep.still", Flat(npc.transform.position, sleptAt) < 0.5f, ""); + + StandIn(field + new Vector3(0, 0, 100), 7); + yield return new WaitForSeconds(4f); + rest = (string)Describe(npc)["rest"]; + Check("sleep.wakes", rest == "Awake", "rest=" + rest); + } + + // ---- place: each and group respawn, a missing profile waits, a missing route waits ---- + + private IEnumerator Place(Vector3 field) + { + Vector3 a = field + new Vector3(-40, 0, 0); + Vector3 b = field + new Vector3(-40, 0, 30); + StandIn(field + new Vector3(-40, 0, 110), 8); + Check("place.set.each", Api("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each")) == null, ""); + Check("place.set.group", Api("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group")) == null, ""); + yield return new WaitForSeconds(4f); + + List> each = List("placement:t_each"); + List> group = List("placement:t_group"); + Check("place.spawned", each.Count == 3 && group.Count == 3, $"each {each.Count}, group {group.Count}"); + + // each: kill one; the other two stay, and it alone returns after ~20 s. + var survivors = new HashSet(each.Skip(1).Select(x => (ulong)x["netId"])); + Kill((ulong)each[0]["netId"]); + yield return new WaitForSeconds(2f); + Check("place.each.down", List("placement:t_each").Count == 2, "" + List("placement:t_each").Count); + yield return new WaitForSeconds(22f); + List> after = List("placement:t_each"); + Check("place.each.back", after.Count == 3 && survivors.All(id => after.Any(x => (ulong)x["netId"] == id)), $"{after.Count} alive, survivors kept={survivors.All(id => after.Any(x => (ulong)x["netId"] == id))}"); + + // group: kill one; nothing returns while two live. Kill the rest; all three return together. + Kill((ulong)group[0]["netId"]); + yield return new WaitForSeconds(24f); + Check("place.group.waitsForAll", List("placement:t_group").Count == 2, "" + List("placement:t_group").Count); + foreach (Dictionary x in List("placement:t_group")) Kill((ulong)x["netId"]); + yield return new WaitForSeconds(10f); + Check("place.group.notEarly", List("placement:t_group").Count == 0, "" + List("placement:t_group").Count); + yield return new WaitForSeconds(14f); + Check("place.group.back", List("placement:t_group").Count == 3, "" + List("placement:t_group").Count); + + // D237: remove t_each's profile. Its NPCs despawn and the placement waits; restore it and they return. + SetTestProfiles(withEach: false); + yield return new WaitForSeconds(2f); + Dictionary row = Row("t_each"); + Check("place.missingProfile.waits", List("placement:t_each").Count == 0 && row != null && row["waiting"] != null, row != null ? "" + row["waiting"] : "no row"); + SetTestProfiles(); + yield return new WaitForSeconds(4f); + Check("place.missingProfile.returns", List("placement:t_each").Count == 3, "" + List("placement:t_each").Count); + + JObject waitRoute = Placement("t_roam", a, 1, 20f, "each"); + waitRoute["movement"] = JObject.FromObject(new { mode = "route:t_nope", radius = 0f }); + Check("place.set.routeOverride", Api("RunicNpc_SetPlacement", "t_waitroute", waitRoute) == null, ""); + yield return new WaitForSeconds(2f); + row = Row("t_waitroute"); + Check("place.missingRoute.waits", row != null && ("" + row["waiting"]).Contains("route"), row != null ? "" + row["waiting"] : "no row"); + Api("RunicNpc_RemovePlacement", "t_waitroute"); + + Check("place.remove", Api("RunicNpc_RemovePlacement", "t_group") && List("placement:t_group").Count == 0, ""); + Check("place.refuse.badRespawnMode", Api("RunicNpc_SetPlacement", "t_x", Placement("t_roam", a, 1, 20f, "sometimes")) != null, ""); + + // Leave two for rnt.after, across a reload or a restart. + Api("RunicNpc_RemovePlacement", "t_each"); + Api("RunicNpc_SetPlacement", "t_keep1", Placement("t_roam", a, 2, 30f, "each")); + Api("RunicNpc_SetPlacement", "t_keep2", Placement("t_sentry", b, 1, 30f, "each")); + Note("left placements t_keep1 (2) and t_keep2 (1) for rnt.after"); + } + + private static JObject Placement(string profile, Vector3 at, int count, float respawn, string mode) => + JObject.FromObject(new { profile, position = new { x = at.x, y = at.y, z = at.z }, yaw = 0f, count, respawn, respawnMode = mode }); + + private Dictionary Row(string id) => + (Api>>("RunicNpc_Placements") ?? new List>()).FirstOrDefault(r => (string)r["id"] == id); + + private static void Kill(ulong netId) + { + BasePlayer p = BaseNetworkable.serverEntities.Find(new NetworkableId(netId)) as BasePlayer; + if (p != null && !p.IsDead()) p.Hurt(new HitInfo(null, p, Rust.DamageType.Bullet, 10000f)); + } + + /// + /// After a reload or restart: every placement is back to its count, every RunicNPC entity in + /// the world is one RunicNPC lists, and no plain scientist stands at a placement's spot (the + /// saved-subclass symptom stage 1 found). + /// + private void After() + { + _pass = _fail = 0; + List> rows = Api>>("RunicNpc_Placements") ?? new List>(); + foreach (Dictionary r in rows) + { + JObject p = (JObject)r["placement"]; + Check($"after.placement.{r["id"]}", Convert.ToInt32(r["alive"]) == (int)p["count"], $"{r["alive"]}/{p["count"]} alive, waiting={r["waiting"] ?? "-"}"); + } + + var listed = new HashSet(List(null).Select(d => (ulong)d["netId"])); + List world = BaseNetworkable.serverEntities.OfType().Where(p => p.GetType().Name == "RunicNpcPlayer").ToList(); + Check("after.worldMatchesRegistry", world.Count == listed.Count && world.All(p => listed.Contains(p.net.ID.Value)), $"world {world.Count}, listed {listed.Count}"); + + int strays = 0; + foreach (Dictionary r in rows) + { + Vector3 at = V(((JObject)r["placement"])["position"].ToObject>()); + strays += BaseNetworkable.serverEntities.OfType().Count(s => s.GetType() == typeof(ScientistNPC) && Vector3.Distance(s.transform.position, at) < 25f); + } + + Check("after.noSavedScientists", strays == 0, strays + " plain scientist(s) at placement spots"); + Check("after.pluginOwnedGone", List(Owner).Count == 0, List(Owner).Count + " left for " + Owner); + Note($"after: {_pass} pass, {_fail} fail"); + } + + /// A route, as RunicNpc_Routes returns it. + private sealed class Route + { + public List

points = new List

(); + public bool loop; + } + + private sealed class P + { + public float x, y, z; + public Vector3 V => new Vector3(x, y, z); + } + } +} diff --git a/tools/fieldlist/FieldList.csproj b/tools/fieldlist/FieldList.csproj new file mode 100644 index 0000000..71d72d1 --- /dev/null +++ b/tools/fieldlist/FieldList.csproj @@ -0,0 +1,18 @@ + + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/tools/fieldlist/Program.cs b/tools/fieldlist/Program.cs new file mode 100644 index 0000000..a76a5df --- /dev/null +++ b/tools/fieldlist/Program.cs @@ -0,0 +1,98 @@ +// Writes the list of fields RunicNPC's swap copies from Rust's scientist to ours (D232). +// +// node tools/managed.js carbon

(once per Rust update) +// dotnet run --project tools/fieldlist -- [plugin/RunicNPC.cs] +// +// must hold the CARBON rig's managed assemblies: Oxide's patcher makes Rust's private fields +// public, so its Assembly-CSharp.dll no longer says what Rust itself serialises. +// +// The rule is stage 1's, which is Unity's own for a component's authored data: every instance +// field from the component's type down to (not including) MonoBehaviour that is public and not +// [NonSerialized], or carries [SerializeField]; never readonly, const or a delegate. Applied here to +// the unmodified assembly, it picks the same fields on both frameworks. The result replaces the +// block between the two marker lines in the plugin, and the plugin resolves each name at load. + +using System.Reflection; +using System.Text; + +if (args.Length < 1) +{ + Console.Error.WriteLine("usage: dotnet run --project tools/fieldlist -- [plugin/RunicNPC.cs]"); + return 2; +} + +string managed = args[0]; +string plugin = args.Length > 1 ? args[1] : Path.Combine("plugin", "RunicNPC.cs"); + +string[] dlls = Directory.GetFiles(managed, "*.dll"); +var resolver = new PathAssemblyResolver(dlls); +using var context = new MetadataLoadContext(resolver, "mscorlib"); +Assembly rust = context.LoadFromAssemblyPath(Path.Combine(managed, "Assembly-CSharp.dll")); + +List Fields(string typeName) +{ + Type type = rust.GetType(typeName, throwOnError: true)!; + var names = new List(); + for (Type? t = type; t != null && t.FullName != "UnityEngine.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.GetCustomAttributesData().Any(a => a.AttributeType.FullName == "UnityEngine.SerializeField"); + if (!serialised || IsDelegate(f.FieldType)) + continue; + names.Add($"{t.Name}.{f.Name}"); + } + } + return names; +} + +static bool IsDelegate(Type t) +{ + for (Type? b = t; b != null; b = b.BaseType) + if (b.FullName == "System.Delegate") + return true; + return false; +} + +List npc = Fields("ScientistNPC"); +List brain = Fields("ScientistBrain"); + +Guid mvid = rust.ManifestModule.ModuleVersionId; + +const string Open = " // "; +const string Close = " // "; + +var block = new StringBuilder(); +block.AppendLine(Open); +block.AppendLine($" // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module {mvid}."); +block.AppendLine(" // Do not edit by hand: regenerate after a Rust update (D232)."); +Append(block, "SwapNpcFields", npc); +Append(block, "SwapBrainFields", brain); +block.Append(Close); + +static void Append(StringBuilder sb, string name, List fields) +{ + sb.AppendLine($" private static readonly string[] {name} ="); + sb.AppendLine(" {"); + foreach (string f in fields) + sb.AppendLine($" \"{f}\","); + sb.AppendLine(" };"); +} + +string text = File.ReadAllText(plugin); +string nl = text.Contains("\r\n") ? "\r\n" : "\n"; +int start = text.IndexOf(Open, StringComparison.Ordinal); +int end = text.IndexOf(Close, StringComparison.Ordinal); +if (start < 0 || end < start) +{ + Console.Error.WriteLine($"{plugin}: the marker lines '{Open.Trim()}' and '{Close.Trim()}' were not found."); + return 1; +} + +string replacement = block.ToString().Replace("\r\n", "\n").Replace("\n", nl); +File.WriteAllText(plugin, text[..start] + replacement + text[(end + Close.Length)..]); +Console.WriteLine($"{plugin}: {npc.Count} NPC fields, {brain.Count} brain fields (Assembly-CSharp module {mvid})."); +return 0; diff --git a/tools/managed.js b/tools/managed.js new file mode 100644 index 0000000..ab490eb --- /dev/null +++ b/tools/managed.js @@ -0,0 +1,49 @@ +// Downloads a rig's managed assemblies (RustDedicated_Data/Managed/*.dll) into a local directory, +// for tools/fieldlist, which reads Rust's own field declarations from them (D232). +// +// node tools/managed.js +// +// Use the CARBON rig. Oxide's patcher rewrites Assembly-CSharp.dll and makes private fields public, +// so the Oxide rig's copy no longer says which fields Rust itself serialises. Carbon leaves the +// assembly as Facepunch shipped it. + +const fs = require('fs') +const path = require('path') +const { load, serverId, unmsys } = require('./panel') + +const config = load() +const [rig, out] = process.argv.slice(2).map(unmsys) +if (!rig || !out) { + console.error('usage: node tools/managed.js ') + process.exit(2) +} + +const base = `${config.panel}/api/client/servers/${serverId(config, rig)}` +const headers = { Authorization: 'Bearer ' + config.key, Accept: 'application/json' } +const dir = '/RustDedicated_Data/Managed' + +async function main() { + const list = await fetch(`${base}/files/list?directory=${encodeURIComponent(dir)}`, { headers }) + if (!list.ok) throw new Error(`list ${list.status}: ${await list.text()}`) + const files = (await list.json()).data + .map((f) => f.attributes) + .filter((f) => f.is_file && f.name.endsWith('.dll')) + + fs.mkdirSync(out, { recursive: true }) + let bytes = 0 + for (const f of files) { + const target = path.join(out, f.name) + if (fs.existsSync(target) && fs.statSync(target).size === f.size) continue + const link = await fetch(`${base}/files/download?file=${encodeURIComponent(`${dir}/${f.name}`)}`, { headers }) + if (!link.ok) throw new Error(`${f.name}: ${link.status} ${await link.text()}`) + const body = await fetch((await link.json()).attributes.url) + fs.writeFileSync(target, Buffer.from(await body.arrayBuffer())) + bytes += f.size + } + console.log(`${files.length} assemblies in ${out} (${(bytes / 1e6).toFixed(1)} MB downloaded)`) +} + +main().catch((e) => { + console.error(e.message || e) + process.exit(1) +})