Files
runicnpc-rust/plugin/RunicNPC.cs
wtclaude f8084955e8
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m38s
feat: stage 2, the NPC and its API (API 2)
RunicNPC now spawns its own NPC: Rust's scientist with its ScientistNPC and
ScientistBrain swapped for ours, copying a fixed field list generated from
Rust's unmodified assembly (D232, tools/fieldlist). Profiles live in
data/RunicNPC/profiles.json in the approved shape (D238); placements persist
with each/group respawn (D236) and wait for a missing profile or route
(D237); roamers wander, follow Rust's monument paths, or walk a route (D233,
D234), with our own chase where Rust's needs an AI zone; sentries hold their
spot; NPCs walk home and sleep past 160 m of any player (D235). Spawns wait
for the navmesh and are spread over frames; caps are off by default and the
cost warning is shown instead (D227). The whole PLAN.md section 4 API and its
hooks are in, documented in docs/runicnpc/API.md.

tools/RunicNpcTest.cs is the stage 2 harness; every group passed on both
rigs, and after a plugin reload and a server restart. checkPlugin no longer
counts an override (RunicNpcPlayer.OnDied) as a hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 01:31:04 -05:00

2010 lines
83 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Requires: Kits
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
using UnityEngine.AI;
namespace Oxide.Plugins
{
/// <summary>
/// RunicNPC — Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon.
///
/// <para>
/// 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 <c>data/RunicNPC/profiles.json</c>, and placements and routes are made
/// through the API.
/// </para>
///
/// <para>
/// <b>Kits is required</b> (D217): it is how every RunicNPC NPC is equipped, so the plugin
/// declares it on the first line and neither framework loads RunicNPC without it. That line
/// and <c>requires_plugins</c> in <c>plugin.toml</c> are two statements of one fact, and the
/// PR check holds them equal.
/// </para>
///
/// <para>
/// <b>Our NPC is Rust's scientist with two components swapped</b> (D223): the prefab's
/// <c>ScientistNPC</c> and <c>ScientistBrain</c> are replaced, before the entity spawns, by
/// <see cref="RunicNpcPlayer"/> and <see cref="RunicNpcBrain"/>, 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.
/// </para>
/// </summary>
[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;
/// <summary>
/// The version of the API other plugins call (<c>RunicNpc_*</c>, PLAN.md §4). Declared
/// twice, here and as <c>api</c> in <c>plugin.toml</c>, which the release copies into
/// the tarball's manifest so the installer and the bridge can refuse a RunicNPC too old
/// for them before it is loaded. The PR check holds the two equal.
///
/// <para>
/// 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.
/// </para>
/// </summary>
private const int ApiVersion = 2;
/// <summary>
/// Every hook this plugin implements. <c>rnpc.status</c> 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.
/// </summary>
private static readonly string[] ExpectedHooks =
{
"OnServerInitialized",
"OnServerSave",
"OnEntityKill",
"OnPluginUnloaded"
};
private readonly Dictionary<string, long> _hookCounts = new Dictionary<string, long>();
/// <summary>The loaded plugin, for the NPC and brain classes, which Rust instantiates.</summary>
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";
/// <summary>How far from a placement's spot its NPCs after the first are scattered.</summary>
private const float Scatter = 3f;
/// <summary>How far above or below a roamer's spot the navmesh it is put on may be.</summary>
private const float MaxDrop = 2f;
#region Config
private PluginConfig _config;
/// <summary>
/// The plugin's config. <b>No caps by default</b> (D227): each is 0, meaning none, until an
/// admin sets it. The cost warning is shown instead, wherever NPCs are added.
/// </summary>
private sealed class PluginConfig
{
[JsonProperty("caps")] public CapsConfig Caps = new CapsConfig();
/// <summary>
/// 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.
/// </summary>
[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<PluginConfig>();
}
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
/// <summary>A position in a data file.</summary>
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 };
}
/// <summary>How a roamer moves (D233): <c>wander</c>, <c>monument</c> or <c>route:&lt;name&gt;</c>.</summary>
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;
}
/// <summary>A named description of an NPC, in the shape the org lead approved (D238).</summary>
public sealed class Profile
{
[JsonProperty("names")] public List<string> Names = new List<string>();
[JsonProperty("kits")] public List<string> Kits = new List<string>();
[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<float> HealthThresholds = new List<float>();
public bool IsSentry => Role == "sentry";
}
private sealed class ProfileFile
{
/// <summary>True once a site has pushed profiles (D221); in-game edits are then refused.</summary>
[JsonProperty("managed")] public bool Managed;
[JsonProperty("profiles")] public Dictionary<string, Profile> Profiles = new Dictionary<string, Profile>();
}
/// <summary>
/// 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).
/// </summary>
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;
/// <summary>Seconds from a death to the respawn.</summary>
[JsonProperty("respawn")] public float Respawn = 300f;
/// <summary><c>each</c> (the default) or <c>group</c> (D236).</summary>
[JsonProperty("respawnMode")] public string RespawnMode = "each";
/// <summary>Overrides the profile's movement for this placement only (D233); null keeps it.</summary>
[JsonProperty("movement", NullValueHandling = NullValueHandling.Ignore)] public Movement Movement;
}
private sealed class PlacementFile
{
[JsonProperty("placements")] public Dictionary<string, Placement> Placements = new Dictionary<string, Placement>();
}
/// <summary>
/// Points an admin records in game (stage 3) and a <c>route:&lt;name&gt;</c> roamer walks in
/// order (D233, D234). <c>loop</c> goes from the last point back to the first; otherwise the
/// route is walked back and forth.
/// </summary>
public sealed class Route
{
[JsonProperty("points")] public List<Pos> Points = new List<Pos>();
[JsonProperty("loop")] public bool Loop = true;
}
private sealed class RouteFile
{
[JsonProperty("routes")] public Dictionary<string, Route> Routes = new Dictionary<string, Route>();
}
private ProfileFile _profileFile = new ProfileFile();
private PlacementFile _placementFile = new PlacementFile();
private RouteFile _routeFile = new RouteFile();
/// <summary>The profiles that passed validation. Only these spawn.</summary>
private readonly Dictionary<string, Profile> _profiles = new Dictionary<string, Profile>();
/// <summary>The profiles that did not, and why.</summary>
private readonly Dictionary<string, string> _refused = new Dictionary<string, string>();
/// <summary>The one flag that marks placements for saving, on every change including removal (§2).</summary>
private bool _placementsDirty;
private T ReadData<T>(string name) where T : class, new()
{
try
{
T value = Interface.Oxide.DataFileSystem.ReadObject<T>(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<ProfileFile>(DataProfiles);
if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary<string, Profile>();
_routeFile = ReadData<RouteFile>(DataRoutes);
if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary<string, Route>();
_placementFile = ReadData<PlacementFile>(DataPlacements);
if (_placementFile.Placements == null) _placementFile.Placements = new Dictionary<string, Placement>();
// 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;
}
/// <summary>Validates every profile in the file; only the valid ones are used.</summary>
private void RevalidateProfiles()
{
_profiles.Clear();
_refused.Clear();
foreach (KeyValuePair<string, Profile> 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<string, string> kv in _refused)
PrintWarning($"Profile '{kv.Key}' is refused: {kv.Value}");
}
/// <summary>Null when the profile can spawn; otherwise the reason, for the log and the caller.</summary>
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:<name>";
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<string, string> _prefabPaths;
/// <summary>A scientist prefab's full path from its short name, read from the game's manifest.</summary>
private static string ResolvePrefab(string shortName)
{
if (string.IsNullOrEmpty(shortName) || !shortName.StartsWith("scientistnpc_"))
return null;
if (_prefabPaths == null)
{
_prefabPaths = new Dictionary<string, string>();
// 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;
}
/// <summary>
/// Rust's own navmesh, the one a scientist walks (stage 1, Q4): its navigator is a Gen2
/// <c>RustNavMeshAgent</c>, and Unity's NavMesh queries see none of the open world.
/// </summary>
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);
/// <summary>False until the map's navmesh is built: 8–10 minutes on a map's first boot (stage 1, Q4).</summary>
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)
// <fieldlist>
// 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",
};
// </fieldlist>
private FieldInfo[] _npcFields;
private FieldInfo[] _brainFields;
private readonly List<string> _fieldsMissing = new List<string>();
private readonly List<string> _fieldsAdded = new List<string>();
/// <summary>
/// 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.
/// </summary>
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<string> missing)
{
var chain = new List<Type>();
for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType)
chain.Add(t);
var found = new List<FieldInfo>();
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();
}
/// <summary>tools/fieldlist's rule, applied at run time: only meaningful on an unmodified assembly.</summary>
private static List<string> RuleFields(Type type)
{
var names = new List<string>();
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));
}
/// <summary>
/// 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: <c>Senses.Init</c> copies the sense values a single time).
/// </summary>
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<ScientistBrain>() : 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<RunicNpcPlayer>();
RunicNpcBrain brain = go.AddComponent<RunicNpcBrain>();
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
/// <summary>
/// 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.
/// </summary>
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<ulong, float> Contributors = new Dictionary<ulong, float>();
public readonly HashSet<float> ThresholdsFired = new HashSet<float>();
public ulong NetId => net != null ? net.ID.Value : 0UL;
public RunicNpcBrain RnBrain => Brain as RunicNpcBrain;
public override string displayName => RnName;
/// <summary>
/// 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.
/// </summary>
public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info)
{
base.AttackerInfo(info);
info.attackerName = displayName;
}
/// <summary>
/// The profile's attack range replaces the weapon's. HumanNPC.TickAttack asks through
/// IAIAttack, which this class re-implements, so the call lands here.
/// </summary>
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);
}
}
/// <summary>
/// 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):
///
/// <list type="bullet">
/// <item>the roam state replaced by our wander or route follower (a monument
/// roamer keeps Rust's own);</item>
/// <item>a sentry's moving states replaced by one that holds its spot and turns;</item>
/// <item>sleep: past the profile's distance from every player it walks home, then
/// stops thinking; a player in range wakes it.</item>
/// </list>
///
/// The design references states by type, so a replaced state keeps its events: the
/// shooting still happens where Rust's design puts it.
/// </summary>
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<AIState> Aggressive = new HashSet<AIState>
{
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);
}
/// <summary>Past its chase range from home: forget the target and head back.</summary>
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;
}
/// <summary>D233 <c>wander</c>: a walkable point within the radius of home, walk there, finish.</summary>
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();
}
}
/// <summary>D233 <c>route:&lt;name&gt;</c>: walks a recorded route's points in order.</summary>
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();
}
/// <summary>Sets off for the current point, skipping any Rust cannot path to.</summary>
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;
}
}
/// <summary>
/// Our chase, for a <c>wander</c> or <c>route</c> 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.
/// </summary>
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();
}
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>Every live NPC of ours, by net id.</summary>
private readonly Dictionary<ulong, RunicNpcPlayer> _npcs = new Dictionary<ulong, RunicNpcPlayer>();
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<string, PlacementState> _placements = new Dictionary<string, PlacementState>();
private sealed class SpawnRequest
{
public PlacementState Placement;
public int Slot;
}
private readonly Queue<SpawnRequest> _queue = new Queue<SpawnRequest>();
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);
/// <summary>Null when a cap allows one more; otherwise which cap, for the caller.</summary>
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;
}
/// <summary>The spawn-rate cap, a token bucket; always true when no rate is set.</summary>
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<Profile>();
}
/// <summary>
/// Spawns one NPC now, or returns null with the reason. Every path into the world comes
/// through here: the API, and the placement queue.
/// </summary>
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;
}
/// <summary>Removes one NPC from the registry, however it went: death, despawn or unload.</summary>
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<RunicNpcPlayer> npcs)
{
int n = 0;
foreach (RunicNpcPlayer npc in npcs.ToList())
{
if (npc == null || npc.IsDestroyed)
continue;
Unregister(npc);
npc.Kill();
n++;
}
return n;
}
/// <summary>
/// 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.
/// </summary>
private void HandleDied(RunicNpcPlayer npc, HitInfo info)
{
var contributors = new Dictionary<ulong, float>(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<string, Placement> 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;
}
/// <summary>Why a placement cannot spawn right now, or null (D237: it waits, it is not removed).</summary>
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;
}
/// <summary>Once a second: what each placement is waiting for, and which slots are due.</summary>
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();
});
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>The count a placement plans, whether or not its NPCs are alive now.</summary>
private int PlannedTotal() =>
_placements.Values.Sum(ps => ps.Slots.Length) + _npcs.Values.Count(n => n.PlacementId == null);
#endregion
#region The cost warning (D227)
/// <summary>
/// What <paramref name="total"/> 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.
/// </summary>
private static string CostWarning(int total)
{
if (total <= 0)
return "RunicNPC: no NPCs on this server.";
float hundreds = total / 100f;
string estimate = total > 100 ? " (measured up to 100; beyond that this is an estimate)" : "";
return $"RunicNPC: {total} NPC(s) on this server{estimate}. Measured on a test server, they add about " +
$"{Mathf.Max(0.1f, hundreds * 1.5f):0.#} ms to every server frame while idle. If all fight at once they add about " +
$"{hundreds * 5f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " +
$"{Mathf.Max(0.3f, hundreds * 2.5f):0.#} s.";
}
#endregion
#region Lifecycle and hooks
private void Init()
{
_self = this;
foreach (string hook in ExpectedHooks)
_hookCounts[hook] = 0L;
}
private void OnServerInitialized()
{
MarkHook("OnServerInitialized");
ResolveFields();
LoadData();
RevalidateProfiles();
RebuildPlacements();
_ready = true;
timer.Every(1f, TickPlacements);
Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " +
$"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}");
TickPlacements();
}
private void OnServerSave()
{
MarkHook("OnServerSave");
if (_placementsDirty)
SavePlacements();
}
private void OnEntityKill(BaseNetworkable entity)
{
MarkHook("OnEntityKill");
RunicNpcPlayer npc = entity as RunicNpcPlayer;
if (npc != null)
Unregister(npc);
}
/// <summary>A plugin's NPCs die with it (§2: <c>plugin:&lt;name&gt;</c>).</summary>
private void OnPluginUnloaded(Plugin plugin)
{
MarkHook("OnPluginUnloaded");
if (plugin == null || plugin == this)
return;
string owner = "plugin:" + plugin.Name;
int n = Despawn(_npcs.Values.Where(x => x.Owner == owner));
if (n > 0)
Puts($"{plugin.Name} unloaded: its {n} NPC(s) were removed.");
}
private void Unload()
{
_unloading = true;
Despawn(_npcs.Values);
_npcs.Clear();
if (_placementsDirty)
SavePlacements();
_self = null;
}
private void MarkHook(string name)
{
long count;
_hookCounts.TryGetValue(name, out count);
_hookCounts[name] = count + 1;
}
#endregion
#region The API (PLAN.md §4, docs/runicnpc/API.md)
// Every call is private: Oxide's Call reaches a non-public method by name, and a public one only
// with [HookMethod] (the PR check holds this). Each returns a value or null and says why in the log.
private int RunicNpc_ApiVersion() => ApiVersion;
private BasePlayer RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)
{
string error = null;
RunicNpcPlayer npc = null;
Profile p;
if (owner == null || !OwnerRule.IsMatch(owner))
error = "owner must be run:<id> or plugin:<name> (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<Dictionary<string, object>> RunicNpc_List(string owner) =>
_npcs.Values.Where(n => owner == null || n.Owner == owner).Select(Describe).ToList();
private static Dictionary<string, object> Describe(RunicNpcPlayer n)
{
Vector3 p = n.transform.position;
RunicNpcBrain brain = n.RnBrain;
return new Dictionary<string, object>
{
["netId"] = n.NetId,
["profile"] = n.ProfileName,
["name"] = n.RnName,
["owner"] = n.Owner,
["placement"] = n.PlacementId,
["kit"] = n.Kit,
["position"] = new Dictionary<string, object> { ["x"] = p.x, ["y"] = p.y, ["z"] = p.z },
["home"] = new Dictionary<string, object> { ["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;
}
/// <summary>
/// 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.
/// </summary>
private Dictionary<string, string> RunicNpc_SetProfiles(JObject all)
{
Dictionary<string, Profile> profiles;
try
{
profiles = all != null ? all.ToObject<Dictionary<string, Profile>>() : new Dictionary<string, Profile>();
}
catch (Exception e)
{
return new Dictionary<string, string> { ["*"] = "the profile set could not be read: " + e.Message };
}
_profileFile.Profiles = profiles;
_profileFile.Managed = true;
SaveProfiles();
RevalidateProfiles();
TickPlacements();
return new Dictionary<string, string>(_refused);
}
private List<Dictionary<string, object>> RunicNpc_Placements() => _placements.Values.Select(ps => new Dictionary<string, object>
{
["id"] = ps.Id,
["placement"] = JObject.FromObject(ps.Data),
["alive"] = ps.Slots.Count(s => s.Npc != null),
["waiting"] = ps.Waiting,
["lastError"] = ps.LastError
}).ToList();
/// <summary>Adds or replaces a placement (D222). Null on success; otherwise why not.</summary>
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<Placement>();
}
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);
/// <summary>Adds or replaces a route (D234). Every point must be on Rust's navmesh.</summary>
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<Route>();
}
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;
/// <summary>The cost warning (D227) for the NPCs this server plans, plus <paramref name="adding"/>.</summary>
private string RunicNpc_CostWarning(int adding) => CostWarning(PlannedTotal() + Math.Max(0, adding));
#endregion
#region Console
[ConsoleCommand("rnpc.status")]
private void CmdStatus(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin)
return;
var fired = new List<string>();
var silent = new List<string>();
foreach (string hook in ExpectedHooks)
{
long count;
_hookCounts.TryGetValue(hook, out count);
if (count > 0L)
fired.Add($"{hook}={count}");
else
silent.Add(hook);
}
string nl = Environment.NewLine;
var sb = new StringBuilder();
sb.Append($"RunicNPC {Version} api={ApiVersion} hooks={ExpectedHooks.Length} fired={fired.Count} silent={silent.Count}").Append(nl);
sb.Append($"fired: {(fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)")}").Append(nl);
sb.Append($"silent: {(silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)")}").Append(nl);
sb.Append($"framework={Framework()} navmesh={(NavmeshReady() ? "ready" : "building")} " +
$"swap fields: npc={_npcFields?.Length ?? 0}/{SwapNpcFields.Length} brain={_brainFields?.Length ?? 0}/{SwapBrainFields.Length} " +
$"missing={(_fieldsMissing.Count > 0 ? string.Join(",", _fieldsMissing) : "-")} added={(_fieldsAdded.Count > 0 ? string.Join(",", _fieldsAdded) : "-")}").Append(nl);
IEnumerable<RunicNpcBrain> 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());
}
/// <summary>Re-reads profiles and routes after a hand edit of their files (standalone, D221).</summary>
[ConsoleCommand("rnpc.reload")]
private void CmdReload(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin)
return;
_profileFile = ReadData<ProfileFile>(DataProfiles);
if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary<string, Profile>();
_routeFile = ReadData<RouteFile>(DataRoutes);
if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary<string, Route>();
RevalidateProfiles();
TickPlacements();
arg.ReplyWith($"RunicNPC: {_profiles.Count} profile(s) in use, {_refused.Count} refused, {_routeFile.Routes.Count} route(s).");
}
#endregion
}
}