Files
runicnpc-rust/plugin/RunicNPC.cs
wtclaude 3be2c54260
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m33s
fix: an event's teardown removes a boss's adds (D287)
The walk found an event run's boss adds outliving the run: teardown
removes what a step placed by net id, and the adds RunicNPC spawned for
a boss's phase are in no step's ledger. RunicNPC now keeps each boss's
live adds by the boss's net id, after the boss dies too, and
RunicNpc_Despawn(<boss net id>) removes them with it, which is the call
the bridge's teardown makes. An add leaves the set when it dies.

Walked on the Oxide rig: event 36, run 65 placed the boss, its phase
summoned two adds owned by run:65, the boss was killed, the event
advanced on rust.boss.killed, and the run's cleanup removed both adds.
New harness check s6.boss.despawnTakesAdds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 13:06:19 -05:00

6012 lines
263 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 Oxide.Game.Rust.Cui;
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 3 plugin (docs/runicnpc/PLAN.md §9): the NPC, its profiles, placements
/// and owners, the API other plugins call (stage 2), and the commands admins use in game
/// (stage 3, §5): <c>/rnpc</c> in chat and <c>rnpc.&lt;verb&gt;</c> in a console.
/// </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 = 5;
/// <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",
"OnEntitySpawned",
"OnEntityTakeDamage",
"CanBeTargeted",
"CanEntityTakeDamage",
"OnPluginLoaded",
"OnPlayerInput",
"OnPlayerDisconnected"
};
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;
[JsonIgnore] public Vector3 V => new Vector3(X, Y, Z);
public static Pos Of(Vector3 v) => new Pos { X = v.x, Y = v.y, Z = v.z };
}
/// <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>();
// ---- stage 5 (D254–D261, D268, D271) ----
/// <summary>Its faction (D254), or null: then its allies are its own profile's NPCs (D256).</summary>
[JsonProperty("faction")] public string Faction;
/// <summary>
/// Its own exceptions, which win over the faction table (D254): a faction, <c>scientists</c>,
/// <c>animals</c> or <c>profile:&lt;name&gt;</c>, each <c>hostile</c>, <c>neutral</c> or <c>allied</c>.
/// </summary>
[JsonProperty("relations")] public Dictionary<string, string> Relations = new Dictionary<string, string>();
/// <summary>Metres within which its allies join a fight one of them is in (D256); 0 is off.</summary>
[JsonProperty("alertRadius")] public float AlertRadius = 40f;
/// <summary><c>default</c>, <c>ignore</c> or <c>always</c> (D259, D264, D267).</summary>
[JsonProperty("turrets")] public string Turrets = "default";
/// <summary>D271: off makes it unkillable by players, on every server, PVE or not.</summary>
[JsonProperty("hurtByPlayers")] public bool HurtByPlayers = true;
/// <summary>D271: off makes it harmless to players; it still fights NPCs and turrets.</summary>
[JsonProperty("hurtsPlayers")] public bool HurtsPlayers = true;
/// <summary>The kit's extras it uses (D260); all off by default, and each one used is used up (D265).</summary>
[JsonProperty("kitUse")] public KitUse KitUse = new KitUse();
// ---- stage 6 (D273–D279) ----
/// <summary>The boss box (D273): null for an ordinary NPC. Any role but passive may carry it.</summary>
[JsonProperty("boss")] public BossBlock Boss;
/// <summary>What pressing E on it does (D278); a passive profile's only, and required there.</summary>
[JsonProperty("use")] public UseBlock Use;
[JsonIgnore] public bool IsSentry => Role == "sentry";
[JsonIgnore] public bool IsGuard => Role == "guard";
/// <summary>D278, D279: never fights, press E to talk; cannot be hurt unless its profile says so.</summary>
[JsonIgnore] public bool IsPassive => Role == "passive";
}
/// <summary>
/// D273–D277: a health bar, phases at health thresholds, and lines said in game. Phases
/// change the NPC's own copy of its profile, never the shared one.
/// </summary>
public sealed class BossBlock
{
/// <summary>D274: players within this many metres see the bar, and so does everyone who has hurt it.</summary>
[JsonProperty("barDistance")] public float BarDistance = 100f;
/// <summary>D277: <c>chat</c>, or <c>popup</c> where PopupNotifications is loaded (chat where it is not).</summary>
[JsonProperty("announce")] public string Announce = "chat";
/// <summary>D286: said to everyone on the server. Blank says nothing.</summary>
[JsonProperty("spawnLine")] public string SpawnLine = "";
/// <summary>D286: said to everyone on the server. <c>{killer}</c> is the killing blow's player.</summary>
[JsonProperty("deathLine")] public string DeathLine = "";
[JsonProperty("phases")] public List<Phase> Phases = new List<Phase>();
}
/// <summary>D275: what happens when a boss falls to a fraction of its health. Every part is optional.</summary>
public sealed class Phase
{
[JsonProperty("at")] public float At;
/// <summary>Replaces the profile's <c>damageDealt</c> from here on.</summary>
[JsonProperty("damageDealt", NullValueHandling = NullValueHandling.Ignore)] public float? DamageDealt;
/// <summary>Replaces the profile's <c>aimCone</c> from here on.</summary>
[JsonProperty("aimCone", NullValueHandling = NullValueHandling.Ignore)] public float? AimCone;
/// <summary>A multiple of Rust's own speed for the NPC: 1.3 is a third faster.</summary>
[JsonProperty("speed", NullValueHandling = NullValueHandling.Ignore)] public float? Speed;
/// <summary>Replaces the profile's ranges from here on.</summary>
[JsonProperty("ranges", NullValueHandling = NullValueHandling.Ignore)] public Ranges Ranges;
/// <summary>A Kits kit it swaps to: strip, give, equip (stage 6 spike, finding 3).</summary>
[JsonProperty("kit", NullValueHandling = NullValueHandling.Ignore)] public string Kit;
[JsonProperty("adds", NullValueHandling = NullValueHandling.Ignore)] public Adds Adds;
/// <summary>D286: said only to the players near the boss and those who have hurt it.</summary>
[JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] public string Line;
}
/// <summary>D275, D287: NPCs of another profile placed around the boss. They fight on after it dies.</summary>
public sealed class Adds
{
[JsonProperty("profile")] public string Profile;
[JsonProperty("count")] public int Count = 1;
}
/// <summary>D278: a chat line picked at random from <c>lines</c>, or a window with a title, text and Close.</summary>
public sealed class UseBlock
{
/// <summary><c>chat</c> or <c>window</c>.</summary>
[JsonProperty("mode")] public string Mode = "chat";
[JsonProperty("lines")] public List<string> Lines = new List<string>();
[JsonProperty("title")] public string Title = "";
[JsonProperty("text")] public string Text = "";
}
/// <summary>D260: the behaviours a kit's extra items allow, each opted into.</summary>
public sealed class KitUse
{
[JsonProperty("heal")] public bool Heal;
[JsonProperty("grenades")] public bool Grenades;
[JsonProperty("melee")] public bool Melee;
[JsonProperty("rockets")] public bool Rockets;
[JsonProperty("flamethrower")] public bool Flamethrower;
[JsonIgnore] public bool Any => Heal || Grenades || Melee || Rockets || Flamethrower;
}
/// <summary>One row of the faction table: a pair, both ways (D268).</summary>
public sealed class FactionPair
{
[JsonProperty("a")] public string A;
[JsonProperty("b")] public string B;
[JsonProperty("relation")] public string Relation = "neutral";
}
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>The faction table (D254), pushed with the profiles; <c>rnpc.faction</c> edits it standalone.</summary>
[JsonProperty("factions")] public List<FactionPair> Factions = new List<FactionPair>();
}
/// <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;
/// <summary>A ZoneManager zone its NPCs never leave (D272); null for none.</summary>
[JsonProperty("tether", NullValueHandling = NullValueHandling.Ignore)] public string Tether;
}
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()
{
ReadProfileFile();
_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 ReadProfileFile()
{
JObject raw = ReadData<JObject>(DataProfiles);
try
{
if (raw["profiles"] is JObject)
foreach (JProperty p in ((JObject)raw["profiles"]).Properties())
NormalizePassive(p.Value as JObject);
_profileFile = raw.ToObject<ProfileFile>() ?? new ProfileFile();
}
catch (Exception e)
{
PrintError($"data/{DataProfiles}.json could not be read and is treated as empty: {e.Message}");
_profileFile = new ProfileFile();
}
if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary<string, Profile>();
if (_profileFile.Factions == null) _profileFile.Factions = new List<FactionPair>();
}
/// <summary>
/// D279: a passive NPC cannot be hurt unless its profile says it can. <c>hurtByPlayers</c> is on
/// by default for every other role, so a passive profile that leaves it out is given "off" as
/// it is read, wherever a profile comes in: the file, a site's push, or <c>rnpc.profile</c>.
/// </summary>
private static void NormalizePassive(JObject profile)
{
if (profile != null && (string)profile["role"] == "passive" && profile["hurtByPlayers"] == null)
profile["hurtByPlayers"] = false;
}
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}");
string factions = ValidateFactions(_profileFile.Factions);
if (factions != null)
{
PrintWarning($"The faction table is ignored: {factions}");
_factionTable.Clear();
}
else
BuildFactionTable(_profileFile.Factions);
RebuildHunts();
}
/// <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" && p.Role != "guard" && p.Role != "passive")
return $"role: '{p.Role}' is not roamer, sentry, guard or passive";
string movement = ValidateMovement(p.Movement);
if (movement != null) return movement;
if (p.Movement.Mode == "stand" && !p.IsPassive)
return "movement.mode: stand is a passive NPC's; a fighting NPC that stands is a sentry";
if (!(p.Health > 0f)) return "health: must be above 0";
if (p.DamageDealt < 0f) return "damageDealt: must not be negative";
if (p.DamageTaken == null || p.DamageTaken.Head < 0f || p.DamageTaken.Body < 0f || p.DamageTaken.Legs < 0f)
return "damageTaken: head, body and legs must not be negative";
if (p.AimCone < 0f) return "aimCone: must not be negative";
if (p.Ranges == null || !(p.Ranges.Sense > 0f) || !(p.Ranges.Attack > 0f) || p.Ranges.LoseTarget < p.Ranges.Sense || p.Ranges.Chase < 0f)
return "ranges: sense and attack above 0, loseTarget at least sense, chase not negative";
if (p.VisionCone < -1f || p.VisionCone > 1f) return "visionCone: between -1 and 1";
if (p.SleepDistance < 0f) return "sleepDistance: 0 (never sleeps) or more";
if (p.HealthThresholds == null || p.HealthThresholds.Any(t => !(t > 0f && t < 1f)))
return "healthThresholds: fractions between 0 and 1";
if (!string.IsNullOrEmpty(p.Faction) && (!NameRule.IsMatch(p.Faction) || BuiltInFactions.Contains(p.Faction)))
return $"faction: '{p.Faction}' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)";
if (p.Relations == null)
return "relations: an object, even an empty one";
foreach (KeyValuePair<string, string> kv in p.Relations)
{
if (!IsFactionKey(kv.Key, true))
return $"relations: '{kv.Key}' is not a faction, scientists, animals or profile:<name>";
if (!Relations.Contains(kv.Value))
return $"relations.{kv.Key}: '{kv.Value}' is not hostile, neutral or allied";
}
if (!(p.AlertRadius >= 0f)) return "alertRadius: 0 (off) or more";
if (p.Turrets != "default" && p.Turrets != "ignore" && p.Turrets != "always")
return $"turrets: '{p.Turrets}' is not default, ignore or always";
if (p.KitUse == null) return "kitUse: an object, even an empty one";
// Stage 6 (D273–D279).
if (p.IsPassive)
{
if (p.Boss != null) return "boss: a passive NPC never fights, so it cannot be a boss";
if (p.Use == null) return "use: a passive NPC says what pressing E on it does (D278)";
}
if (p.Use != null && !p.IsPassive)
return "use: only a passive NPC answers E (D278)";
if (p.Use != null)
{
string use = ValidateUse(p.Use);
if (use != null) return use;
}
if (p.Boss != null)
{
string boss = ValidateBoss(p.Boss);
if (boss != null) return boss;
}
return null;
}
private const int MaxLine = 256;
private const int MaxWindowText = 2000;
private const int MaxAdds = 20;
private static string ValidateUse(UseBlock u)
{
if (u.Mode != "chat" && u.Mode != "window")
return $"use.mode: '{u.Mode}' is not chat or window";
if (u.Mode == "chat" && (u.Lines == null || u.Lines.Count == 0 || u.Lines.Any(string.IsNullOrWhiteSpace)))
return "use.lines: a chat answer gives at least one line, and no blank ones";
if (u.Lines != null && u.Lines.Any(l => l != null && l.Length > MaxLine))
return $"use.lines: each line is at most {MaxLine} characters";
if (u.Mode == "window" && string.IsNullOrWhiteSpace(u.Title) && string.IsNullOrWhiteSpace(u.Text))
return "use: a window has a title or some text";
if ((u.Title ?? "").Length > 80) return "use.title: at most 80 characters";
if ((u.Text ?? "").Length > MaxWindowText) return $"use.text: at most {MaxWindowText} characters";
return null;
}
private string ValidateBoss(BossBlock b)
{
if (!(b.BarDistance > 0f && b.BarDistance <= 1000f)) return "boss.barDistance: above 0 and at most 1000 metres";
if (b.Announce != "chat" && b.Announce != "popup") return $"boss.announce: '{b.Announce}' is not chat or popup";
if ((b.SpawnLine ?? "").Length > MaxLine || (b.DeathLine ?? "").Length > MaxLine)
return $"boss: a line is at most {MaxLine} characters";
if (b.Phases == null) return "boss.phases: a list, even an empty one";
var seen = new HashSet<float>();
for (int i = 0; i < b.Phases.Count; i++)
{
Phase ph = b.Phases[i];
string at = $"boss.phases[{i}]";
if (ph == null) return $"{at}: it is empty";
if (!(ph.At > 0f && ph.At < 1f)) return $"{at}.at: a fraction of its health between 0 and 1";
if (!seen.Add(ph.At)) return $"{at}.at: two phases at {ph.At}";
if (ph.DamageDealt.HasValue && ph.DamageDealt.Value < 0f) return $"{at}.damageDealt: must not be negative";
if (ph.AimCone.HasValue && ph.AimCone.Value < 0f) return $"{at}.aimCone: must not be negative";
if (ph.Speed.HasValue && !(ph.Speed.Value > 0f && ph.Speed.Value <= 5f)) return $"{at}.speed: above 0 and at most 5 (times Rust's own)";
if (ph.Ranges != null && (!(ph.Ranges.Sense > 0f) || !(ph.Ranges.Attack > 0f) || ph.Ranges.LoseTarget < ph.Ranges.Sense || ph.Ranges.Chase < 0f))
return $"{at}.ranges: sense and attack above 0, loseTarget at least sense, chase not negative";
if (ph.Kit != null && !KitExists(ph.Kit)) return $"{at}.kit: this server has no kit '{ph.Kit}'";
if (ph.Adds != null)
{
if (ph.Adds.Profile == null || !NameRule.IsMatch(ph.Adds.Profile)) return $"{at}.adds.profile: a profile name";
if (ph.Adds.Count < 1 || ph.Adds.Count > MaxAdds) return $"{at}.adds.count: 1 to {MaxAdds}";
Profile add;
if (_profileFile.Profiles.TryGetValue(ph.Adds.Profile, out add) && add != null && add.Boss != null)
return $"{at}.adds.profile: '{ph.Adds.Profile}' is a boss, and a boss does not summon bosses";
}
if ((ph.Line ?? "").Length > MaxLine) return $"{at}.line: at most {MaxLine} characters";
}
return null;
}
// ---- the faction table (D254, D268) ----
/// <summary>Rust's own two factions (D254). Neither can be a profile's faction.</summary>
private static readonly HashSet<string> BuiltInFactions = new HashSet<string> { "scientists", "animals" };
private static readonly HashSet<string> Relations = new HashSet<string> { "hostile", "neutral", "allied" };
/// <summary>A faction name, one of Rust's two, or (where a profile names it) <c>profile:&lt;name&gt;</c>.</summary>
private static bool IsFactionKey(string key, bool profileAllowed)
{
if (string.IsNullOrEmpty(key)) return false;
if (BuiltInFactions.Contains(key) || NameRule.IsMatch(key)) return true;
return profileAllowed && key.StartsWith("profile:") && NameRule.IsMatch(key.Substring(8));
}
/// <summary>Null when the table can be used; otherwise the first problem.</summary>
private static string ValidateFactions(List<FactionPair> pairs)
{
if (pairs == null) return "it is missing";
var seen = new HashSet<string>();
for (int i = 0; i < pairs.Count; i++)
{
FactionPair f = pairs[i];
if (f == null || !IsFactionKey(f.A, false) || !IsFactionKey(f.B, false))
return $"factions[{i}]: a and b are faction names (1–40 of a-z, 0-9, _ and -), scientists or animals";
if (f.A == f.B)
return $"factions[{i}]: a faction is always allied to itself";
if (BuiltInFactions.Contains(f.A) && BuiltInFactions.Contains(f.B))
return $"factions[{i}]: both are Rust's own, and RunicNPC does not change how Rust's NPCs treat each other";
if (!Relations.Contains(f.Relation))
return $"factions[{i}]: '{f.Relation}' is not hostile, neutral or allied";
if (!seen.Add(PairKey(f.A, f.B)))
return $"factions[{i}]: {f.A} and {f.B} are given twice (one row is both ways, D268)";
}
return null;
}
/// <summary>The faction table, by pair, either way round (D268).</summary>
private readonly Dictionary<string, string> _factionTable = new Dictionary<string, string>();
private static string PairKey(string a, string b) => string.CompareOrdinal(a, b) < 0 ? a + "|" + b : b + "|" + a;
private void BuildFactionTable(List<FactionPair> pairs)
{
_factionTable.Clear();
foreach (FactionPair f in pairs)
_factionTable[PairKey(f.A, f.B)] = f.Relation;
}
private static string ValidateMovement(Movement m)
{
if (m == null) return "movement: missing";
string kind, route;
if (!ParseMode(m.Mode, out kind, out route))
return $"movement.mode: '{m.Mode}' is not wander, monument, route:<name> or (passive only) stand";
if (kind == "wander" && !(m.Radius > 0f))
return "movement.radius: a wanderer's radius must be above 0";
return null;
}
/// <summary>A sentry, or a passive NPC whose movement is <c>stand</c> (D288): it holds its spot, on the navmesh or off it.</summary>
private static bool Still(Profile p, Movement placementMovement) =>
p.IsSentry || (p.IsPassive && (placementMovement ?? p.Movement)?.Mode == "stand");
private static bool ParseMode(string mode, out string kind, out string route)
{
kind = null;
route = null;
if (mode == "wander" || mode == "monument" || mode == "stand")
{
kind = mode;
return true;
}
if (mode != null && mode.StartsWith("route:") && NameRule.IsMatch(mode.Substring(6)))
{
kind = "route";
route = mode.Substring(6);
return true;
}
return false;
}
private string ValidatePlacement(Placement p)
{
if (p == null) return "it is empty";
if (p.Profile == null || !NameRule.IsMatch(p.Profile)) return "profile: a profile name";
if (p.Position == null || float.IsNaN(p.Position.X) || float.IsNaN(p.Position.Y) || float.IsNaN(p.Position.Z))
return "position: x, y and z";
if (p.Count < 1) return "count: at least 1";
if (!(p.Respawn >= 1f)) return "respawn: at least 1 second";
if (p.RespawnMode != "each" && p.RespawnMode != "group") return "respawnMode: each or group";
if (p.Movement != null)
{
string movement = ValidateMovement(p.Movement);
if (movement != null) return movement;
}
if (p.Tether != null && !TetherNameRule.IsMatch(p.Tether))
return $"tether: '{p.Tether}' is not a ZoneManager zone id";
return null;
}
/// <summary>ZoneManager's zone ids are free text; RunicNPC asks only that they be one plain word.</summary>
private static readonly Regex TetherNameRule = new Regex(@"^[A-Za-z0-9_.:-]{1,64}$");
private static string ValidateRoute(Route r)
{
if (r == null || r.Points == null || r.Points.Count < 2) return "points: at least two";
for (int i = 0; i < r.Points.Count; i++)
{
NavMeshHit hit;
if (r.Points[i] == null || !SampleNavmesh(r.Points[i].V, 3f, out hit))
return $"points[{i}] is not on Rust's navmesh";
}
return null;
}
#endregion
#region Rust's facts: kits, prefabs, the navmesh
private bool KitExists(string kit) => Kits != null && !string.IsNullOrEmpty(kit) && Kits.Call("IsKit", kit) is bool b && b;
private static Dictionary<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 5c6eb1c7-ffa2-4b1e-a218-0a66bcc56f9e.
// 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.debugCommandedNpcs",
"BasePlayer.GestureViewModel",
"BasePlayer.lastPlayerVisibility",
"BasePlayer.eyeHistory",
"BasePlayer.tickHistory",
"BasePlayer.fallDamageEffect",
"BasePlayer.drownEffect",
"BasePlayer.Belt",
"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.faces",
"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, bool still, 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;
// A sentry, and a passive NPC that stands (D288), hold their spot and turn.
brain.Sentry = still;
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, IAISenses
{
public string RnName = "Scientist";
public string ProfileName;
public Profile Profile;
public string Owner;
public string PlacementId;
public int Slot = -1;
public Vector3 Home;
public float HomeYaw;
public string MoveKind = "wander";
public float MoveRadius = 20f;
public string RouteName;
public string Kit;
public bool Died;
public readonly Dictionary<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();
/// <summary>
/// D279: a passive NPC never fights. Rust's design picks its target from every player it has
/// sensed (<c>HumanNPC.GetBestTarget</c> reads <c>Senses.Players</c>, which <c>IsTarget</c>
/// does not filter) and shoots through <c>AttackTick</c>; both come through IAIAttack, which
/// this class re-implements, so a passive one has no target and no attack.
/// </summary>
public new BaseEntity GetBestTarget() => Profile != null && Profile.IsPassive ? null : base.GetBestTarget();
public new void AttackTick(float delta, BaseEntity target, bool targetIsLOS)
{
if (Profile != null && Profile.IsPassive)
return;
base.AttackTick(delta, target, targetIsLOS);
}
// ---- stage 5: whom it fights, beside Rust's own design (D231, D254–D272) ----
/// <summary>
/// Its own target: an NPC, an animal or a turret. Rust's design still owns every fight with a
/// player; this one is fought by <see cref="RunicNPC.Fight"/> when the design has none.
/// </summary>
public BaseCombatEntity NpcTarget;
/// <summary>True while it fights <see cref="NpcTarget"/>: its roam state stands aside (D262).</summary>
public bool Engaged;
/// <summary>Whom it escorts (D258, D269), or null. Its leash and home follow it.</summary>
public BaseEntity Escort;
/// <summary>The clan, team or lone player it is allied to (D257, D270); 0 for none.</summary>
public long AllyClan;
public ulong AllyTeam;
public ulong AllyPlayer;
/// <summary>The ZoneManager zone it never leaves (D272), or null.</summary>
public string Tether;
public int HuntsVersion = -1;
public HashSet<string> Hunts;
public float NextSense, NextLos, NextMove, NextAlert, NextDefend;
public bool TargetLos;
public float LastHurtAt = float.NegativeInfinity;
public float NextHeal, NextThrow, NextRocket, KitBusyUntil;
public int Heals, Throws, Rockets, MeleeHits, ShotsMarked;
public bool HasAlly => AllyClan != 0L || AllyTeam != 0UL || AllyPlayer != 0UL;
/// <summary>Where its leash is measured from: the escorted entity while it has one, else its spot.</summary>
public Vector3 Anchor => Escort != null && !Escort.IsDestroyed ? Escort.transform.position : Home;
/// <summary>
/// Rust's <c>HumanNPC.IsTarget</c> (players, pets, scarecrows), less anyone it is allied to or
/// escorting (D257, D269). <c>IAISenses</c> is re-implemented so Rust's senses ask here.
/// </summary>
public new bool IsTarget(BaseEntity entity) =>
(Profile == null || !Profile.IsPassive) && base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity));
// ---- stage 6: the boss (D273–D277) ----
/// <summary>The phases it has entered, by their <c>at</c>; each is entered once.</summary>
public readonly HashSet<float> PhasesFired = new HashSet<float>();
/// <summary>The phase it is in: 0 before the first.</summary>
public int Phase;
/// <summary>Rust's own navigator speed at spawn, which a phase's <c>speed</c> multiplies.</summary>
public float BaseSpeed = -1f;
/// <summary>Its bar needs a redraw: set on every hit, cleared by the next redraw (at most four a second).</summary>
public bool BarDirty;
public bool IsBoss => Profile != null && Profile.Boss != null;
public new bool IsThreat(BaseEntity entity) => IsTarget(entity);
public new bool IsFriendly(BaseEntity entity) => base.IsFriendly(entity);
private BaseCombatEntity _marked;
private BaseCombatEntity.Faction _markedWas;
/// <summary>
/// Rust drops every hit an NPC's bullet makes on another NPC unless the victim's faction is
/// Horror (<c>BaseProjectile.ServerUse</c>, stage 5 spike). So its own NPC target alone is
/// made Horror for the length of its own shot, then put back. ShotTest calls TriggerDown
/// inside itself: a mark is made only when none is in place, or the inner call would save
/// Horror as the faction to restore and leave the target Horror for everyone's bullets.
/// </summary>
private bool Mark()
{
if (_marked != null)
return false;
BaseCombatEntity t = NpcTarget;
if (!Engaged || t == null || t.IsDestroyed || !t.IsNpc)
return false;
_marked = t;
_markedWas = t.faction;
t.faction = BaseCombatEntity.Faction.Horror;
ShotsMarked++;
return true;
}
private void Unmark(bool marked)
{
if (!marked)
return;
if (_marked != null && !_marked.IsDestroyed)
_marked.faction = _markedWas;
_marked = null;
}
/// <summary>A single-shot weapon fires inside ShotTest, a burst weapon from TriggerDown.</summary>
public override bool ShotTest(float targetDist)
{
if (_self != null && !_self.FuelExtra(this))
return false;
bool marked = Mark();
try { return base.ShotTest(targetDist); }
finally { Unmark(marked); }
}
public override void TriggerDown()
{
bool marked = Mark();
try { base.TriggerDown(); }
finally { Unmark(marked); }
}
public override void Hurt(HitInfo info)
{
if (Profile != null && info != null)
{
// A hit with no bone (fire, an explosion, a fall) reports every area at once,
// (HitArea)(-1), so only an exact area counts as head or legs; the rest is body.
HitArea area = info.boneArea;
float scale = area == HitArea.Head ? Profile.DamageTaken.Head
: area == HitArea.Leg || area == HitArea.Foot ? Profile.DamageTaken.Legs
: Profile.DamageTaken.Body;
if (!Mathf.Approximately(scale, 1f))
info.damageTypes.ScaleAll(scale);
// D271: "players can hurt it" off. RunicNPC's own rule, so it holds with a PVE plugin
// or without one; NextGenPVE ignores a "deny" answer (stage 5 spike).
BasePlayer by = info.InitiatorPlayer;
if (!Profile.HurtByPlayers && by != null && !by.IsNpc)
info.damageTypes.ScaleAll(0f);
}
// A contributor is credited with the health it actually took, after Rust's own
// protection and never past zero. A killing blow is credited in OnDied, which
// base.Hurt reaches before it returns, so OnRunicNpcDied carries it.
BasePlayer attacker = info?.InitiatorPlayer;
ulong attackerId = attacker != null && !attacker.IsNpc ? (ulong)attacker.userID : 0UL;
_pendingAttacker = attackerId;
_healthBefore = health;
base.Hurt(info);
_pendingAttacker = 0UL;
if (attackerId != 0UL && !Died)
Credit(attackerId, _healthBefore - health);
if (Profile != null && !IsDead())
{
// D279: Rust's own Hurt teaches the NPC its attacker; a passive one forgets at once.
if (Profile.IsPassive && Brain?.Senses?.Memory != null)
{
Brain.Senses.Memory.Targets.Clear();
Brain.Senses.Memory.Threats.Clear();
}
_self?.CheckThresholds(this);
if (IsBoss)
{
BarDirty = true;
_self?.CheckPhases(this, attackerId);
}
// An unkillable NPC (D271) takes nothing and still answers the attack.
if (info != null && info.Initiator != null)
{
LastHurtAt = Time.time;
_self?.NpcHurt(this, info);
}
}
}
private ulong _pendingAttacker;
private float _healthBefore;
private void Credit(ulong id, float damage)
{
if (damage <= 0f)
return;
float sum;
Contributors.TryGetValue(id, out sum);
Contributors[id] = sum + damage;
}
public override void OnDied(HitInfo info)
{
if (_pendingAttacker != 0UL)
Credit(_pendingAttacker, _healthBefore);
Died = true;
_self?.HandleDied(this, info);
base.OnDied(info);
}
}
/// <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)));
return;
}
// Every other NPC's roam is ours (stage 5): it stands aside while ours fights an NPC
// (D262), follows an escort (D269), and otherwise walks as its role and movement say.
// Rust's own roam and chase both need an AI zone's move points, so outside a monument
// they do nothing (stage 1, Q3; stage 2 found the chase's Error branch): a guard, a
// wanderer and a route walker get ours, and a monument roamer keeps both of Rust's.
BasicAIState inner;
if (npc.Profile != null && npc.Profile.IsGuard)
inner = new RnGuardState();
else if (npc.MoveKind == "wander")
inner = new WanderState();
else if (npc.MoveKind == "route")
inner = new RouteState();
else
{
BasicAIState rust;
inner = states.TryGetValue(AIState.Roam, out rust) ? rust : null;
}
if (inner != null)
Replace(new RnRoamState(inner));
if (npc.MoveKind != "monument" || (npc.Profile != null && npc.Profile.IsGuard))
Replace(new PursueState());
}
/// <summary>Whether Rust's design is fighting a player now; ours then leaves the fight to it.</summary>
public bool FightingAPlayer()
{
if (CurrentState == null || !CurrentState.AgrresiveState || Senses?.Memory == null)
return false;
foreach (BaseEntity e in Senses.Memory.Targets)
{
BasePlayer p = e as BasePlayer;
if (p != null && !p.IsNpc && p.IsAlive())
return true;
}
return false;
}
/// <summary>Wakes a sleeping NPC at once, for an ally's alarm (D256) or a defence (D257).</summary>
public void Rouse()
{
if (Rest == RestState.Awake)
return;
bool asleep = Rest == RestState.Asleep;
Rest = RestState.Awake;
_nextCheck = Time.time + 10f;
_playerNear = true;
if (asleep)
((IAISleepable)this).WakeAI();
}
private void Replace(BasicAIState state)
{
states.Remove(state.StateType);
AddState(state);
}
public override void Think(float delta)
{
RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer;
// Rust's think manager can reach a brain before Unity has started it, and until then
// it has no navigator and no state: leave that think to Rust.
if (npc == null || npc.Profile == null || npc.IsDead() || Navigator == null)
{
base.Think(delta);
return;
}
float now = Time.time;
if (now >= _nextCheck)
{
_nextCheck = now + 2f;
_playerNear = npc.Profile.SleepDistance <= 0f || PlayerWithin(npc.transform.position, npc.Profile.SleepDistance);
if (!Sentry && !Navigator.IsOnNavMeshLink)
Reground(npc);
_self?.CheckEscort(npc);
// D272: outside its zone (pushed, or the zone moved), it forgets the fight and walks home.
if (!Sentry && npc.Tether != null && _self != null && !_self.InTether(npc, npc.transform.position))
{
Tethered++;
Leash(npc);
}
}
switch (Rest)
{
case RestState.Asleep:
if (!_playerNear)
{
lastThinkTime = now;
return;
}
Rest = RestState.Awake;
((IAISleepable)this).WakeAI();
break;
case RestState.GoingHome:
if (_playerNear)
{
Rest = RestState.Awake;
break;
}
lastThinkTime = now;
if (Flat(npc.transform.position - npc.Anchor) <= 2f || now >= _homeBy)
Sleep();
else if (!Navigator.Moving && !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal))
Sleep();
return;
default:
if (!_playerNear && !npc.Engaged && (CurrentState == null || !CurrentState.AgrresiveState))
{
// D235: walk home first, then sleep. Nothing thinks on the way: no player
// is within the sleep distance, so there is nothing to sense or fight.
Rest = RestState.GoingHome;
_homeBy = now + 120f;
lastThinkTime = now;
if (Sentry || !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal))
Sleep();
return;
}
break;
}
if (!Sentry && npc.Profile.Ranges.Chase > 0f && CurrentState != null && CurrentState.AgrresiveState &&
Flat(npc.transform.position - npc.Anchor) > npc.Profile.Ranges.Chase)
Leash(npc);
base.Think(delta);
// Stage 5: our own fight, after Rust's, so Rust's design still owns every fight with a
// player. It costs nothing for an NPC with no faction settings and no target (D255).
if (_self != null && !npc.IsDead())
{
long t0 = Stopwatch.GetTimestamp();
_self.Fight(npc, this, delta);
FightTicks++;
FightMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency;
}
}
public int Tethered;
/// <summary>What our own fight layer has cost, for <c>rnpc.status</c> and the cost warning.</summary>
public static double FightMs;
public static long FightTicks;
/// <summary>
/// D239: the floor under a roamer was destroyed. Rust leaves the NPC standing in the air
/// where the floor was (stage 3 measured 15 s and counting), so it is put on the nearest
/// navmesh, as Rust's own navigator warps, and that spot is its home until it respawns.
/// </summary>
private void Reground(RunicNpcPlayer npc)
{
Vector3 at = npc.transform.position;
if (OffMeshReason(at) == null)
return;
NavMeshHit hit;
if (Navigator.Agent == null || !SampleNavmesh(at, Fallback, out hit))
return;
Navigator.Stop();
Navigator.Agent.Warp(hit.position);
npc.transform.position = hit.position;
npc.Home = hit.position;
Regrounded++;
_self?.NoteRegrounded(npc, at, hit.position);
}
public int Regrounded;
/// <summary>Past its chase range from home: forget the target and head back.</summary>
public void Leash(RunicNpcPlayer npc)
{
Leashes++;
npc.NpcTarget = null;
npc.Engaged = false;
Rust.Ai.SimpleAIMemory memory = Senses.Memory;
memory.All.Clear();
memory.Players.Clear();
memory.Targets.Clear();
memory.Threats.Clear();
memory.LOS.Clear();
Events.Memory.Entity.Clear();
Senses.DelaySenseUpdate(5f);
npc.NextSense = Time.time + 5f;
Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Fast);
}
private void Sleep()
{
Rest = RestState.Asleep;
Navigator.Stop();
((IAISleepable)this).SleepAI();
}
private static bool PlayerWithin(Vector3 at, float distance) =>
BaseEntity.Query.Server.GetPlayersInSphere(at, distance, NearBuffer, p => !p.IsNpc && p.IsAlive() && !p.IsSleeping()) > 0;
private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude;
}
/// <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) &&
(npc.Tether == null || _self == null || _self.InTether(npc, hit.position)) &&
brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Slow))
{
_status = StateStatus.Running;
return;
}
}
}
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
{
base.StateThink(delta, brain, entity);
if (_status == StateStatus.Error)
return _status;
return brain.Navigator.Moving ? StateStatus.Running : StateStatus.Finished;
}
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
{
base.StateLeave(brain, entity);
brain.Navigator.Stop();
}
}
/// <summary>
/// The roam state of every NPC that is not a sentry (stage 5). It hands the NPC to one of
/// three things, in order:
///
/// <list type="bullet">
/// <item>an escort (D269): it keeps within a few metres of whom it escorts;</item>
/// <item>its own fight with an NPC (D231): it stands aside while <see cref="RunicNPC.Fight"/>
/// moves the NPC, and a route walker resumes at the point it was heading to (D262);</item>
/// <item>otherwise its role's own roam: a guard's hold, our wander or route, or Rust's own
/// monument roam.</item>
/// </list>
/// </summary>
public class RnRoamState : BaseAIBrain.BasicAIState
{
private readonly BaseAIBrain.BasicAIState _inner;
private bool _innerActive;
private float _nextFollow;
/// <summary>How close an escort keeps, and how far it lets its charge get before it runs.</summary>
private const float Keep = 4f;
private const float Run = 12f;
public RnRoamState(BaseAIBrain.BasicAIState inner) : base(AIState.Roam)
{
_inner = inner;
}
public override void StateEnter(BaseAIBrain brain, BaseEntity entity)
{
base.StateEnter(brain, entity);
_innerActive = false;
_nextFollow = 0f;
RunicNpcPlayer npc = entity as RunicNpcPlayer;
if (npc != null && !Elsewhere(npc))
EnterInner(brain, entity);
}
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
{
base.StateThink(delta, brain, entity);
RunicNpcPlayer npc = entity as RunicNpcPlayer;
if (npc == null)
return StateStatus.Error;
if (Elsewhere(npc))
{
LeaveInner(brain, entity);
if (npc.Escort != null && !npc.Engaged)
Follow(brain, npc);
return StateStatus.Running;
}
if (!_innerActive)
EnterInner(brain, entity);
return _inner.StateThink(delta, brain, entity);
}
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
{
base.StateLeave(brain, entity);
LeaveInner(brain, entity);
brain.Navigator.ClearFacingDirectionOverride();
}
private static bool Elsewhere(RunicNpcPlayer npc) => npc.Engaged || (npc.Escort != null && !npc.Escort.IsDestroyed);
private void EnterInner(BaseAIBrain brain, BaseEntity entity)
{
_inner.StateEnter(brain, entity);
_innerActive = true;
}
private void LeaveInner(BaseAIBrain brain, BaseEntity entity)
{
if (!_innerActive)
return;
_inner.StateLeave(brain, entity);
_innerActive = false;
}
/// <summary>D269: keep close to whom it escorts, running when they get away.</summary>
private void Follow(BaseAIBrain brain, RunicNpcPlayer npc)
{
if (Time.time < _nextFollow)
return;
_nextFollow = Time.time + 0.5f;
Vector3 there = npc.Escort.transform.position;
float distance = Vector3.Distance(npc.transform.position, there);
if (distance <= Keep + 1f)
{
if (brain.Navigator.Moving)
brain.Navigator.Stop();
return;
}
// A spot a little short of the escorted entity, on the side ours comes from, so a squad
// gathers round rather than on top of it.
Vector3 from = (npc.transform.position - there);
from.y = 0f;
Vector3 goal = there + (from.sqrMagnitude > 0.01f ? from.normalized : Vector3.forward) * Keep;
NavMeshHit hit;
if (SampleNavmesh(goal, 3f, out hit) || SampleNavmesh(there, 3f, out hit))
brain.Navigator.SetDestination(hit.position, distance > Run ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal);
}
}
/// <summary>
/// A guard's roam (stage 5): it holds its spot facing the way it was placed, and walks back
/// to it after a chase. Its chase range is its leash, as for any roamer.
/// </summary>
public class RnGuardState : BaseAIBrain.BasicAIState
{
private float _next;
public RnGuardState() : base(AIState.Roam)
{
}
public override void StateEnter(BaseAIBrain brain, BaseEntity entity)
{
base.StateEnter(brain, entity);
_next = 0f;
}
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
{
base.StateThink(delta, brain, entity);
RunicNpcPlayer npc = entity as RunicNpcPlayer;
if (npc == null)
return StateStatus.Error;
if (Time.time < _next)
return StateStatus.Running;
_next = Time.time + 1f;
if (Flat(npc.transform.position - npc.Home) > 2f)
{
if (!brain.Navigator.Moving)
brain.Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal);
return StateStatus.Running;
}
if (brain.Navigator.Moving)
brain.Navigator.Stop();
brain.Navigator.SetFacingDirectionOverride(Quaternion.Euler(0f, npc.HomeYaw, 0f) * Vector3.forward);
return StateStatus.Running;
}
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
{
base.StateLeave(brain, entity);
brain.Navigator.Stop();
brain.Navigator.ClearFacingDirectionOverride();
}
private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude;
}
/// <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 anchor = npc.Anchor;
Vector3 fromHome = goal - anchor;
fromHome.y = 0f;
bool clamped = chase > 0f && fromHome.magnitude > chase;
if (clamped)
goal = anchor + fromHome.normalized * chase;
// D272: the zone's edge is a leash too. Past it, ours stands and waits, as at the chase range.
if (npc.Tether != null && _self != null && !_self.InTether(npc, goal))
{
goal = here;
clamped = true;
}
if (clamped && distance > npc.Profile.Ranges.Attack)
{
if (_edgeSince < 0f)
_edgeSince = Time.time;
if (Time.time - _edgeSince >= 5f)
{
(brain as RunicNpcBrain)?.Leash(npc);
return StateStatus.Error;
}
}
else
_edgeSince = -1f;
NavMeshHit hit;
if (!SampleNavmesh(goal, 3f, out hit))
return StateStatus.Error;
brain.Navigator.SetDestination(hit.position, distance > 10f ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal);
return StateStatus.Running;
}
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
{
base.StateLeave(brain, entity);
brain.Navigator.Stop();
brain.Navigator.ClearFacingDirectionOverride();
}
}
/// <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 Factions, sensing and the fight (stage 5)
// Rust's own design still owns every fight with a player. What is ours: whom else an NPC fights
// (D231, D254, D255), its allies' alarm (D256), the clan or team it serves (D257), escorts (D258,
// D269), turrets (D259, D264, D267), the kit's extras (D260), PVE (D261, D271) and the tether (D272).
// An NPC with no faction settings, no ally and no escort costs one dictionary look-up a think.
/// <summary>Rust's scientists (not ours), for the profiles hostile to <c>scientists</c>.</summary>
private readonly HashSet<BaseCombatEntity> _scientists = new HashSet<BaseCombatEntity>();
/// <summary>Rust's animals, old and new, for the profiles hostile to <c>animals</c>.</summary>
private readonly HashSet<BaseCombatEntity> _animals = new HashSet<BaseCombatEntity>();
/// <summary>Our live NPCs by profile, for the profiles hostile to another profile.</summary>
private readonly Dictionary<string, HashSet<RunicNpcPlayer>> _byProfile = new Dictionary<string, HashSet<RunicNpcPlayer>>();
/// <summary>Our NPCs with an ally or an escort: the only ones a damage event is checked for (D257, D269).</summary>
private readonly HashSet<RunicNpcPlayer> _defenders = new HashSet<RunicNpcPlayer>();
/// <summary>Moves whenever profiles or the faction table change, so each NPC recomputes whom it hunts.</summary>
private int _huntsVersion;
private static bool IsScientist(BaseEntity e) => e is ScientistNPC && !(e is RunicNpcPlayer);
private static bool IsAnimal(BaseEntity e) => e is BaseNpc || e is Rust.Ai.Gen2.BaseNPC2;
private void Track(BaseNetworkable entity)
{
BaseCombatEntity e = entity as BaseCombatEntity;
if (e == null || e is RunicNpcPlayer)
return;
if (IsScientist(e))
_scientists.Add(e);
else if (IsAnimal(e))
_animals.Add(e);
}
private void Untrack(BaseNetworkable entity)
{
BaseCombatEntity e = entity as BaseCombatEntity;
if (e == null)
return;
_scientists.Remove(e);
_animals.Remove(e);
}
private void TrackWorld()
{
_scientists.Clear();
_animals.Clear();
foreach (BaseNetworkable e in BaseNetworkable.serverEntities)
Track(e);
}
private void RebuildHunts() => _huntsVersion++;
/// <summary>A profile's faction, or <c>profile:&lt;name&gt;</c> when it has none: its allies are then its own (D256).</summary>
private static string FactionOf(string profileName, Profile p) => string.IsNullOrEmpty(p.Faction) ? "profile:" + profileName : p.Faction;
private static string FactionOf(RunicNpcPlayer npc) => FactionOf(npc.ProfileName, npc.Profile);
/// <summary>
/// How a profile treats a faction: its own exceptions first (D254), then its own faction
/// (allied), then the table, both ways (D268); neutral when nothing says otherwise.
/// </summary>
private string RelationOf(string profileName, Profile p, string otherFaction, string otherProfile)
{
string r;
if (otherProfile != null && p.Relations.TryGetValue("profile:" + otherProfile, out r))
return r;
if (p.Relations.TryGetValue(otherFaction, out r))
return r;
string mine = FactionOf(profileName, p);
if (mine == otherFaction)
return "allied";
return _factionTable.TryGetValue(PairKey(mine, otherFaction), out r) ? r : "neutral";
}
/// <summary>How one of ours treats an entity: a relation for NPCs and animals, null for anything else.</summary>
private string RelationTo(RunicNpcPlayer npc, BaseEntity e)
{
RunicNpcPlayer other = e as RunicNpcPlayer;
if (other != null)
return other.Profile == null ? null : RelationOf(npc.ProfileName, npc.Profile, FactionOf(other), other.ProfileName);
if (IsScientist(e))
return RelationOf(npc.ProfileName, npc.Profile, "scientists", null);
if (IsAnimal(e))
return RelationOf(npc.ProfileName, npc.Profile, "animals", null);
return null;
}
/// <summary>D256: an ally is the same faction, or the same profile when there is no faction.</summary>
private static bool SameSide(RunicNpcPlayer a, RunicNpcPlayer b) =>
a.Profile != null && b.Profile != null && FactionOf(a) == FactionOf(b);
/// <summary>The registry keys an NPC hunts: <c>scientists</c>, <c>animals</c> and <c>ours:&lt;profile&gt;</c>. Null for none (D255).</summary>
private HashSet<string> HuntsOf(RunicNpcPlayer npc)
{
if (npc.HuntsVersion == _huntsVersion)
return npc.Hunts;
npc.HuntsVersion = _huntsVersion;
var hunts = new HashSet<string>();
if (RelationOf(npc.ProfileName, npc.Profile, "scientists", null) == "hostile")
hunts.Add("scientists");
if (RelationOf(npc.ProfileName, npc.Profile, "animals", null) == "hostile")
hunts.Add("animals");
foreach (KeyValuePair<string, Profile> kv in _profiles)
if (RelationOf(npc.ProfileName, npc.Profile, FactionOf(kv.Key, kv.Value), kv.Key) == "hostile")
hunts.Add("ours:" + kv.Key);
npc.Hunts = hunts.Count > 0 ? hunts : null;
return npc.Hunts;
}
private IEnumerable<BaseCombatEntity> Registry(string key)
{
if (key == "scientists")
return _scientists;
if (key == "animals")
return _animals;
HashSet<RunicNpcPlayer> ours;
return key.StartsWith("ours:") && _byProfile.TryGetValue(key.Substring(5), out ours) ? ours.Cast<BaseCombatEntity>() : null;
}
private void Index(RunicNpcPlayer npc, bool add)
{
HashSet<RunicNpcPlayer> set;
if (!_byProfile.TryGetValue(npc.ProfileName, out set))
{
if (!add)
return;
_byProfile[npc.ProfileName] = set = new HashSet<RunicNpcPlayer>();
}
if (add)
set.Add(npc);
else
{
set.Remove(npc);
_defenders.Remove(npc);
}
}
private void NoteDefender(RunicNpcPlayer npc)
{
if ((npc.HasAlly || npc.Escort != null) && !npc.Profile.IsPassive)
_defenders.Add(npc);
else
_defenders.Remove(npc);
}
/// <summary>How far from its anchor ours may fight an NPC (D263): its chase range, or for a sentry its attack range.</summary>
private static float LeashOf(RunicNpcPlayer npc) =>
npc.Profile.IsSentry ? npc.Profile.Ranges.Attack : npc.Profile.Ranges.Chase > 0f ? npc.Profile.Ranges.Chase : npc.Profile.Ranges.Sense;
private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude;
// ---- the fight ----
/// <summary>LOS tests a sense may spend: the nearest few candidates only (stage 5 spike).</summary>
private const int LosBudget = 3;
/// <summary>Rust's own line-of-sight mask for an NPC's shots.</summary>
private const int LosMask = 1218519041;
private static readonly BaseCombatEntity[] Best = new BaseCombatEntity[LosBudget];
private static readonly float[] BestD2 = new float[LosBudget];
/// <summary>
/// Our own fight, after Rust's think (stage 5). With no player to fight and a target of ours —
/// sensed (D231, D263), learned from an ally (D256), or whoever hurt it or its charge (D257,
/// D268) — it faces it, closes in within its leash and its zone, and hands Rust's own attack
/// routine the target, so every shot is Rust's.
/// </summary>
private void Fight(RunicNpcPlayer npc, RunicNpcBrain brain, float delta)
{
if (npc.Profile.IsPassive)
return;
float now = Time.time;
HashSet<string> hunts = HuntsOf(npc);
BaseCombatEntity t = npc.NpcTarget;
Vector3 anchor = npc.Anchor;
float leash = LeashOf(npc);
if (t != null && (t.IsDestroyed || t.IsDead() || Spares(npc, t) ||
Vector3.Distance(npc.transform.position, t.transform.position) > Mathf.Max(npc.Profile.Ranges.LoseTarget, leash) + 10f ||
Flat(t.transform.position - anchor) > leash + Mathf.Max(npc.Profile.Ranges.Attack, 10f)))
t = npc.NpcTarget = null;
if (hunts != null && now >= npc.NextSense && TurretOf(t) == null)
{
npc.NextSense = now + 1f;
long s0 = Stopwatch.GetTimestamp();
BaseCombatEntity found = Sense(npc, hunts, anchor, leash);
SenseMs += (Stopwatch.GetTimestamp() - s0) * 1000.0 / Stopwatch.Frequency;
SenseRuns++;
if (found != null)
t = npc.NpcTarget = found;
}
bool player = brain.FightingAPlayer();
npc.Engaged = t != null && !player;
if (npc.Profile.KitUse.Any)
KitTick(npc, brain, npc.Engaged ? t : PlayerTarget(brain), now);
if (!npc.Engaged)
return;
if (now >= npc.NextLos)
{
npc.NextLos = now + 0.5f;
npc.TargetLos = Los(npc, t);
}
brain.Navigator.SetFacingDirectionEntity(t);
float distance = Vector3.Distance(npc.transform.position, t.transform.position);
if (!npc.Profile.IsSentry)
{
if (!npc.TargetLos || distance > npc.EngagementRange() * 0.9f)
{
if (now >= npc.NextMove)
{
npc.NextMove = now + 1f;
Vector3 goal = t.transform.position;
Vector3 fromAnchor = goal - anchor;
fromAnchor.y = 0f;
if (fromAnchor.magnitude > leash)
goal = anchor + fromAnchor.normalized * leash;
NavMeshHit hit;
if ((npc.Tether == null || InTether(npc, goal)) && SampleNavmesh(goal, 3f, out hit))
brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Fast);
else if (brain.Navigator.Moving)
brain.Navigator.Stop();
}
}
else if (brain.Navigator.Moving)
brain.Navigator.Stop();
}
if (now >= npc.KitBusyUntil)
npc.AttackTick(delta, t, npc.TargetLos);
}
/// <summary>The player Rust's design is fighting, for the kit's extras (D260).</summary>
private static BaseEntity PlayerTarget(RunicNpcBrain brain)
{
if (brain.CurrentState == null || !brain.CurrentState.AgrresiveState)
return null;
return brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot);
}
public static double SenseMs;
public static long SenseRuns;
/// <summary>
/// The nearest hostile within its sense range AND its leash around its anchor (D263), from the
/// registry rather than a physics query: distances first, then line of sight on the nearest
/// few only. Stage 5's spike measured this at 1.5–2.5 ms a think in a 100-against-100 fight.
/// </summary>
private BaseCombatEntity Sense(RunicNpcPlayer npc, HashSet<string> hunts, Vector3 anchor, float leash)
{
for (int i = 0; i < LosBudget; i++)
{
Best[i] = null;
BestD2[i] = float.MaxValue;
}
Vector3 at = npc.transform.position;
float sense = npc.Profile.IsSentry ? Mathf.Min(npc.Profile.Ranges.Sense, npc.Profile.Ranges.Attack) : npc.Profile.Ranges.Sense;
float r2 = sense * sense;
float leash2 = leash * leash;
foreach (string key in hunts)
{
IEnumerable<BaseCombatEntity> set = Registry(key);
if (set == null)
continue;
foreach (BaseCombatEntity e in set)
{
if (e == null || e.IsDestroyed || e == npc || e.IsDead())
continue;
Vector3 p = e.transform.position;
float d2 = (p - at).sqrMagnitude;
if (d2 > r2 || d2 >= BestD2[LosBudget - 1])
continue;
Vector3 fromAnchor = p - anchor;
if (fromAnchor.x * fromAnchor.x + fromAnchor.z * fromAnchor.z > leash2)
continue;
int k = LosBudget - 1;
while (k > 0 && BestD2[k - 1] > d2)
{
Best[k] = Best[k - 1];
BestD2[k] = BestD2[k - 1];
k--;
}
Best[k] = e;
BestD2[k] = d2;
}
}
for (int i = 0; i < LosBudget && Best[i] != null; i++)
if ((npc.Tether == null || InTether(npc, Best[i].transform.position)) && Los(npc, Best[i]))
return Best[i];
return null;
}
/// <summary>
/// A line from its eyes to the target's centre that counts the target's own collider as seen.
/// Not Rust's <c>CanSeeTarget</c>, even for a scientist or one of ours: its rays are unbounded,
/// so on open ground they run past the target into the terrain behind it, and an NPC 25 m off
/// read as unseen until ours had walked right up to it (stage 5 build). Players are Rust's
/// design's to see; this is only for what our own fight takes on.
/// </summary>
private static bool Los(RunicNpcPlayer npc, BaseCombatEntity t)
{
RaycastHit hit;
if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore))
return true;
BaseEntity e = hit.GetEntity();
return e != null && (e == t || e.EqualNetID(t) || (e.HasParent() && e.GetParentEntity() == t));
}
/// <summary>
/// The turret behind a hit. A flame turret hurts through its fireballs, whose creator is the
/// turret or the fireball that spawned them, so the chain is followed (stage 5 spike).
/// </summary>
private static BaseCombatEntity TurretOf(BaseEntity e)
{
for (int i = 0; i < 6 && e is FireBall; i++)
e = e.creatorEntity == e ? null : e.creatorEntity;
return e is AutoTurret || e is FlameTurret || e is GunTrap ? (BaseCombatEntity)e : null;
}
/// <summary>
/// Whom one of ours never targets: whom it escorts (D269), its allied clan's, team's or player's
/// people (D257), and NPCs it is allied to (D254).
/// </summary>
private bool Spares(RunicNpcPlayer npc, BaseEntity e)
{
if (e == null)
return false;
if (npc.Escort != null && (e == npc.Escort || e.EqualNetID(npc.Escort)))
return true;
BasePlayer p = e as BasePlayer;
if (p != null && !p.IsNpc)
return npc.HasAlly && IsAllyMember(npc, p);
return npc.Profile != null && (e is RunicNpcPlayer || IsScientist(e) || IsAnimal(e)) && RelationTo(npc, e) == "allied";
}
// ---- hurt: shoot back (D259, D268) and raise the alarm (D256) ----
/// <summary>Called from the NPC's own Hurt, after Rust's.</summary>
private void NpcHurt(RunicNpcPlayer npc, HitInfo info)
{
// D279: a passive NPC never fights back, and raises no alarm.
if (npc.Profile.IsPassive)
return;
BaseEntity source = info.Initiator;
BaseCombatEntity attacker = TurretOf(source) ?? TurretOf(info.Weapon != null ? info.Weapon.GetParentEntity() : null) ?? source as BaseCombatEntity;
if (attacker == null || attacker == npc || attacker.IsDestroyed || attacker.IsDead())
return;
RunicNpcPlayer ours = attacker as RunicNpcPlayer;
if (ours != null && SameSide(npc, ours))
return; // an ally's fire or grenade (D266): no fight among friends.
BasePlayer player = attacker as BasePlayer;
bool real = player != null && !player.IsNpc;
if (real && npc.HasAlly && IsAllyMember(npc, player))
return;
// D268: whoever shot it is fought back, whatever the table says. Rust's design already learns
// a player (HumanNPC.Hurt); an NPC, an animal or a turret is ours to fight.
if (!real && (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed))
npc.NpcTarget = attacker;
Alarm(npc, attacker, real);
}
/// <summary>D256: every ally within the profile's alert radius learns the attacker, seen or not.</summary>
private void Alarm(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real)
{
float radius = npc.Profile.AlertRadius;
float now = Time.time;
if (radius <= 0f || now < npc.NextAlert)
return;
npc.NextAlert = now + 1f;
float r2 = radius * radius;
Vector3 at = npc.transform.position;
foreach (RunicNpcPlayer other in _npcs.Values)
{
if (other == npc || other.IsDestroyed || other.IsDead() || other.Profile == null || !SameSide(npc, other))
continue;
if ((other.transform.position - at).sqrMagnitude > r2)
continue;
Learn(other, attacker, real);
Alarms++;
}
}
public static long Alarms, Defences;
/// <summary>Tells one of ours about an attacker: Rust's memory for a player, our own target for anything else.</summary>
private void Learn(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real)
{
// D279: a passive NPC never fights, so an ally's alarm does not reach it.
if (npc.Profile.IsPassive || Spares(npc, attacker))
return;
npc.RnBrain?.Rouse();
if (real)
{
BaseAIBrain brain = npc.Brain;
if (brain?.Senses?.Memory != null)
brain.Senses.Memory.SetKnown(attacker, npc, brain.Senses);
return;
}
if (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed)
npc.NpcTarget = attacker;
}
// ---- allies: a clan, a team or a player (D257, D270) ----
private sealed class Members
{
public HashSet<ulong> Ids = new HashSet<ulong>();
public float ReadAt = float.NegativeInfinity;
}
private readonly Dictionary<string, Members> _members = new Dictionary<string, Members>();
/// <summary>Whether a player belongs to whom one of ours is allied to.</summary>
private bool IsAllyMember(RunicNpcPlayer npc, BasePlayer p)
{
if (npc.AllyPlayer != 0UL && (ulong)p.userID == npc.AllyPlayer)
return true;
if (npc.AllyClan != 0L && p.clanId == npc.AllyClan)
return true;
return npc.AllyTeam != 0UL && p.currentTeam == npc.AllyTeam;
}
/// <summary>
/// Whether a Steam id belongs to whom one of ours is allied to: for what they own (D257), when
/// the owner may be offline. Clan and team rosters are re-read every five seconds at most.
/// </summary>
private bool IsAllyMember(RunicNpcPlayer npc, ulong id)
{
if (id == 0UL)
return false;
if (npc.AllyPlayer == id)
return true;
if (npc.AllyClan != 0L && Roster("clan:" + npc.AllyClan).Contains(id))
return true;
return npc.AllyTeam != 0UL && Roster("team:" + npc.AllyTeam).Contains(id);
}
private HashSet<ulong> Roster(string key)
{
Members m;
if (!_members.TryGetValue(key, out m))
_members[key] = m = new Members();
float now = Time.realtimeSinceStartup;
if (now - m.ReadAt < 5f)
return m.Ids;
m.ReadAt = now;
m.Ids.Clear();
if (key.StartsWith("team:"))
{
RelationshipManager.PlayerTeam team = RelationshipManager.ServerInstance != null
? RelationshipManager.ServerInstance.FindTeam(ulong.Parse(key.Substring(5)))
: null;
if (team?.members != null)
foreach (ulong id in team.members)
m.Ids.Add(id);
}
else
{
IClan clan = ReadClan(long.Parse(key.Substring(5)));
if (clan?.Members != null)
foreach (ClanMember member in clan.Members)
m.Ids.Add(member.SteamId);
}
return m.Ids;
}
/// <summary>A clan by id from the game's own clans, or null. Never waits: a clan not cached is read only if the read is instant.</summary>
private static IClan ReadClan(long clanId)
{
IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null;
if (backend == null)
return null;
IClan clan;
if (backend.TryGet(clanId, out clan) && clan != null)
return clan;
System.Threading.Tasks.ValueTask<ClanValueResult<IClan>> read = backend.Get(clanId);
return read.IsCompleted && read.Result.IsSuccess ? read.Result.Value : null;
}
/// <summary>
/// D257 and D269: a damage event against whom ours escort or serve. The attacker is fought by
/// every defender within its leash of the victim; a player, by Rust's own design.
/// </summary>
private void Defend(BaseCombatEntity victim, HitInfo info)
{
BaseEntity source = info.Initiator;
BaseCombatEntity attacker = TurretOf(source) ?? source as BaseCombatEntity;
if (attacker == null || attacker == victim || attacker.IsDead())
return;
BasePlayer victimPlayer = victim as BasePlayer;
BasePlayer attackerPlayer = attacker as BasePlayer;
bool real = attackerPlayer != null && !attackerPlayer.IsNpc;
float now = Time.time;
foreach (RunicNpcPlayer d in _defenders)
{
if (d == null || d.IsDestroyed || d.IsDead() || d.Profile == null || d == attacker || now < d.NextDefend)
continue;
bool charge = d.Escort != null && (victim == d.Escort || victim.EqualNetID(d.Escort));
if (!charge && d.HasAlly)
{
charge = victimPlayer != null && !victimPlayer.IsNpc
? IsAllyMember(d, victimPlayer)
: victim.OwnerID != 0UL && IsAllyMember(d, victim.OwnerID);
}
if (!charge)
continue;
// The attacker is not fought if it is one of the side being defended.
if (real && d.HasAlly && IsAllyMember(d, attackerPlayer))
continue;
RunicNpcPlayer ours = attacker as RunicNpcPlayer;
if (ours != null && SameSide(d, ours))
continue;
float reach = Mathf.Max(LeashOf(d), d.Profile.AlertRadius);
if (Flat(victim.transform.position - d.Anchor) > reach)
continue;
d.NextDefend = now + 0.5f;
Learn(d, attacker, real);
Defences++;
}
}
// ---- escort (D258, D269) ----
/// <summary>
/// Sets whom one of ours escorts, or clears it with null. Null on success, else why not: a
/// sentry never moves, and a dead or disconnected player cannot be escorted.
/// </summary>
private string SetEscort(RunicNpcPlayer npc, BaseEntity target)
{
if (target == null)
{
npc.Escort = null;
NoteDefender(npc);
return null;
}
if (npc.Profile.IsSentry)
return $"{npc.RnName} is a sentry and never moves, so it cannot escort";
if (target == npc || target.IsDestroyed)
return "there is nothing to escort";
BasePlayer p = target as BasePlayer;
if (p != null && (!p.IsConnected || p.IsDead()) && !p.IsNpc)
return $"{p.displayName} is not on the server";
BaseCombatEntity c = target as BaseCombatEntity;
if (c != null && c.IsDead())
return "it is dead";
npc.Escort = target;
npc.NpcTarget = null;
NoteDefender(npc);
npc.RnBrain?.Rouse();
return null;
}
/// <summary>D269: whom it escorts died or left the server; it walks back to its spot.</summary>
private void CheckEscort(RunicNpcPlayer npc)
{
// Unity's == says a destroyed entity is null, which is exactly the case to catch here.
BaseEntity e = npc.Escort;
if (ReferenceEquals(e, null))
return;
bool destroyed = e == null || e.IsDestroyed;
BasePlayer p = destroyed ? null : e as BasePlayer;
BaseCombatEntity c = destroyed ? null : e as BaseCombatEntity;
bool gone = destroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected);
if (!gone)
return;
string who = p != null ? p.displayName : destroyed ? "its charge" : e.ShortPrefabName;
npc.Escort = null;
NoteDefender(npc);
npc.RnBrain?.Navigator?.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal);
Interface.CallHook("OnRunicNpcEscortEnded", npc, npc.ProfileName, npc.Owner);
Puts($"{npc.RnName} ({npc.Owner}) no longer escorts {who}, who is gone; it walks back to its spot.");
}
// ---- the tether (D272) ----
[PluginReference] private Plugin ZoneManager;
private bool _tetherWarned;
/// <summary>
/// Whether a point is inside one of ours' zone. With ZoneManager gone the NPC is not held,
/// and the log says so once, rather than every NPC freezing where it stands.
/// </summary>
private bool InTether(RunicNpcPlayer npc, Vector3 at)
{
if (npc.Tether == null)
return true;
if (ZoneManager == null)
{
if (!_tetherWarned)
PrintWarning("ZoneManager is not loaded, so no NPC is held inside its zone.");
_tetherWarned = true;
return true;
}
object inside = ZoneManager.Call("IsPositionInZone", npc.Tether, at);
return !(inside is bool) || (bool)inside;
}
/// <summary>Null when a zone can hold NPCs at a point, else why not.</summary>
private string TetherRefusal(string zone, Vector3 at)
{
if (zone == null)
return null;
if (ZoneManager == null)
return "tether: ZoneManager is not loaded on this server";
if (ZoneManager.Call("CheckZoneID", zone) == null)
return $"tether: this server has no zone '{zone}'";
object inside = ZoneManager.Call("IsPositionInZone", zone, at);
if (inside is bool && !(bool)inside)
return $"tether: the spot is outside the zone '{zone}'";
return null;
}
// ---- the kit's extras (D260, D265) ----
/// <summary>The first item in its inventory whose held entity is a <typeparamref name="T"/>, or null.</summary>
private static Item FindHeld<T>(RunicNpcPlayer npc) where T : HeldEntity
{
foreach (ItemContainer container in new[] { npc.inventory.containerBelt, npc.inventory.containerMain })
{
if (container == null)
continue;
foreach (Item item in container.itemList)
if (item != null && item.GetHeldEntity() is T)
return item;
}
return null;
}
/// <summary>Puts an item in its hands; only the belt can be held, so it is moved there first.</summary>
private static bool Hold(RunicNpcPlayer npc, Item item)
{
if (item == null)
return false;
if (npc.GetActiveItem() == item)
return true;
if (item.parent != npc.inventory.containerBelt && !item.MoveToContainer(npc.inventory.containerBelt))
return false;
npc.UpdateActiveItem(item.uid);
return true;
}
/// <summary>Back to the main weapon (the belt's first slot, Rust's own rule).</summary>
private static void MainWeapon(RunicNpcPlayer npc)
{
Item main = npc.inventory.containerBelt?.GetSlot(0);
if (main != null && npc.GetActiveItem() != main)
npc.EquipWeapon(true);
}
private static bool IsMain(RunicNpcPlayer npc, Item item) => item != null && npc.inventory.containerBelt?.GetSlot(0) == item;
/// <summary>
/// D260: each extra the profile opts into, when the moment calls for it. Rust's AI uses none of
/// them in open ground (stage 5 spike), so these are ours: heal in a lull, melee close in,
/// the flamethrower within its reach, a grenade at 5–20 m, a rocket at 15–80 m.
/// </summary>
private void KitTick(RunicNpcPlayer npc, RunicNpcBrain brain, BaseEntity target, float now)
{
KitUse k = npc.Profile.KitUse;
if (now < npc.KitBusyUntil)
return;
if (k.Heal && npc.health < npc.MaxHealth() * 0.5f && now - npc.LastHurtAt > 3f && now >= npc.NextHeal)
{
npc.NextHeal = now + (Heal(npc) ? 6f : 15f);
if (now < npc.KitBusyUntil)
return;
}
if (target == null || target.IsDestroyed)
{
MainWeapon(npc);
return;
}
float distance = Vector3.Distance(npc.transform.position, target.transform.position);
if (k.Melee && distance <= 2.5f)
{
Item melee = FindHeld<BaseMelee>(npc);
if (melee != null && !IsMain(npc, melee) && Hold(npc, melee))
{
brain.Navigator.SetFacingDirectionEntity(target);
if (npc.MeleeAttack())
npc.MeleeHits++;
return;
}
}
if (k.Flamethrower && distance <= 7f)
{
Item flame = FindHeld<FlameThrower>(npc);
if (flame != null && !IsMain(npc, flame) && HasFuel(npc, flame) && Hold(npc, flame))
return; // Rust's own attack tick fires what it holds.
}
if (k.Grenades && distance > 5f && distance < 20f && now >= npc.NextThrow)
{
npc.NextThrow = now + 10f;
Item thrown = FindHeld<ThrownWeapon>(npc);
if (thrown != null && (thrown.parent == npc.inventory.containerBelt || thrown.MoveToContainer(npc.inventory.containerBelt)) &&
npc.TryUseThrownWeapon(thrown, target, 10f))
{
// Rust's own throw: aims for 1.5 s, throws, uses one up, and re-equips the main weapon.
npc.Throws++;
npc.KitBusyUntil = now + 2.5f;
return;
}
}
if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket)
{
npc.NextRocket = now + 2f;
// A real player's line of sight is the one Rust's design shoots on; an NPC (a scientist, one of
// ours) or an animal is never in Rust's player memory, so it is ours.
BaseCombatEntity aim = target as BaseCombatEntity;
BasePlayer human = aim as BasePlayer;
bool seen = human != null && !human.IsNpc
? brain.Senses?.Memory != null && brain.Senses.Memory.IsLOS(aim)
: aim != null && Los(npc, aim);
if (seen && FireRocket(npc, target))
{
npc.KitBusyUntil = now + 1.5f;
return;
}
}
Item held = npc.GetActiveItem();
if (held != null && !IsMain(npc, held))
{
HeldEntity h = held.GetHeldEntity() as HeldEntity;
bool keep = (h is BaseMelee && distance <= 4f) || (h is FlameThrower && distance <= 9f);
if (!keep)
MainWeapon(npc);
}
}
/// <summary>D260: one syringe, medkit or bandage, used up, its health given at once.</summary>
private bool Heal(RunicNpcPlayer npc)
{
Item item = FindHeld<MedicalTool>(npc);
ItemModConsumable consumable = item?.info.GetComponent<ItemModConsumable>();
if (consumable == null)
return false;
float amount = 0f;
foreach (ItemModConsumable.ConsumableEffect effect in consumable.effects)
if (effect.type == MetabolismAttribute.Type.Health || effect.type == MetabolismAttribute.Type.HealthOverTime)
amount += effect.amount;
if (amount <= 0f)
return false;
Hold(npc, item);
npc.SignalBroadcast(BaseEntity.Signal.Attack);
item.UseItem(1);
npc.Heal(amount);
npc.Heals++;
npc.KitBusyUntil = Time.time + 1.5f;
timer.Once(1.2f, () =>
{
if (npc != null && !npc.IsDestroyed && !npc.IsDead())
MainWeapon(npc);
});
return true;
}
/// <summary>
/// D260: a rocket from the kit's launcher, one rocket used up. Rust's AI never fires a launcher
/// (stage 5 spike), so the rocket is launched here, from its eyes, on an arc that lands on the
/// target, and credited to the NPC.
/// </summary>
private bool FireRocket(RunicNpcPlayer npc, BaseEntity target)
{
Item launcher = FindHeld<BaseLauncher>(npc);
if (launcher == null)
return false;
Item ammo = null;
ItemModProjectile projectile = null;
foreach (ItemContainer container in new[] { npc.inventory.containerMain, npc.inventory.containerBelt })
{
if (container == null)
continue;
foreach (Item item in container.itemList)
{
ItemModProjectile p = item?.info.GetComponent<ItemModProjectile>();
if (p != null && p.projectileObject != null && p.projectileObject.isValid && item.info.shortname.StartsWith("ammo.rocket"))
{
ammo = item;
projectile = p;
break;
}
}
if (ammo != null)
break;
}
if (ammo == null || !Hold(npc, launcher))
return false;
Vector3 from = npc.eyes.position + npc.eyes.BodyForward() * 1f;
Vector3 to = target.CenterPoint();
BaseEntity rocket = GameManager.server.CreateEntity(projectile.projectileObject.resourcePath, from);
ServerProjectile mover = rocket != null ? rocket.GetComponent<ServerProjectile>() : null;
if (mover == null)
{
if (rocket != null)
UnityEngine.Object.Destroy(rocket.gameObject);
return false;
}
Vector3 direction = Arc(from, to, mover.speed, -Physics.gravity.y * mover.gravityModifier);
npc.SetAimDirection(direction);
rocket.SetCreatorEntity(npc);
rocket.OwnerID = 0UL;
mover.InitializeVelocity(direction * mover.speed);
rocket.SendMessage("SetDamageScale", npc.damageScale, SendMessageOptions.DontRequireReceiver);
rocket.Spawn();
ammo.UseItem(1);
launcher.GetHeldEntity()?.SignalBroadcast(BaseEntity.Signal.Attack, string.Empty);
npc.Rockets++;
npc.NextRocket = Time.time + 10f;
timer.Once(1.2f, () =>
{
if (npc != null && !npc.IsDestroyed && !npc.IsDead())
MainWeapon(npc);
});
return true;
}
/// <summary>The direction that lands a projectile of this speed and gravity on the point; straight at it when out of reach.</summary>
private static Vector3 Arc(Vector3 from, Vector3 to, float speed, float gravity)
{
Vector3 delta = to - from;
Vector3 flat = new Vector3(delta.x, 0f, delta.z);
float x = flat.magnitude;
if (gravity <= 0f || x < 0.5f)
return delta.normalized;
float y = delta.y;
float v2 = speed * speed;
float root = v2 * v2 - gravity * (gravity * x * x + 2f * y * v2);
if (root < 0f)
return delta.normalized;
float angle = Mathf.Atan((v2 - Mathf.Sqrt(root)) / (gravity * x));
return (flat.normalized * Mathf.Cos(angle) + Vector3.up * Mathf.Sin(angle)).normalized;
}
private static bool HasFuel(RunicNpcPlayer npc, Item flameItem)
{
FlameThrower f = flameItem.GetHeldEntity() as FlameThrower;
return f != null && (f.ammo > 0 || (f.fuelType != null && npc.inventory.FindItemByItemName(f.fuelType.shortname) != null));
}
/// <summary>
/// Called from ShotTest. D265: the main weapon is endless, as in Rust. A flamethrower held as an
/// EXTRA burns the kit's fuel: an empty tank is refilled from the inventory, and with none left
/// the NPC goes back to its main weapon. False means "no shot now".
/// </summary>
private bool FuelExtra(RunicNpcPlayer npc)
{
Item held = npc.GetActiveItem();
FlameThrower f = held?.GetHeldEntity() as FlameThrower;
if (f == null || IsMain(npc, held) || f.ammo > 0)
return true;
Item fuel = f.fuelType != null ? npc.inventory.FindItemByItemName(f.fuelType.shortname) : null;
if (fuel == null)
{
MainWeapon(npc);
return false;
}
int take = Mathf.Min(fuel.amount, f.maxAmmo);
fuel.UseItem(take);
f.ammo = take;
f.SendNetworkUpdateImmediate();
return true;
}
// ---- turrets (D259, D264, D267) ----
/// <summary>
/// <c>turrets: ignore</c>: no auto turret, flame turret or shotgun trap targets it. Answers only
/// for our own NPCs, and null for everything else.
/// </summary>
private object CanBeTargeted(BaseCombatEntity entity, MonoBehaviour turret)
{
MarkHook("CanBeTargeted");
RunicNpcPlayer npc = entity as RunicNpcPlayer;
if (npc?.Profile == null || npc.Profile.Turrets != "ignore")
return null;
return false;
}
private HarmonyLib.Harmony _harmony;
private const string HarmonyId = "runicnpc.sentries";
private string _sentryPatch = "not tried";
/// <summary>
/// D267: Outpost's and Bandit Camp's sentries refuse every scientist in <c>Ignore</c> and
/// <c>IsEntityHostile</c>, before any Oxide or Carbon hook runs, so <c>turrets: always</c> is a
/// Harmony patch on those two. Each answers "hostile" for our <c>always</c> NPCs alone and runs
/// Rust's own method for everything else, Rust's scientists and bandit guards included.
/// </summary>
private void PatchSentries()
{
try
{
const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
MethodInfo ignore = typeof(NPCAutoTurret).GetMethod("Ignore", any, null, new[] { typeof(BasePlayer) }, null);
MethodInfo hostile = typeof(NPCAutoTurret).GetMethod("IsEntityHostile", any, null, new[] { typeof(BaseCombatEntity) }, null);
if (ignore == null || hostile == null)
{
_sentryPatch = $"not patched: this Rust has no NPCAutoTurret.{(ignore == null ? "Ignore" : "IsEntityHostile")}";
PrintWarning($"turrets: always cannot reach the safe-zone sentries ({_sentryPatch}).");
return;
}
_harmony = new HarmonyLib.Harmony(HarmonyId);
_harmony.Patch(ignore, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.IgnorePrefix))));
_harmony.Patch(hostile, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.HostilePrefix))));
_sentryPatch = "patched";
}
catch (Exception e)
{
_sentryPatch = "failed: " + e.Message;
PrintWarning($"turrets: always cannot reach the safe-zone sentries: Harmony could not patch them ({e.Message}).");
}
}
private void UnpatchSentries()
{
try
{
_harmony?.UnpatchAll(HarmonyId);
}
catch (Exception e)
{
PrintWarning($"Harmony could not unpatch the sentries: {e.Message}");
}
_harmony = null;
}
/// <summary>The two prefixes. Each answers for our <c>always</c> NPCs only; Rust's method runs for anything else.</summary>
public static class SentryPatch
{
public static long Answered;
private static bool Always(BaseEntity e)
{
RunicNpcPlayer npc = e as RunicNpcPlayer;
return npc != null && npc.Profile != null && npc.Profile.Turrets == "always" && !npc.IsDead();
}
public static bool IgnorePrefix(BasePlayer __0, ref bool __result)
{
if (!Always(__0))
return true;
__result = false;
Answered++;
return false;
}
public static bool HostilePrefix(BaseCombatEntity __0, ref bool __result)
{
if (!Always(__0))
return true;
__result = true;
Answered++;
return false;
}
}
// ---- PVE (D261, D271) ----
/// <summary>
/// TruePVE's and NextGenPVE's question. Ours are "allowed" both ways with players, as Rust's
/// scientists are (D261), unless the profile turns a direction off (D271). Null for anything
/// that is not one of ours facing a player.
/// </summary>
private object CanEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
{
MarkHook("CanEntityTakeDamage");
if (entity == null || info == null)
return null;
RunicNpcPlayer victim = entity as RunicNpcPlayer;
BasePlayer attacker = info.InitiatorPlayer;
if (victim?.Profile != null && attacker != null && !attacker.IsNpc)
return victim.Profile.HurtByPlayers;
RunicNpcPlayer from = info.Initiator as RunicNpcPlayer;
BasePlayer player = entity as BasePlayer;
if (from?.Profile != null && player != null && !player.IsNpc)
return from.Profile.HurtsPlayers;
return null;
}
/// <summary>
/// Every damage event. Two jobs only: a harmless profile's NPC never hurts a player (D271),
/// and whom ours escort or serve are defended (D257, D269). Null otherwise.
/// </summary>
private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
{
MarkHook("OnEntityTakeDamage");
if (entity == null || info == null)
return null;
RunicNpcPlayer from = info.Initiator as RunicNpcPlayer;
BasePlayer player = entity as BasePlayer;
if (from?.Profile != null && !from.Profile.HurtsPlayers && player != null && !player.IsNpc)
return true;
if (_defenders.Count > 0)
Defend(entity, info);
return null;
}
private void OnEntitySpawned(BaseNetworkable entity)
{
MarkHook("OnEntitySpawned");
if (_ready)
Track(entity);
}
#endregion
#region Registry, owners, spawning
/// <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;
/// <summary>Set while its spot is off the navmesh and its NPCs stand on the nearest (D239).</summary>
public string Note;
}
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 (kind == "stand" && !p.IsPassive)
{
error = "movement stand is a passive NPC's; a fighting NPC that stands is a sentry";
return null;
}
bool still = p.IsSentry || kind == "stand";
if (!still && kind == "route" && !_routeFile.Routes.ContainsKey(route))
{
error = $"route '{route}' does not exist";
return null;
}
Vector3 position = at;
if (!still)
{
// 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;
}
// A boss's phases change its stats, so it gets its own copy of the profile (D275).
if (p.Boss != null)
p = Effective(p, null);
RunicNpcPlayer npc = CreateNpc(p, position, yaw, still, out error);
if (npc == null)
return null;
npc.RnName = p.Names[UnityEngine.Random.Range(0, p.Names.Count)];
npc.ProfileName = profileName;
npc.Profile = p;
npc.Owner = owner;
npc.PlacementId = placementId;
npc.Slot = slot;
npc.Home = position;
npc.HomeYaw = yaw;
npc.MoveKind = kind;
npc.MoveRadius = movement.Radius;
npc.RouteName = route;
// CreateEntity(..., startActive: false) leaves the GameObject inactive and skips exactly this
// call, which GameManager.CreatePrefab makes when active is true. Without it the entity still
// spawns and thinks, but Unity never starts the brain: no design, no navigator, no state
// (stage 1, Q1).
npc.gameObject.AwakeFromInstantiate();
npc.Spawn();
npc.InitializeHealth(p.Health, p.Health);
npc.Kit = p.Kits[UnityEngine.Random.Range(0, p.Kits.Count)];
npc.inventory.Strip();
object given = Kits?.Call("GiveKit", npc, npc.Kit);
if (given is string)
PrintWarning($"Kits did not give '{npc.Kit}' to {npc.RnName} ({profileName}): {given}");
npc.EquipWeapon();
_npcs[npc.NetId] = npc;
Index(npc, true);
Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner);
if (p.Boss != null)
BossSpawned(npc);
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;
Index(npc, false);
if (npc.IsBoss)
ClearBar(npc);
if (_addsOf.Count > 0)
foreach (ulong boss in _addsOf.Keys.ToList())
if (_addsOf[boss].Remove(npc) && _addsOf[boss].Count == 0)
_addsOf.Remove(boss);
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);
if (npc.IsBoss)
BossDied(npc, info, contributors);
}
private void NoteRegrounded(RunicNpcPlayer npc, Vector3 from, Vector3 to)
{
string text = $"its ground went from under {npc.RnName} ({from.y - to.y:0.0} m), who now stands on the nearest navmesh until it respawns";
PlacementState ps;
if (npc.PlacementId != null && _placements.TryGetValue(npc.PlacementId, out ps))
ps.Note = text;
Puts($"{npc.Owner}: {text}.");
}
private void CheckThresholds(RunicNpcPlayer npc)
{
float fraction = npc.health / npc.MaxHealth();
foreach (float t in npc.Profile.HealthThresholds.OrderByDescending(x => x))
if (fraction <= t && npc.ThresholdsFired.Add(t))
Interface.CallHook("OnRunicNpcHealth", npc, npc.ProfileName, t);
}
private Route RouteFor(RunicNpcPlayer npc)
{
Route route;
return npc.RouteName != null && _routeFile.Routes.TryGetValue(npc.RouteName, out route) && route.Points.Count > 0 ? route : null;
}
#endregion
#region Placements
private void RebuildPlacements()
{
foreach (PlacementState ps in _placements.Values)
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
_placements.Clear();
_queue.Clear();
foreach (KeyValuePair<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";
// D272: a zone that has gone holds its placement back, as a missing route does, rather than
// letting NPCs that were meant to stay inside it roam the map.
if (data.Tether != null && (ZoneManager == null || ZoneManager.Call("CheckZoneID", data.Tether) == null))
return ZoneManager == null ? $"its zone '{data.Tether}' needs ZoneManager, which is not loaded" : $"its zone '{data.Tether}' is missing";
return null;
}
/// <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 || _holdForKits)
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)
{
// A scattered spot that leaves a small floor takes the placement's own spot instead.
Vector2 offset = UnityEngine.Random.insideUnitCircle * Scatter;
at = spot + new Vector3(offset.x, 0f, offset.y);
if (!Still(p, data.Movement) && OffMeshReason(at) != null)
at = spot;
}
// D239: the spot was on the navmesh when it was placed. If the world has changed under it (a
// player-built floor destroyed), the NPC stands on the nearest navmesh until the spot returns.
string off = Still(p, data.Movement) ? null : OffMeshReason(at);
NavMeshHit nearest;
if (off != null && SampleNavmesh(at, Fallback, out nearest))
{
string note = $"its spot is off the navmesh now ({off}), so its NPCs stand on the nearest, {Meters(at, nearest.position)} away";
if (ps.Note != note)
PrintWarning($"Placement '{ps.Id}': {note}.");
ps.Note = note;
at = nearest.position;
}
else if (off == null && slot == 0)
ps.Note = null;
string error;
RunicNpcPlayer npc = Spawn(data.Profile, p, at, data.Yaw, "placement:" + ps.Id, data.Movement, ps.Id, slot, out error);
if (npc == null)
{
if (ps.LastError != error)
PrintWarning($"Placement '{ps.Id}' could not spawn: {error}. It retries after its respawn delay.");
ps.LastError = error;
ps.Slots[slot].DiedAt = Time.realtimeSinceStartup;
return;
}
ps.LastError = null;
ps.Slots[slot].Npc = npc;
npc.Tether = data.Tether;
}
/// <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. Fighting other NPCs (a faction setting) was measured to cost about " +
$"what fighting players does; on a map whose own AI is busy, each then reacts only every " +
$"{Mathf.Max(0.5f, hundreds * 7f):0.#} s.";
}
#endregion
#region Bosses (stage 6: D273–D277, D286, D287)
[PluginReference] private Plugin PopupNotifications;
private const string BarName = "rnpc.bar";
private const string BarFill = "rnpc.bar.fill";
private const string BarText = "rnpc.bar.text";
/// <summary>How far from the boss its adds are placed (D275), before each point is put on the navmesh.</summary>
private const float AddRing = 6f;
/// <summary>The bosses alive now.</summary>
private readonly HashSet<RunicNpcPlayer> _bosses = new HashSet<RunicNpcPlayer>();
/// <summary>
/// Each boss's live adds, by the boss's net id, kept after the boss dies (D287) so that despawning
/// the boss's id removes them too. An add leaves its set when it dies or despawns.
/// </summary>
private readonly Dictionary<ulong, HashSet<RunicNpcPlayer>> _addsOf = new Dictionary<ulong, HashSet<RunicNpcPlayer>>();
/// <summary>Whose bar each player sees, by Steam id: one boss at a time, the nearest that qualifies (D274).</summary>
private readonly Dictionary<ulong, RunicNpcPlayer> _barShown = new Dictionary<ulong, RunicNpcPlayer>();
private int _barTick;
private void BossSpawned(RunicNpcPlayer npc)
{
_bosses.Add(npc);
BossBlock b = npc.Profile.Boss;
if (!string.IsNullOrWhiteSpace(b.SpawnLine))
Say(b, BossText(b.SpawnLine, npc, null), null);
Interface.CallHook("OnRunicNpcBossSpawned", npc, npc.ProfileName, npc.Owner);
}
private void BossDied(RunicNpcPlayer npc, HitInfo info, Dictionary<ulong, float> contributors)
{
BossBlock b = npc.Profile.Boss;
BasePlayer killer = info != null ? info.InitiatorPlayer : null;
if (killer != null && killer.IsNpc)
killer = null;
if (!string.IsNullOrWhiteSpace(b.DeathLine))
Say(b, BossText(b.DeathLine, npc, killer), null);
Interface.CallHook("OnRunicNpcBossDied", npc, npc.ProfileName, npc.Owner, info, contributors);
}
/// <summary>A line's placeholders: <c>{name}</c> is the boss's name, <c>{killer}</c> the killing blow's player.</summary>
private static string BossText(string line, RunicNpcPlayer npc, BasePlayer killer) =>
line.Replace("{name}", npc.RnName).Replace("{killer}", killer != null ? killer.displayName : "");
/// <summary>
/// D277, D286: a line said in game, to everyone (<paramref name="to"/> null) or to the players
/// given. A popup where the profile asks for one and PopupNotifications is loaded; chat otherwise.
/// </summary>
private void Say(BossBlock b, string text, IEnumerable<BasePlayer> to)
{
bool popup = b.Announce == "popup" && PopupNotifications != null && PopupNotifications.IsLoaded;
if (to == null)
{
if (popup)
PopupNotifications.Call("CreatePopupNotification", text, null, 0f);
else
PrintToChat(text);
return;
}
foreach (BasePlayer p in to)
{
if (p == null || !p.IsConnected)
continue;
if (popup)
PopupNotifications.Call("CreatePopupNotification", text, p, 0f);
else
p.ChatMessage(text);
}
}
/// <summary>D286: the players a phase line reaches: within the bar's distance, or who have hurt it.</summary>
private static List<BasePlayer> Near(RunicNpcPlayer npc)
{
float r = npc.Profile.Boss.BarDistance;
Vector3 at = npc.transform.position;
return BasePlayer.activePlayerList
.Where(p => p != null && !p.IsNpc && ((p.transform.position - at).sqrMagnitude <= r * r || npc.Contributors.ContainsKey((ulong)p.userID)))
.ToList();
}
/// <summary>Called from the boss's Hurt: enters every phase its health has fallen to, highest first.</summary>
private void CheckPhases(RunicNpcPlayer npc, ulong attackerId)
{
float fraction = npc.health / npc.MaxHealth();
foreach (Phase ph in npc.Profile.Boss.Phases.OrderByDescending(x => x.At))
if (fraction <= ph.At && npc.PhasesFired.Add(ph.At))
EnterPhase(npc, ph);
}
/// <summary>D275: a phase changes the boss's own copy of its profile (Spawn made it), never the shared one.</summary>
private void EnterPhase(RunicNpcPlayer npc, Phase ph)
{
npc.Phase = npc.PhasesFired.Count;
Profile p = npc.Profile;
RunicNpcBrain brain = npc.RnBrain;
if (ph.DamageDealt.HasValue)
{
p.DamageDealt = ph.DamageDealt.Value;
npc.damageScale = p.DamageDealt;
}
if (ph.AimCone.HasValue)
{
p.AimCone = ph.AimCone.Value;
npc.aimConeScale = p.AimCone;
}
if (ph.Ranges != null)
{
p.Ranges = new Ranges { Sense = ph.Ranges.Sense, LoseTarget = ph.Ranges.LoseTarget, Chase = ph.Ranges.Chase, Attack = ph.Ranges.Attack };
if (brain != null)
{
brain.SenseRange = p.Ranges.Sense;
brain.TargetLostRange = p.Ranges.LoseTarget;
}
}
// Rust sets the agent's speed from the navigator's on every move, so this holds.
if (ph.Speed.HasValue && brain?.Navigator != null)
{
if (npc.BaseSpeed < 0f)
npc.BaseSpeed = brain.Navigator.Speed;
brain.Navigator.Speed = npc.BaseSpeed * ph.Speed.Value;
}
// The spike (finding 3): strip, give, equip. Without EquipWeapon it holds nothing.
if (ph.Kit != null)
{
npc.inventory.Strip();
object given = Kits?.Call("GiveKit", npc, ph.Kit);
if (given is string)
PrintWarning($"Kits did not give '{ph.Kit}' to {npc.RnName} ({npc.ProfileName}) in phase {npc.Phase}: {given}");
else
npc.Kit = ph.Kit;
npc.EquipWeapon();
}
if (ph.Adds != null)
SummonAdds(npc, ph.Adds);
if (!string.IsNullOrWhiteSpace(ph.Line))
Say(p.Boss, BossText(ph.Line, npc, null), Near(npc));
Interface.CallHook("OnRunicNpcBossPhase", npc, npc.ProfileName, npc.Owner, npc.Phase, ph.At);
}
/// <summary>
/// D275, D287: adds of another profile on a ring around the boss, one per server frame (the
/// spike measured 4–8 ms each). They belong to the boss's owner, so an event's teardown removes
/// them, and they fight on after the boss dies. Each learns the boss's attackers nearby.
/// </summary>
private void SummonAdds(RunicNpcPlayer boss, Adds adds)
{
Profile p;
if (!_profiles.TryGetValue(adds.Profile, out p))
{
PrintWarning($"{boss.RnName} ({boss.ProfileName}) cannot summon '{adds.Profile}': " +
(_refused.ContainsKey(adds.Profile) ? "it is refused: " + _refused[adds.Profile] : "there is no such profile"));
return;
}
Vector3 centre = boss.transform.position;
string owner = boss.Owner;
ulong bossId = boss.NetId;
var attackers = boss.Contributors.Keys.ToList();
float turn = UnityEngine.Random.value * Mathf.PI * 2f;
int i = 0;
Action next = null;
next = () =>
{
if (_unloading || i >= adds.Count)
return;
float a = turn + i * Mathf.PI * 2f / adds.Count;
Vector3 ring = centre + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * AddRing;
string error;
NavMeshHit hit;
RunicNpcPlayer add = null;
if (SampleNavmesh(ring, 3f, out hit))
add = Spawn(adds.Profile, p, hit.position, UnityEngine.Random.Range(0f, 360f), owner, null, null, -1, out error);
else
error = "no navmesh within 3 m of its spot";
if (add == null)
PrintWarning($"{boss.RnName} ({boss.ProfileName}): add {i + 1} of {adds.Count} ('{adds.Profile}') not placed: {error}");
else
{
HashSet<RunicNpcPlayer> mine;
if (!_addsOf.TryGetValue(bossId, out mine))
_addsOf[bossId] = mine = new HashSet<RunicNpcPlayer>();
mine.Add(add);
foreach (ulong id in attackers)
{
BasePlayer who = BasePlayer.FindByID(id);
if (who != null && who.IsAlive() && Vector3.Distance(who.transform.position, add.transform.position) <= 100f)
Learn(add, who, true);
}
}
i++;
if (i < adds.Count)
NextFrame(next);
};
next();
}
// ---- the bar (D274): the stage 6 spike's shape (finding 2) ----
/// <summary>
/// Four times a second: redraw each hurt boss's bar in place for its viewers. Once a second:
/// work out who sees which bar, adding and removing whole bars.
/// </summary>
private void BarTick()
{
if (_bosses.Count == 0 && _barShown.Count == 0)
return;
if (++_barTick % 4 == 0)
SweepViewers();
foreach (RunicNpcPlayer boss in _bosses)
{
if (!boss.BarDirty || boss.IsDestroyed)
continue;
boss.BarDirty = false;
string json = null;
foreach (KeyValuePair<ulong, RunicNpcPlayer> kv in _barShown)
{
if (kv.Value != boss)
continue;
BasePlayer p = BasePlayer.FindByID(kv.Key);
if (p == null || !p.IsConnected)
continue;
if (json == null)
json = BarJson(boss, true);
CuiHelper.AddUi(p, json);
}
}
}
private void SweepViewers()
{
var want = new Dictionary<ulong, RunicNpcPlayer>();
foreach (BasePlayer p in BasePlayer.activePlayerList)
{
if (p == null || p.IsNpc || !p.IsConnected)
continue;
ulong id = (ulong)p.userID;
RunicNpcPlayer best = null;
float bestD = float.MaxValue;
foreach (RunicNpcPlayer b in _bosses)
{
if (b == null || b.IsDestroyed || b.IsDead())
continue;
float d = (b.transform.position - p.transform.position).sqrMagnitude;
float r = b.Profile.Boss.BarDistance;
if ((d <= r * r || b.Contributors.ContainsKey(id)) && d < bestD)
{
best = b;
bestD = d;
}
}
if (best != null)
want[id] = best;
}
foreach (KeyValuePair<ulong, RunicNpcPlayer> kv in _barShown.ToList())
{
RunicNpcPlayer now;
if (want.TryGetValue(kv.Key, out now) && now == kv.Value)
continue;
BasePlayer p = BasePlayer.FindByID(kv.Key);
if (p != null && p.IsConnected)
CuiHelper.DestroyUi(p, BarName);
_barShown.Remove(kv.Key);
}
foreach (KeyValuePair<ulong, RunicNpcPlayer> kv in want)
{
if (_barShown.ContainsKey(kv.Key))
continue;
BasePlayer p = BasePlayer.FindByID(kv.Key);
if (p == null)
continue;
CuiHelper.AddUi(p, BarJson(kv.Value, false));
_barShown[kv.Key] = kv.Value;
}
}
/// <summary>A boss gone (death, despawn, unload): its bar comes off every screen showing it.</summary>
private void ClearBar(RunicNpcPlayer npc)
{
_bosses.Remove(npc);
foreach (KeyValuePair<ulong, RunicNpcPlayer> kv in _barShown.Where(x => x.Value == npc).ToList())
{
BasePlayer p = BasePlayer.FindByID(kv.Key);
if (p != null && p.IsConnected)
CuiHelper.DestroyUi(p, BarName);
_barShown.Remove(kv.Key);
}
}
/// <summary>
/// The bar: a dark strip at the top of the screen, a red fill and the boss's name and health.
/// A redraw sends only the fill and the text, changed in place with CUI's <c>update</c>.
/// </summary>
private static string BarJson(RunicNpcPlayer boss, bool update)
{
float fraction = Mathf.Clamp01(boss.health / boss.MaxHealth());
string text = $"{boss.RnName} {Mathf.CeilToInt(boss.health)} / {Mathf.CeilToInt(boss.MaxHealth())}";
var c = new CuiElementContainer();
if (!update)
c.Add(new CuiPanel { Image = { Color = "0.1 0.1 0.1 0.75" }, RectTransform = { AnchorMin = "0.35 0.905", AnchorMax = "0.65 0.935" } }, "Hud", BarName);
c.Add(new CuiPanel { Image = { Color = "0.72 0.12 0.1 0.9" }, RectTransform = { AnchorMin = "0 0", AnchorMax = $"{fraction.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)} 1" } }, BarName, BarFill);
c.Add(new CuiLabel { Text = { Text = text, FontSize = 13, Align = TextAnchor.MiddleCenter, Color = "1 1 1 0.95" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, BarName, BarText);
if (update)
foreach (CuiElement e in c)
e.Update = true;
return CuiHelper.ToJson(c);
}
#endregion
#region Passive NPCs and press E (stage 6: D278, D279, D288)
private const string WindowName = "rnpc.window";
/// <summary>How far a player's E reaches: Rust's own use distance is about this.</summary>
private const float UseReach = 3f;
private static int _useMask;
private readonly Dictionary<ulong, float> _nextUse = new Dictionary<ulong, float>();
/// <summary>
/// Neither framework raises a hook for E on an NPC, and Rust ignores it (stage 6 spike, finding
/// 1): so every player tick is checked for E pressed, at about 7 ns, and only then is a ray cast.
/// </summary>
private void OnPlayerInput(BasePlayer player, InputState input)
{
MarkHook("OnPlayerInput");
if (input == null || !input.WasJustPressed(BUTTON.USE) || player == null || player.IsNpc)
return;
if (_useMask == 0)
_useMask = LayerMask.GetMask("Player (Server)", "Construction", "Deployed", "World", "Terrain", "Default", "Tree");
RaycastHit hit;
if (!Physics.Raycast(player.eyes.HeadRay(), out hit, UseReach, _useMask, QueryTriggerInteraction.Ignore))
return;
RunicNpcPlayer npc = hit.GetEntity() as RunicNpcPlayer;
if (npc?.Profile == null || !npc.Profile.IsPassive || npc.Profile.Use == null || npc.IsDead())
return;
ulong id = (ulong)player.userID;
float now = Time.realtimeSinceStartup, next;
if (_nextUse.TryGetValue(id, out next) && now < next)
return;
_nextUse[id] = now + 1f;
Use(npc, player);
}
/// <summary>D278: a chat line picked at random, or a window. <c>{player}</c> in either is the player's name.</summary>
private void Use(RunicNpcPlayer npc, BasePlayer player)
{
UseBlock u = npc.Profile.Use;
if (u.Mode == "chat")
{
string line = u.Lines[UnityEngine.Random.Range(0, u.Lines.Count)].Replace("{player}", player.displayName);
player.ChatMessage($"<color=#e8c170>{npc.RnName}</color>: {line}");
}
else
OpenWindow(player, npc, u);
// One that stands turns to whoever spoke to it.
if (npc.RnBrain != null && npc.RnBrain.Sentry)
npc.SetAimDirection((player.eyes.position - npc.eyes.position).normalized);
Interface.CallHook("OnRunicNpcUsed", npc, npc.ProfileName, player);
}
private static void OpenWindow(BasePlayer player, RunicNpcPlayer npc, UseBlock u)
{
CuiHelper.DestroyUi(player, WindowName);
string title = string.IsNullOrWhiteSpace(u.Title) ? npc.RnName : u.Title;
var c = new CuiElementContainer();
c.Add(new CuiPanel
{
Image = { Color = "0.08 0.08 0.08 0.92" },
RectTransform = { AnchorMin = "0.32 0.3", AnchorMax = "0.68 0.72" },
CursorEnabled = true
}, "Overlay", WindowName);
c.Add(new CuiLabel
{
Text = { Text = title.Replace("{player}", player.displayName), FontSize = 18, Align = TextAnchor.MiddleLeft, Color = "0.91 0.76 0.44 1" },
RectTransform = { AnchorMin = "0.05 0.85", AnchorMax = "0.95 0.96" }
}, WindowName);
c.Add(new CuiLabel
{
Text = { Text = (u.Text ?? "").Replace("{player}", player.displayName), FontSize = 14, Align = TextAnchor.UpperLeft, Color = "0.92 0.92 0.92 1" },
RectTransform = { AnchorMin = "0.05 0.17", AnchorMax = "0.95 0.82" }
}, WindowName);
c.Add(new CuiButton
{
Button = { Close = WindowName, Color = "0.3 0.3 0.3 0.9" },
Text = { Text = "Close", FontSize = 14, Align = TextAnchor.MiddleCenter },
RectTransform = { AnchorMin = "0.4 0.03", AnchorMax = "0.6 0.12" }
}, WindowName);
CuiHelper.AddUi(player, c);
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
MarkHook("OnPlayerDisconnected");
if (player == null)
return;
ulong id = (ulong)player.userID;
_barShown.Remove(id);
_nextUse.Remove(id);
}
#endregion
#region The example profiles (stage 6: D280, D284, D289)
private const string DataState = "RunicNPC/state";
/// <summary>The permission every example kit needs, which nobody is granted: no player can claim one by name (D284).</summary>
private const string ExamplePermission = "kits.runicnpc";
private sealed class StateFile
{
/// <summary>D284: set on the first load that wrote them, and never cleared.</summary>
[JsonProperty("examplesWritten")] public bool ExamplesWritten;
}
/// <summary>Placements wait while Kits reloads with the example kits (D283 reloads RunicNPC with it).</summary>
private bool _holdForKits;
private static JObject ExItem(string shortname, int amount, int position, float condition = 0f) =>
JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = condition, MaxCondition = condition, Ammo = 0, Position = position, Frequency = -1 });
private static JObject ExKit(string name, JObject[] main, JObject[] wear, JObject[] belt) => new JObject
{
["Name"] = name, ["Description"] = "A RunicNPC example kit, for its example NPCs", ["RequiredPermission"] = ExamplePermission,
["MaximumUses"] = 0, ["RequiredAuth"] = 0, ["Cooldown"] = 0, ["Cost"] = 0, ["IsHidden"] = true,
["CopyPasteFile"] = "", ["KitImage"] = "",
["MainItems"] = new JArray(main), ["WearItems"] = new JArray(wear), ["BeltItems"] = new JArray(belt)
};
private static JObject[] Wear(params string[] items) => items.Select((s, i) => ExItem(s, 1, i)).ToArray();
/// <summary>D289: the four kits. A short name this Rust does not know is left out with a warning.</summary>
private static Dictionary<string, JObject> ExampleKits() => new Dictionary<string, JObject>
{
["rnpc_raider"] = ExKit("rnpc_raider", new[] { ExItem("ammo.rifle", 120, 0) },
Wear("hoodie", "pants", "shoes.boots", "mask.bandana"), new[] { ExItem("rifle.semiauto", 1, 0, 100f) }),
["rnpc_campguard"] = ExKit("rnpc_campguard", new[] { ExItem("ammo.pistol", 200, 0) },
Wear("metal.facemask", "metal.plate.torso", "hoodie", "pants", "shoes.boots"), new[] { ExItem("smg.thompson", 1, 0, 100f) }),
["rnpc_sniper"] = ExKit("rnpc_sniper", new[] { ExItem("ammo.rifle", 60, 0) },
Wear("hat.boonie", "hoodie", "pants", "shoes.boots"), new[] { ExItem("rifle.bolt", 1, 0, 100f) }),
["rnpc_juggernaut"] = ExKit("rnpc_juggernaut", new[] { ExItem("ammo.rifle", 300, 0) },
Wear("heavy.plate.helmet", "heavy.plate.jacket", "heavy.plate.pants", "shoes.boots"), new[] { ExItem("lmg.m249", 1, 0, 100f) })
};
private static JObject ExRanges(float sense, float lose, float chase, float attack) =>
JObject.FromObject(new { sense, loseTarget = lose, chase, attack });
/// <summary>D289: the four profiles.</summary>
private static Dictionary<string, JObject> ExampleProfiles() => new Dictionary<string, JObject>
{
["raider"] = new JObject
{
["names"] = new JArray("Raider"), ["kits"] = new JArray("rnpc_raider"), ["role"] = "roamer",
["movement"] = JObject.FromObject(new { mode = "wander", radius = 40f }), ["health"] = 150f,
["ranges"] = ExRanges(30f, 40f, 40f, 30f)
},
["campguard"] = new JObject
{
["names"] = new JArray("Camp Guard"), ["kits"] = new JArray("rnpc_campguard"), ["role"] = "guard",
["movement"] = JObject.FromObject(new { mode = "wander", radius = 10f }), ["health"] = 200f,
["ranges"] = ExRanges(35f, 45f, 30f, 30f)
},
["sniper"] = new JObject
{
["names"] = new JArray("Sniper"), ["kits"] = new JArray("rnpc_sniper"), ["role"] = "sentry", ["health"] = 120f,
["aimCone"] = 1f, ["ranges"] = ExRanges(120f, 130f, 0f, 120f)
},
["juggernaut"] = new JObject
{
["names"] = new JArray("Juggernaut"), ["kits"] = new JArray("rnpc_juggernaut"), ["role"] = "roamer",
["movement"] = JObject.FromObject(new { mode = "wander", radius = 30f }), ["health"] = 2500f,
["ranges"] = ExRanges(40f, 50f, 40f, 35f),
["boss"] = JObject.FromObject(new
{
barDistance = 100f,
announce = "chat",
spawnLine = "The Juggernaut has risen!",
deathLine = "The Juggernaut has fallen.",
phases = new object[]
{
new { at = 0.5f, adds = new { profile = "raider", count = 3 }, line = "The Juggernaut calls for help!" },
new { at = 0.25f, damageDealt = 1.5f, speed = 1.3f, line = "The Juggernaut is enraged!" }
}
})
}
};
/// <summary>
/// D284: on the first load only, the four example kits go into Kits' own data file (Kits has no
/// API that creates one, D280) and the four profiles into ours, each only where its name is free.
/// The flag is set before anything is written, so this runs once whatever happens. Kits is then
/// reloaded, which reloads RunicNPC too (D283); placements wait for it.
/// </summary>
private void WriteExamplesOnce()
{
StateFile state = ReadData<StateFile>(DataState);
if (state.ExamplesWritten)
return;
state.ExamplesWritten = true;
Interface.Oxide.DataFileSystem.WriteObject(DataState, state);
var addedKits = new List<string>();
try
{
JObject file = Interface.Oxide.DataFileSystem.ReadObject<JObject>("Kits/kits_data") ?? new JObject();
JObject kits = file["_kits"] as JObject;
if (kits == null)
file["_kits"] = kits = new JObject();
foreach (KeyValuePair<string, JObject> kv in ExampleKits())
{
if (kits.Properties().Any(x => string.Equals(x.Name, kv.Key, StringComparison.OrdinalIgnoreCase)))
continue;
foreach (string list in new[] { "MainItems", "WearItems", "BeltItems" })
{
var known = new JArray();
foreach (JToken item in (JArray)kv.Value[list])
{
if (ItemManager.FindItemDefinition((string)item["Shortname"]) != null)
known.Add(item);
else
PrintWarning($"Example kit {kv.Key}: this Rust has no item '{item["Shortname"]}', so it is left out.");
}
kv.Value[list] = known;
}
kits[kv.Key] = kv.Value;
addedKits.Add(kv.Key);
}
if (addedKits.Count > 0)
Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file);
}
catch (Exception e)
{
PrintError($"The example kits could not be written into Kits' data file: {e.Message}");
addedKits.Clear();
}
var addedProfiles = new List<string>();
if (!_profileFile.Managed)
{
foreach (KeyValuePair<string, JObject> kv in ExampleProfiles())
{
if (_profileFile.Profiles.ContainsKey(kv.Key))
continue;
_profileFile.Profiles[kv.Key] = kv.Value.ToObject<Profile>();
addedProfiles.Add(kv.Key);
}
if (addedProfiles.Count > 0)
SaveProfiles();
}
Puts($"First load: example kits written into Kits' data file: {(addedKits.Count > 0 ? string.Join(", ", addedKits.ToArray()) : "none (their names are taken)")}; " +
$"example profiles: {(addedProfiles.Count > 0 ? string.Join(", ", addedProfiles.ToArray()) : _profileFile.Managed ? "none (a site manages this server's profiles)" : "none (their names are taken)")}. " +
"They are never written again, even if you delete them.");
if (addedKits.Count == 0)
return;
// Kits reads its file only when it loads (stage 6 spike, finding 5). The reload takes RunicNPC down and
// back with it (D283); if this framework does not, OnPluginLoaded("Kits") picks the profiles up.
_holdForKits = true;
timer.Once(60f, () => _holdForKits = false);
string command = Framework() == "carbon" ? "c.reload" : "oxide.reload";
timer.Once(1f, () => ConsoleSystem.Run(ConsoleSystem.Option.Server, command, "Kits"));
}
#endregion
#region Lifecycle and hooks
private void Init()
{
_self = this;
foreach (string hook in ExpectedHooks)
_hookCounts[hook] = 0L;
RegisterVerbs();
}
private void OnServerInitialized()
{
MarkHook("OnServerInitialized");
ResolveFields();
LoadData();
WriteExamplesOnce();
RevalidateProfiles();
RebuildPlacements();
TrackWorld();
PatchSentries();
_ready = true;
timer.Every(1f, TickPlacements);
timer.Every(0.25f, BarTick);
Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " +
$"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}");
TickPlacements();
}
private void OnServerSave()
{
MarkHook("OnServerSave");
if (_placementsDirty)
SavePlacements();
}
private void OnEntityKill(BaseNetworkable entity)
{
MarkHook("OnEntityKill");
RunicNpcPlayer npc = entity as RunicNpcPlayer;
if (npc != null)
Unregister(npc);
else
Untrack(entity);
}
/// <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.");
}
/// <summary>
/// Kits came back (a reload, or the example kits' own, D284): the profiles' kits are checked again
/// and held placements go on. Where the framework reloads RunicNPC with Kits (D283), this is moot.
/// </summary>
private void OnPluginLoaded(Plugin plugin)
{
MarkHook("OnPluginLoaded");
if (plugin == null || plugin.Name != "Kits" || !_ready)
return;
_holdForKits = false;
RevalidateProfiles();
TickPlacements();
}
private void Unload()
{
_unloading = true;
foreach (BasePlayer p in BasePlayer.activePlayerList)
{
if (p == null || !p.IsConnected)
continue;
CuiHelper.DestroyUi(p, BarName);
CuiHelper.DestroyUi(p, WindowName);
}
_barShown.Clear();
UnpatchSentries();
Despawn(_npcs.Values);
_npcs.Clear();
if (_placementsDirty)
SavePlacements();
_self = null;
}
private void MarkHook(string name)
{
long count;
_hookCounts.TryGetValue(name, out count);
_hookCounts[name] = count + 1;
}
#endregion
#region The API (PLAN.md §4, docs/runicnpc/API.md)
// Every call is private: Oxide's Call reaches a non-public method by name, and a public one only
// with [HookMethod] (the PR check holds this). Each returns a value or null and says why in the log.
private int RunicNpc_ApiVersion() => ApiVersion;
private BasePlayer RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)
{
string error = null;
RunicNpcPlayer npc = null;
Profile p;
if (owner == null || !OwnerRule.IsMatch(owner))
error = "owner must be run:<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;
}
/// <summary>
/// Removes one NPC, and for a boss the adds it summoned, alive or not (D287): an event's teardown
/// removes what its step placed by net id, and a boss's adds are in no step's ledger. The boss may
/// already be dead; its adds are still found by its net id.
/// </summary>
private int RunicNpc_Despawn(ulong netId)
{
var gone = new List<RunicNpcPlayer>();
RunicNpcPlayer npc;
if (_npcs.TryGetValue(netId, out npc))
gone.Add(npc);
HashSet<RunicNpcPlayer> adds;
if (_addsOf.TryGetValue(netId, out adds))
{
gone.AddRange(adds);
_addsOf.Remove(netId);
}
return Despawn(gone);
}
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,
["regrounded"] = brain != null ? brain.Regrounded : 0,
["faction"] = n.Profile != null ? FactionOf(n) : null,
["target"] = n.NpcTarget != null && !n.NpcTarget.IsDestroyed ? n.NpcTarget.ShortPrefabName : null,
["engaged"] = n.Engaged,
["escort"] = n.Escort != null && !n.Escort.IsDestroyed ? (n.Escort is BasePlayer ? ((BasePlayer)n.Escort).UserIDString : n.Escort.ShortPrefabName) : null,
["ally"] = n.AllyClan != 0L ? "clan:" + n.AllyClan : n.AllyTeam != 0UL ? "team:" + n.AllyTeam : n.AllyPlayer != 0UL ? "player:" + n.AllyPlayer : null,
["tether"] = n.Tether,
["shotsMarked"] = n.ShotsMarked,
["role"] = n.Profile?.Role,
["boss"] = n.IsBoss ? new Dictionary<string, object> { ["phase"] = n.Phase, ["damagers"] = n.Contributors.Count } : null,
["kitUsed"] = new Dictionary<string, object> { ["heals"] = n.Heals, ["throws"] = n.Throws, ["rockets"] = n.Rockets, ["meleeHits"] = n.MeleeHits }
};
}
private JObject RunicNpc_Profiles()
{
JObject all = JObject.FromObject(_profileFile);
all["refused"] = JObject.FromObject(_refused);
return all;
}
/// <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
{
if (all != null)
foreach (JProperty p in all.Properties())
NormalizePassive(p.Value as JObject);
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,
["note"] = ps.Note
}).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;
}
return SetPlacement(id, data);
}
/// <summary>
/// Adds or replaces a placement, for the API and <c>rnpc place</c> alike. A roamer's spot must
/// be on Rust's navmesh when it is set (D219); if the world changes under it later, its NPCs
/// stand on the nearest navmesh instead (D239, <see cref="SpawnSlot"/>).
/// </summary>
private string SetPlacement(string id, Placement data)
{
string error = ValidatePlacement(data);
if (error != null)
return error;
Profile profile;
if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !Still(profile, data.Movement))
{
string off = OffMeshReason(data.Position.V);
if (off != null)
return $"position: {off}, and only a sentry may stand off Rust's navmesh (D219)";
}
error = TetherRefusal(data.Tether, data.Position.V);
if (error != null)
return error;
PlacementState old;
if (_placements.TryGetValue(id, out old))
Despawn(old.Slots.Select(s => s.Npc).Where(n => n != null));
_placementFile.Placements[id] = data;
_placements[id] = NewState(id, data);
_placementsDirty = true;
Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}");
TickPlacements();
PlacementChanged(id, "set");
return null;
}
private bool RunicNpc_RemovePlacement(string id)
{
PlacementState ps;
if (id == null || !_placements.TryGetValue(id, out ps))
return false;
_placements.Remove(id);
_placementFile.Placements.Remove(id);
_placementsDirty = true;
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
PlacementChanged(id, "removed");
return true;
}
/// <summary>
/// API 3 (D249): creates a placement and names it as <c>rnpc place</c> does (D241, D246). A
/// position without <c>y</c> is a point on a map: it is put on the ground there (terrain and
/// rock, never a building, D245) and then checked against the navmesh like any other.
/// Answers <c>{id, position, built, cost}</c>, or <c>{error}</c>.
/// </summary>
private Dictionary<string, object> RunicNpc_AddPlacement(JObject placement)
{
Placement data;
try
{
data = placement?.ToObject<Placement>();
}
catch (Exception e)
{
return new Dictionary<string, object> { ["error"] = "the placement could not be read: " + e.Message };
}
if (data != null && data.Position != null && placement["position"]?["y"] == null)
{
Vector3 ground;
string off = GroundAt(data.Position.X, data.Position.Z, out ground);
if (off != null)
return new Dictionary<string, object> { ["error"] = off };
data.Position = Pos.Of(ground);
}
string id;
bool built;
string error = CreatePlacement(data, out id, out built);
if (error != null)
{
PrintWarning($"RunicNpc_AddPlacement('{data?.Profile}') refused: {error}");
return new Dictionary<string, object> { ["error"] = error };
}
return new Dictionary<string, object>
{
["id"] = id,
["position"] = new Dictionary<string, object> { ["x"] = data.Position.X, ["y"] = data.Position.Y, ["z"] = data.Position.Z },
["built"] = built,
["cost"] = CostWarning(PlannedTotal())
};
}
/// <summary>API 3 (D249): renames a placement, as <c>rnpc rename</c> does. Null on success.</summary>
private string RunicNpc_RenamePlacement(string from, string to) => from == null || to == null ? "Name both placements." : RenamePlacement(from, to);
/// <summary>API 3 (D249): respawns a placement's NPCs now, as <c>rnpc respawn</c>. How many, or -1 if there is no such placement.</summary>
private int RunicNpc_RespawnPlacement(string id)
{
PlacementState ps;
if (id == null || !_placements.TryGetValue(id, out ps))
return -1;
int n = RespawnNow(ps);
TickPlacements();
return n;
}
/// <summary>
/// The ground under a point of the map: terrain or rock, never anything built or a tree (D245:
/// a roof is an in-game placement). Null with the spot, or the reason there is none.
/// </summary>
private static string GroundAt(float x, float z, out Vector3 ground)
{
ground = Vector3.zero;
float half = World.Size / 2f;
if (float.IsNaN(x) || float.IsNaN(z) || Mathf.Abs(x) > half || Mathf.Abs(z) > half)
return $"({x:0}, {z:0}) is off the map";
RaycastHit hit;
if (Physics.Raycast(new Vector3(x, 1000f, z), Vector3.down, out hit, 2000f, MapGroundMask, QueryTriggerInteraction.Ignore))
ground = hit.point;
else
ground = new Vector3(x, TerrainMeta.HeightMap.GetHeight(new Vector3(x, 0f, z)), z);
if (TerrainMeta.WaterMap != null && TerrainMeta.WaterMap.GetHeight(ground) > ground.y + 0.5f)
return $"({x:0}, {z:0}) is under water";
return null;
}
/// <summary>
/// API 3 (D249): raised whenever a placement is set, removed or renamed, from the API or in
/// game alike. <c>change</c> is <c>set</c>, <c>removed</c> or <c>renamed</c> (with <c>previous</c>).
/// </summary>
private static void PlacementChanged(string id, string change, string previous = null) =>
Interface.CallHook("OnRunicNpcPlacementChanged", id, change, previous);
private JObject RunicNpc_Routes() => JObject.FromObject(_routeFile.Routes);
/// <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;
}
return SetRoute(name, data);
}
private string SetRoute(string name, Route data)
{
string error = ValidateRoute(data);
if (error != null)
return error;
_routeFile.Routes[name] = data;
SaveRoutes();
TickPlacements();
return null;
}
private bool RunicNpc_RemoveRoute(string name)
{
if (name == null || !_routeFile.Routes.Remove(name))
return false;
SaveRoutes();
return true;
}
private bool RunicNpc_IsRunicNpc(BaseEntity entity) => entity is RunicNpcPlayer;
private string RunicNpc_ProfileOf(BaseEntity entity) => (entity as RunicNpcPlayer)?.ProfileName;
/// <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));
// ---- API 4 (stage 5) ----
/// <summary>API 4 (D254): the faction table, one row per pair, both ways (D268).</summary>
private JArray RunicNpc_Factions() => JArray.FromObject(_profileFile.Factions);
/// <summary>
/// API 4 (D254): replaces the faction table, as a site does with every profile push, and marks
/// the server managed (D221). Null on success; otherwise why not, and nothing changes.
/// </summary>
private string RunicNpc_SetFactions(JArray pairs)
{
List<FactionPair> table;
try
{
table = pairs != null ? pairs.ToObject<List<FactionPair>>() : new List<FactionPair>();
}
catch (Exception e)
{
return "the faction table could not be read: " + e.Message;
}
string error = ValidateFactions(table);
if (error != null)
return error;
_profileFile.Factions = table;
_profileFile.Managed = true;
SaveProfiles();
RevalidateProfiles();
return null;
}
/// <summary>
/// API 4 (D258, D269): one of ours escorts an entity, a player or anything else; null stops it.
/// Null on success, else why not.
/// </summary>
private string RunicNpc_Escort(ulong netId, BaseEntity target)
{
RunicNpcPlayer npc;
if (!_npcs.TryGetValue(netId, out npc))
return $"there is no RunicNPC NPC {netId}";
return SetEscort(npc, target);
}
/// <summary>
/// API 4 (D257, D270): one of ours is allied to a clan (<c>clan</c>, the game's clan id), a team
/// (<c>team</c>), or a player and that player's team (<c>player</c>, a Steam id), or to nobody
/// (<c>none</c>). Null on success, else why not.
/// </summary>
private string RunicNpc_Ally(ulong netId, string kind, string id)
{
RunicNpcPlayer npc;
if (!_npcs.TryGetValue(netId, out npc))
return $"there is no RunicNPC NPC {netId}";
return SetAlly(npc, kind, id);
}
/// <summary>API 4 (D272): holds one of ours inside a ZoneManager zone, or frees it with null.</summary>
private string RunicNpc_Tether(ulong netId, string zone)
{
RunicNpcPlayer npc;
if (!_npcs.TryGetValue(netId, out npc))
return $"there is no RunicNPC NPC {netId}";
if (zone != null && npc.Profile.IsSentry)
return $"{npc.RnName} is a sentry and never moves, so it needs no zone";
string error = TetherRefusal(zone, npc.transform.position);
if (error != null)
return error;
npc.Tether = zone;
return null;
}
private string SetAlly(RunicNpcPlayer npc, string kind, string id)
{
long clan = 0L;
ulong team = 0UL, player = 0UL;
switch (kind)
{
case "none":
case null:
break;
case "clan":
if (!long.TryParse(id, out clan) || clan == 0L)
return "a clan is the game's clan id";
if (ReadClan(clan) == null)
return $"this server has no clan {id}";
break;
case "team":
if (!ulong.TryParse(id, out team) || team == 0UL || RelationshipManager.ServerInstance?.FindTeam(team) == null)
return $"this server has no team {id}";
break;
case "player":
if (!ulong.TryParse(id, out player) || player == 0UL)
return "a player is a Steam id";
BasePlayer p = BasePlayer.FindAwakeOrSleepingByID(player);
if (p != null && p.currentTeam != 0UL)
team = p.currentTeam;
break;
default:
return $"'{kind}' is not clan, team, player or none";
}
npc.AllyClan = clan;
npc.AllyTeam = team;
npc.AllyPlayer = player;
NoteDefender(npc);
return null;
}
#endregion
#region In game: the commands (PLAN.md §5, stage 3)
// `/rnpc <verb>` in chat and `rnpc.<verb>` in a console run the same code. Each verb sits behind a
// permission; the server console has them all. `runicnpc.admin` includes `runicnpc.place`.
private const string PermPlace = "runicnpc.place";
private const string PermAdmin = "runicnpc.admin";
/// <summary>How far an admin's look reaches, for place, info and remove.</summary>
private const float LookReach = 100f;
/// <summary>How far below or around a spot the navmesh is looked for when saying why it is off it.</summary>
private const float Fallback = 20f;
private static readonly int GroundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default", "Tree");
private static readonly int BuiltMask = LayerMask.GetMask("Construction", "Deployed");
/// <summary>What a map point is put down on (D245): terrain and rock, never a building or a tree.</summary>
private static readonly int MapGroundMask = LayerMask.GetMask("Terrain", "World");
/// <summary>Who ran a command, and where the answer goes.</summary>
private sealed class Caller
{
/// <summary>Null for the server console.</summary>
public BasePlayer Player;
public bool Chat;
public readonly List<string> Lines = new List<string>();
public string Key => Player != null ? Player.UserIDString : "server";
public void Say(string line) => Lines.Add(line);
}
private sealed class Verb
{
public string Permission;
public string Usage;
public Action<Caller, string[]> Run;
public bool ConsoleOnly;
}
private Dictionary<string, Verb> _verbs;
/// <summary>A route being recorded, per admin (D240). Kept in memory only: a reload discards it.</summary>
private sealed class Recording
{
public string Name;
public readonly List<Vector3> Points = new List<Vector3>();
}
private readonly Dictionary<string, Recording> _recordings = new Dictionary<string, Recording>();
private void RegisterVerbs()
{
permission.RegisterPermission(PermPlace, this);
permission.RegisterPermission(PermAdmin, this);
_verbs = new Dictionary<string, Verb>
{
["place"] = new Verb { Permission = PermPlace, Usage = "place <profile> [count= respawn= mode=each|group move=wander|monument|route:<name> radius=]", Run = (c, a) => CmdPlace(c, a, false) },
["here"] = new Verb { Permission = PermPlace, Usage = "here <profile> [the same options]", Run = (c, a) => CmdPlace(c, a, true) },
["remove"] = new Verb { Permission = PermPlace, Usage = "remove [placement]", Run = CmdRemove },
["rename"] = new Verb { Permission = PermPlace, Usage = "rename <placement> <new name>", Run = CmdRename },
["near"] = new Verb { Permission = PermPlace, Usage = "near [radius]", Run = CmdNear },
["info"] = new Verb { Permission = PermPlace, Usage = "info", Run = CmdInfo },
["profiles"] = new Verb { Permission = PermPlace, Usage = "profiles", Run = CmdProfiles },
["path"] = new Verb { Permission = PermPlace, Usage = "path record <name> | point | undo | save [loop|back] | cancel | list | delete <name>", Run = CmdPath },
["tp"] = new Verb { Permission = PermAdmin, Usage = "tp <placement>", Run = CmdTp },
["respawn"] = new Verb { Permission = PermAdmin, Usage = "respawn <placement|all>", Run = CmdRespawn },
["clear"] = new Verb { Permission = PermAdmin, Usage = "clear <owner>", Run = CmdClear },
["profile"] = new Verb { Permission = PermAdmin, Usage = "profile list | show <name> | create <name> [from=<profile>] | set <name> <field> <value> | delete <name>", Run = CmdProfile, ConsoleOnly = true },
["follow"] = new Verb { Permission = PermPlace, Usage = "follow <placement> <player|me|off>", Run = CmdFollow },
["faction"] = new Verb { Permission = PermAdmin, Usage = "faction list | set <a> <b> hostile|neutral|allied | clear <a> <b>", Run = CmdFaction, ConsoleOnly = true }
};
}
private bool Allowed(Caller c, string perm) =>
c.Player == null || permission.UserHasPermission(c.Player.UserIDString, PermAdmin) ||
(perm == PermPlace && permission.UserHasPermission(c.Player.UserIDString, PermPlace));
private void Dispatch(Caller c, string verb, string[] args)
{
Verb v;
if (verb == null || verb == "help" || !_verbs.TryGetValue(verb, out v))
{
if (verb != null && verb != "help")
c.Say($"There is no '{verb}'.");
string prefix = c.Chat ? "/rnpc " : "rnpc.";
List<string> mine = _verbs.Where(kv => Allowed(c, kv.Value.Permission) && !(c.Chat && kv.Value.ConsoleOnly))
.Select(kv => prefix + kv.Value.Usage).ToList();
if (mine.Count == 0)
c.Say("You have no RunicNPC permissions.");
else
c.Lines.AddRange(mine);
return;
}
if (!Allowed(c, v.Permission))
{
c.Say($"You need the {v.Permission} permission.");
return;
}
if (c.Chat && v.ConsoleOnly)
{
c.Say($"'{verb}' is a console command: open the console (F1) and type rnpc.{verb}.");
return;
}
if (!_ready)
{
c.Say("RunicNPC is still starting.");
return;
}
try
{
v.Run(c, args);
}
catch (Exception e)
{
c.Say($"'{verb}' failed: {e.Message}");
PrintError($"rnpc {verb} {string.Join(" ", args)}: {e}");
}
}
[ChatCommand("rnpc")]
private void CmdChat(BasePlayer player, string command, string[] args)
{
var c = new Caller { Player = player, Chat = true };
Dispatch(c, args.Length > 0 ? args[0].ToLowerInvariant() : null, args.Skip(1).ToArray());
if (c.Lines.Count > 0)
player.ChatMessage(string.Join("\n", c.Lines.ToArray()));
}
private void RunConsole(ConsoleSystem.Arg arg, string verb)
{
BasePlayer player = arg.Player();
if (arg.Connection != null && player == null)
return;
var c = new Caller { Player = player };
// Rust's Args element type has changed between versions (string, then StringView), so
// read each through GetString, which both have.
int count = arg.Args?.Length ?? 0;
Dispatch(c, verb, Enumerable.Range(0, count).Select(i => arg.GetString(i)).ToArray());
arg.ReplyWith(string.Join(Environment.NewLine, c.Lines.ToArray()));
}
[ConsoleCommand("rnpc.place")] private void CcPlace(ConsoleSystem.Arg arg) => RunConsole(arg, "place");
[ConsoleCommand("rnpc.here")] private void CcHere(ConsoleSystem.Arg arg) => RunConsole(arg, "here");
[ConsoleCommand("rnpc.remove")] private void CcRemove(ConsoleSystem.Arg arg) => RunConsole(arg, "remove");
[ConsoleCommand("rnpc.rename")] private void CcRename(ConsoleSystem.Arg arg) => RunConsole(arg, "rename");
[ConsoleCommand("rnpc.near")] private void CcNear(ConsoleSystem.Arg arg) => RunConsole(arg, "near");
[ConsoleCommand("rnpc.info")] private void CcInfo(ConsoleSystem.Arg arg) => RunConsole(arg, "info");
[ConsoleCommand("rnpc.profiles")] private void CcProfiles(ConsoleSystem.Arg arg) => RunConsole(arg, "profiles");
[ConsoleCommand("rnpc.path")] private void CcPath(ConsoleSystem.Arg arg) => RunConsole(arg, "path");
[ConsoleCommand("rnpc.tp")] private void CcTp(ConsoleSystem.Arg arg) => RunConsole(arg, "tp");
[ConsoleCommand("rnpc.respawn")] private void CcRespawn(ConsoleSystem.Arg arg) => RunConsole(arg, "respawn");
[ConsoleCommand("rnpc.clear")] private void CcClear(ConsoleSystem.Arg arg) => RunConsole(arg, "clear");
[ConsoleCommand("rnpc.profile")] private void CcProfile(ConsoleSystem.Arg arg) => RunConsole(arg, "profile");
[ConsoleCommand("rnpc.help")] private void CcHelp(ConsoleSystem.Arg arg) => RunConsole(arg, "help");
[ConsoleCommand("rnpc.follow")] private void CcFollow(ConsoleSystem.Arg arg) => RunConsole(arg, "follow");
[ConsoleCommand("rnpc.faction")] private void CcFaction(ConsoleSystem.Arg arg) => RunConsole(arg, "faction");
// ---- where an admin stands and looks ----
/// <summary>The ground, a floor or a roof the admin is looking at, within <see cref="LookReach"/>.</summary>
private static bool LookSpot(BasePlayer player, out Vector3 spot)
{
RaycastHit hit;
if (Physics.Raycast(player.eyes.HeadRay(), out hit, LookReach, GroundMask, QueryTriggerInteraction.Ignore))
{
spot = hit.point;
return true;
}
spot = Vector3.zero;
return false;
}
/// <summary>
/// Our NPC the admin is looking at: the nearest whose body is within a metre of the line of
/// sight. It reads positions, not colliders, so it finds an NPC behind a bush too.
/// </summary>
private RunicNpcPlayer LookNpc(BasePlayer player)
{
Ray ray = player.eyes.HeadRay();
RunicNpcPlayer best = null;
float bestAlong = float.MaxValue;
foreach (RunicNpcPlayer npc in _npcs.Values)
{
if (npc == null || npc.IsDestroyed)
continue;
Vector3 body = npc.transform.position + Vector3.up * 1f;
Vector3 to = body - ray.origin;
float along = Vector3.Dot(to, ray.direction);
if (along <= 0f || along > LookReach)
continue;
float off = (to - ray.direction * along).magnitude;
if (off <= 1.2f && along < bestAlong)
{
best = npc;
bestAlong = along;
}
}
return best;
}
/// <summary>Null when a roamer may stand here (on Rust's navmesh, within 2 m up or down); otherwise why not.</summary>
private static string OffMeshReason(Vector3 spot)
{
NavMeshHit hit;
if (SampleNavmesh(spot, Scatter, out hit) && Mathf.Abs(hit.position.y - spot.y) <= MaxDrop)
return null;
if (!SampleNavmesh(spot, Fallback, out hit))
{
float above = spot.y - TerrainMeta.HeightMap.GetHeight(spot);
return above > MaxDrop ? $"it is {above:0.0} m above the ground, with no navmesh within {Fallback:0} m" : $"there is no navmesh within {Fallback:0} m";
}
float dy = spot.y - hit.position.y;
if (dy > MaxDrop) return $"it is {dy:0.0} m above the navmesh";
if (dy < -MaxDrop) return $"it is {-dy:0.0} m below the navmesh";
return $"it is {new Vector2(hit.position.x - spot.x, hit.position.z - spot.z).magnitude:0.0} m from the navmesh";
}
private static Rust.Ai.Gen2.RustNavMeshPath _path;
/// <summary>
/// Whether an NPC can walk from one point to the other on Rust's navmesh. A route leg that
/// fails this leaves its NPC standing at the last point it reached (stage 3: a route recorded
/// up a hill a player walked, and the NPC never left its first point).
/// </summary>
private static bool Walkable(Vector3 from, Vector3 to)
{
if (_path == null)
_path = new Rust.Ai.Gen2.RustNavMeshPath();
return Rust.Ai.Gen2.RustNavMeshHelpers.CalculatePath(from, to, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas, _path) &&
_path.status == NavMeshPathStatus.PathComplete;
}
/// <summary>D239: a spot on something players built, which can be destroyed under the NPC.</summary>
private static bool IsPlayerBuilt(Vector3 spot)
{
RaycastHit hit;
if (!Physics.Raycast(spot + Vector3.up * 0.5f, Vector3.down, out hit, 2.5f, BuiltMask, QueryTriggerInteraction.Ignore))
return false;
BaseEntity entity = hit.GetEntity();
return entity != null && (entity is StabilityEntity || entity.OwnerID != 0UL);
}
private static string Meters(Vector3 a, Vector3 b) => $"{Vector3.Distance(a, b):0} m";
private static bool TryFloat(string s, out float f) =>
float.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out f) && !float.IsNaN(f) && !float.IsInfinity(f);
// ---- place and here (D239, D241, D242) ----
private sealed class PlaceOptions
{
public int? Count;
public float? Respawn;
public string Mode;
public string Move;
public float? Radius;
public Vector3? At;
public float? Yaw;
public string Tether;
}
/// <summary>
/// D242's <c>key=value</c> options, in any order. The server console has no position, so it
/// alone may also give <c>at=x,y,z</c> and <c>yaw=</c>.
/// </summary>
private static string ParseOptions(Caller c, string[] args, int from, out PlaceOptions o)
{
o = new PlaceOptions();
var seen = new HashSet<string>();
for (int i = from; i < args.Length; i++)
{
int eq = args[i].IndexOf('=');
if (eq <= 0)
return $"'{args[i]}' is not key=value. Options: count= respawn= mode= move= radius= tether=";
string key = args[i].Substring(0, eq).ToLowerInvariant();
string value = args[i].Substring(eq + 1);
if (!seen.Add(key))
return $"{key}= is given twice";
float f;
switch (key)
{
case "count":
int n;
if (!int.TryParse(value, out n) || n < 1) return "count= must be a whole number, at least 1";
o.Count = n;
break;
case "respawn":
if (!TryFloat(value, out f) || f < 1f) return "respawn= is in seconds, at least 1";
o.Respawn = f;
break;
case "mode":
if (value != "each" && value != "group") return "mode= is each or group";
o.Mode = value;
break;
case "move":
string kind, route;
if (!ParseMode(value, out kind, out route)) return "move= is wander, monument or route:<name>";
o.Move = value;
break;
case "radius":
if (!TryFloat(value, out f) || f <= 0f) return "radius= is in metres, above 0";
o.Radius = f;
break;
case "at":
string[] xyz = value.Split(',');
float x, y, z;
if (c.Player != null) return "at= is for the server console; in game, look at the spot or stand on it";
if (xyz.Length != 3 || !TryFloat(xyz[0], out x) || !TryFloat(xyz[1], out y) || !TryFloat(xyz[2], out z))
return "at= is x,y,z";
o.At = new Vector3(x, y, z);
break;
case "yaw":
if (c.Player != null) return "yaw= is for the server console";
if (!TryFloat(value, out f)) return "yaw= is in degrees";
o.Yaw = f;
break;
case "tether":
if (!TetherNameRule.IsMatch(value)) return "tether= is a ZoneManager zone id";
o.Tether = value;
break;
default:
return $"there is no option {key}=. Options: count= respawn= mode= move= radius= tether=";
}
}
return null;
}
/// <summary>D241: the profile's name and the first free number.</summary>
private string NextPlacementId(string profile)
{
string stem = profile.Length > 34 ? profile.Substring(0, 34) : profile;
for (int n = 1; ; n++)
{
string id = $"{stem}-{n}";
if (!_placementFile.Placements.ContainsKey(id) && !_placements.ContainsKey(id))
return id;
}
}
/// <summary>
/// Checks, names and sets a NEW placement: <c>rnpc place</c>, <c>rnpc here</c> and
/// <c>RunicNpc_AddPlacement</c> alike, so a placement made from a website is named and refused
/// exactly as one made in game (D241, D246, D249). Null on success, else the sentence to show.
/// </summary>
private string CreatePlacement(Placement data, out string id, out bool built)
{
id = null;
built = false;
Profile p;
if (data == null || data.Profile == null || !_profiles.TryGetValue(data.Profile, out p))
{
string name = data?.Profile;
return name != null && _refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them.";
}
string error = ValidatePlacement(data);
if (error != null)
return "Not placed: " + error;
string kind, route;
ParseMode((data.Movement ?? p.Movement).Mode, out kind, out route);
if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route))
return $"There is no route '{route}'. Record it first: rnpc path record {route}";
if (!NavmeshReady())
return "The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly.";
if (!Still(p, data.Movement))
{
string off = OffMeshReason(data.Position.V);
if (off != null)
return $"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219).";
built = IsPlayerBuilt(data.Position.V);
}
string next = NextPlacementId(data.Profile);
error = SetPlacement(next, data);
if (error != null)
return "Not placed: " + error;
id = next;
return null;
}
private void CmdPlace(Caller c, string[] args, bool here)
{
if (args.Length < 1)
{
c.Say($"Usage: {_verbs[here ? "here" : "place"].Usage}");
return;
}
string name = args[0];
Profile p;
if (!_profiles.TryGetValue(name, out p))
{
c.Say(_refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them.");
return;
}
PlaceOptions o;
string error = ParseOptions(c, args, 1, out o);
if (error != null)
{
c.Say(error);
return;
}
Vector3 spot;
float yaw;
if (o.At.HasValue)
{
spot = o.At.Value;
yaw = o.Yaw ?? 0f;
}
else if (c.Player == null)
{
c.Say("From the server console, give the spot as at=x,y,z.");
return;
}
else if (here)
{
spot = c.Player.transform.position;
yaw = c.Player.eyes.rotation.eulerAngles.y;
}
else
{
if (!LookSpot(c.Player, out spot))
{
c.Say($"Look at the ground, a floor or a roof within {LookReach:0} m.");
return;
}
// It faces the admin who placed it.
Vector3 back = c.Player.transform.position - spot;
yaw = back.sqrMagnitude > 0.01f ? Quaternion.LookRotation(new Vector3(back.x, 0f, back.z)).eulerAngles.y : 0f;
}
Movement movement = null;
if (o.Move != null || o.Radius.HasValue)
{
if (p.IsSentry)
{
c.Say($"'{name}' is a sentry and never moves: drop move= and radius=.");
return;
}
movement = new Movement { Mode = o.Move ?? p.Movement.Mode, Radius = o.Radius ?? p.Movement.Radius };
error = ValidateMovement(movement);
if (error != null)
{
c.Say(error);
return;
}
}
var data = new Placement
{
Profile = name,
Position = Pos.Of(spot),
Yaw = yaw,
Count = o.Count ?? 1,
Respawn = o.Respawn ?? 300f,
RespawnMode = o.Mode ?? "each",
Movement = movement,
Tether = o.Tether
};
if (o.Tether != null && p.IsSentry)
{
c.Say($"'{name}' is a sentry and never moves: drop tether=.");
return;
}
string id;
bool built;
error = CreatePlacement(data, out id, out built);
if (error != null)
{
c.Say(error);
return;
}
string kind, route;
ParseMode((movement ?? p.Movement).Mode, out kind, out route);
Movement m = movement ?? p.Movement;
c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " +
$"respawn {data.Respawn:0} s, {data.RespawnMode}{(data.Tether != null ? $", inside zone {data.Tether}" : "")}. Rename it with rnpc rename {id} <name>.");
if (built)
c.Say("This spot is on a player-built structure. If it is destroyed, the NPC falls back to the nearest navmesh.");
c.Say(CostWarning(PlannedTotal()));
}
// ---- remove, rename, near, info, profiles ----
/// <summary>A placement named in the command, or that of the NPC the admin is looking at.</summary>
private PlacementState NamedOrLooked(Caller c, string[] args, int at)
{
PlacementState ps;
if (args.Length > at)
{
if (_placements.TryGetValue(args[at], out ps))
return ps;
c.Say($"There is no placement '{args[at]}'. rnpc near lists those around you.");
return null;
}
if (c.Player == null)
{
c.Say("Name the placement.");
return null;
}
RunicNpcPlayer npc = LookNpc(c.Player);
if (npc == null)
{
c.Say("Look at one of RunicNPC's NPCs, or name the placement.");
return null;
}
if (npc.PlacementId == null || !_placements.TryGetValue(npc.PlacementId, out ps))
{
c.Say($"{npc.RnName} is not from a placement: it belongs to {npc.Owner}. rnpc clear {npc.Owner} removes that owner's NPCs.");
return null;
}
return ps;
}
private void CmdRemove(Caller c, string[] args)
{
PlacementState ps = NamedOrLooked(c, args, 0);
if (ps == null)
return;
int alive = ps.Slots.Count(s => s.Npc != null);
RunicNpc_RemovePlacement(ps.Id);
c.Say($"Removed {ps.Id} and its {alive} NPC(s). {CostWarning(PlannedTotal())}");
}
private void CmdRename(Caller c, string[] args)
{
if (args.Length != 2)
{
c.Say("Usage: rnpc rename <placement> <new name>");
return;
}
string error = RenamePlacement(args[0], args[1]);
c.Say(error ?? $"Renamed {args[0]} to {args[1]}.");
}
private string RenamePlacement(string from, string to)
{
PlacementState ps;
if (!_placements.TryGetValue(from, out ps))
return $"There is no placement '{from}'.";
if (!NameRule.IsMatch(to))
return "A name is 1–40 of a-z, 0-9, _ and -.";
if (_placements.ContainsKey(to) || _placementFile.Placements.ContainsKey(to))
return $"'{to}' is taken.";
_placements.Remove(from);
_placementFile.Placements.Remove(from);
ps.Id = to;
_placements[to] = ps;
_placementFile.Placements[to] = ps.Data;
foreach (Slot slot in ps.Slots)
{
if (slot.Npc == null)
continue;
slot.Npc.PlacementId = to;
slot.Npc.Owner = "placement:" + to;
}
_placementsDirty = true;
Puts($"Placement '{from}' renamed to '{to}'.");
PlacementChanged(to, "renamed", from);
return null;
}
private void CmdNear(Caller c, string[] args)
{
float radius = 50f;
if (args.Length > 0 && (!TryFloat(args[0], out radius) || radius <= 0f))
{
c.Say("Usage: rnpc near [radius in metres]");
return;
}
if (c.Player == null)
{
c.Say("From the server console there is no 'near': rnpc.status counts everything.");
return;
}
Vector3 here = c.Player.transform.position;
List<PlacementState> places = _placements.Values.Where(ps => Vector3.Distance(ps.Data.Position.V, here) <= radius)
.OrderBy(ps => Vector3.Distance(ps.Data.Position.V, here)).ToList();
List<RunicNpcPlayer> loose = _npcs.Values.Where(n => n.PlacementId == null && Vector3.Distance(n.transform.position, here) <= radius)
.OrderBy(n => Vector3.Distance(n.transform.position, here)).ToList();
if (places.Count == 0 && loose.Count == 0)
{
c.Say($"Nothing of RunicNPC's within {radius:0} m.");
return;
}
foreach (PlacementState ps in places)
{
Placement d = ps.Data;
string move = d.Movement != null ? $" · move {d.Movement.Mode}" : "";
string state = ps.Waiting != null ? $" · waiting: {ps.Waiting}" : ps.LastError != null ? $" · {ps.LastError}" : ps.Note != null ? $" · {ps.Note}" : "";
c.Say($"{ps.Id} · {d.Count} × {d.Profile} ({ps.Slots.Count(s => s.Npc != null)} alive) · {Meters(d.Position.V, here)} · respawn {d.Respawn:0} s {d.RespawnMode}{move}{state}");
}
foreach (RunicNpcPlayer n in loose)
c.Say($"{n.RnName} ({n.ProfileName}) · {n.Owner} · {Meters(n.transform.position, here)}");
}
private void CmdInfo(Caller c, string[] args)
{
if (c.Player == null)
{
c.Say("Info is for the NPC you look at, in game.");
return;
}
RunicNpcPlayer npc = LookNpc(c.Player);
if (npc == null)
{
c.Say("Look at one of RunicNPC's NPCs.");
return;
}
RunicNpcBrain brain = npc.RnBrain;
BaseEntity target = npc.GetBestTarget();
string targetText = target == null ? "none"
: $"{(target is BasePlayer ? ((BasePlayer)target).displayName : target.ShortPrefabName)} {Meters(target.transform.position, npc.transform.position)}";
string move = npc.Profile.IsSentry ? "holds its spot" : npc.MoveKind == "route" ? "route:" + npc.RouteName : npc.MoveKind == "wander" ? $"wander {npc.MoveRadius:0} m" : npc.MoveKind;
c.Say($"{npc.RnName} · profile {npc.ProfileName} ({npc.Profile.Role}) · {(npc.PlacementId != null ? "placement " + npc.PlacementId : npc.Owner)}");
c.Say($"health {npc.health:0}/{npc.MaxHealth():0} · state {(brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none")} · " +
$"{(brain != null ? brain.Rest.ToString().ToLowerInvariant() : "?")} · {move} · kit {npc.Kit}");
c.Say($"target {targetText} · {Meters(npc.transform.position, npc.Home)} from home");
BaseCombatEntity own = npc.NpcTarget;
HashSet<string> hunts = HuntsOf(npc);
c.Say($"faction {FactionOf(npc)} · hunts {(hunts != null ? string.Join(", ", hunts.ToArray()) : "players only")} · " +
$"fighting {(own != null && !own.IsDestroyed ? $"{own.ShortPrefabName} {Meters(own.transform.position, npc.transform.position)}{(npc.Engaged ? "" : " (waits)")}" : "no NPC")}");
var orders = new List<string>();
if (npc.Escort != null && !npc.Escort.IsDestroyed)
orders.Add("escorts " + (npc.Escort is BasePlayer ? ((BasePlayer)npc.Escort).displayName : npc.Escort.ShortPrefabName));
if (npc.HasAlly)
orders.Add("allied to " + (npc.AllyClan != 0L ? "clan " + npc.AllyClan : npc.AllyTeam != 0UL ? "team " + npc.AllyTeam : "player " + npc.AllyPlayer));
if (npc.Tether != null)
orders.Add("held in zone " + npc.Tether);
if (orders.Count > 0)
c.Say(string.Join(" · ", orders.ToArray()));
}
// ---- follow (D258, D269) ----
/// <summary>
/// <c>rnpc follow &lt;placement&gt; &lt;player|me|off&gt;</c>: the placement's live NPCs escort a
/// player, for trying it out in game (D258). In memory only: a respawn or a reload ends it, and a
/// placement never escorts anyone of its own accord.
/// </summary>
private void CmdFollow(Caller c, string[] args)
{
if (args.Length < 2)
{
c.Say("Usage: rnpc " + _verbs["follow"].Usage);
return;
}
PlacementState ps;
if (!_placements.TryGetValue(args[0], out ps))
{
c.Say($"There is no placement '{args[0]}'. rnpc near lists those around you.");
return;
}
BasePlayer target = null;
string who = args[1];
if (who == "me")
{
if (c.Player == null)
{
c.Say("The server console cannot be followed; name a player.");
return;
}
target = c.Player;
}
else if (who != "off")
{
target = BasePlayer.Find(who);
if (target == null)
{
c.Say($"No player on the server matches '{who}'.");
return;
}
}
int n = 0;
foreach (Slot s in ps.Slots)
{
if (s.Npc == null || s.Npc.IsDestroyed)
continue;
string error = SetEscort(s.Npc, target);
if (error != null)
{
c.Say(error + ".");
return;
}
n++;
}
c.Say(n == 0 ? $"{ps.Id} has no live NPCs now." : target == null
? $"{ps.Id}: {n} NPC(s) stop escorting and walk back to their spot."
: $"{ps.Id}: {n} NPC(s) escort {target.displayName}. They walk back to their spot if {target.displayName} dies or leaves; rnpc follow {ps.Id} off ends it.");
}
// ---- faction (D254, D268) ----
/// <summary><c>rnpc.faction</c>: the faction table on a standalone server; refused when a site manages it (D221).</summary>
private void CmdFaction(Caller c, string[] args)
{
string sub = args.Length > 0 ? args[0].ToLowerInvariant() : "list";
if (sub == "list")
{
List<FactionPair> table = _profileFile.Factions;
var named = _profiles.Where(kv => !string.IsNullOrEmpty(kv.Value.Faction)).GroupBy(kv => kv.Value.Faction)
.Select(g => $"{g.Key} ({string.Join(", ", g.Select(kv => kv.Key).ToArray())})").ToList();
c.Say($"Factions in use: {(named.Count > 0 ? string.Join("; ", named.ToArray()) : "none")}. Rust's own: scientists, animals.");
if (table.Count == 0)
c.Say("The table is empty: every pair is neutral, and a profile with no faction settings fights players only (D255).");
foreach (FactionPair f in table.OrderBy(f => f.A).ThenBy(f => f.B))
c.Say($"{f.A} ↔ {f.B}: {f.Relation}");
if (_profileFile.Managed)
c.Say("Managed by this server's website (D221): edit the table there.");
return;
}
if (_profileFile.Managed)
{
c.Say("This server's faction table is managed by its website (D221): edit it there.");
return;
}
if ((sub == "set" && args.Length < 4) || (sub == "clear" && args.Length < 3) || (sub != "set" && sub != "clear"))
{
c.Say("Usage: rnpc." + _verbs["faction"].Usage);
return;
}
string a = args[1], b = args[2];
var next = _profileFile.Factions.Where(f => PairKey(f.A, f.B) != PairKey(a, b)).ToList();
if (sub == "set")
next.Add(new FactionPair { A = a, B = b, Relation = args[3].ToLowerInvariant() });
string error = ValidateFactions(next);
if (error != null)
{
c.Say(Regex.Replace(error, @"^factions\[\d+\]: ", ""));
return;
}
_profileFile.Factions = next;
SaveProfiles();
RevalidateProfiles();
c.Say(sub == "set" ? $"{a} ↔ {b}: {args[3].ToLowerInvariant()}, both ways (D268)." : $"{a} ↔ {b}: back to neutral.");
}
private void CmdProfiles(Caller c, string[] args)
{
if (_profiles.Count == 0 && _refused.Count == 0)
{
c.Say(_profileFile.Managed ? "This server has no profiles yet; its website manages them." : "This server has no profiles yet. rnpc.profile create <name> makes one.");
return;
}
c.Say(_profileFile.Managed ? "Profiles (managed by this server's website):" : "Profiles:");
foreach (KeyValuePair<string, Profile> kv in _profiles.OrderBy(kv => kv.Key))
{
Profile p = kv.Value;
c.Say($"{kv.Key} · {p.Role}{(p.IsSentry ? "" : " " + p.Movement.Mode)} · {p.Health:0} hp · kits {string.Join(", ", p.Kits.ToArray())}");
}
foreach (KeyValuePair<string, string> kv in _refused.OrderBy(kv => kv.Key))
c.Say($"{kv.Key} · refused: {kv.Value}");
}
// ---- tp, respawn, clear ----
private void CmdTp(Caller c, string[] args)
{
if (c.Player == null)
{
c.Say("Teleporting is for a player.");
return;
}
PlacementState ps;
if (args.Length != 1 || !_placements.TryGetValue(args[0], out ps))
{
c.Say(args.Length == 1 ? $"There is no placement '{args[0]}'." : "Usage: rnpc tp <placement>");
return;
}
c.Player.Teleport(ps.Data.Position.V + Vector3.up * 0.2f);
c.Say($"At {ps.Id}.");
}
/// <summary>A placement's NPCs, despawned and spawned fresh now, without waiting for the delay.</summary>
private int RespawnNow(PlacementState ps)
{
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
foreach (Slot s in ps.Slots)
s.DiedAt = float.NegativeInfinity;
ps.LastError = null;
return ps.Slots.Length;
}
private void CmdRespawn(Caller c, string[] args)
{
if (args.Length != 1)
{
c.Say("Usage: rnpc respawn <placement|all>");
return;
}
List<PlacementState> which;
if (args[0] == "all")
which = _placements.Values.ToList();
else
{
PlacementState ps;
if (!_placements.TryGetValue(args[0], out ps))
{
c.Say($"There is no placement '{args[0]}'.");
return;
}
which = new List<PlacementState> { ps };
}
int n = which.Sum(RespawnNow);
TickPlacements();
List<string> waiting = which.Where(ps => ps.Waiting != null).Select(ps => $"{ps.Id} waits: {ps.Waiting}").ToList();
c.Say($"Respawning {n} NPC(s) from {which.Count} placement(s).");
c.Lines.AddRange(waiting);
}
private void CmdClear(Caller c, string[] args)
{
if (args.Length != 1 || !(OwnerRule.IsMatch(args[0]) || args[0].StartsWith("placement:")))
{
c.Say("Usage: rnpc clear <owner>, e.g. run:<id>, plugin:<name> or placement:<name>");
return;
}
int n = Despawn(_npcs.Values.Where(x => x.Owner == args[0]));
c.Say(args[0].StartsWith("placement:")
? $"Removed {n} NPC(s) of {args[0]}; the placement stays and respawns them after its delay (rnpc remove deletes it)."
: $"Removed {n} NPC(s) of {args[0]}.");
}
// ---- path: recording a route point by point (D240) ----
private void CmdPath(Caller c, string[] args)
{
string sub = args.Length > 0 ? args[0].ToLowerInvariant() : null;
Recording rec;
_recordings.TryGetValue(c.Key, out rec);
switch (sub)
{
case "record":
if (args.Length != 2 || !NameRule.IsMatch(args[1]))
{
c.Say("Usage: rnpc path record <name>; a name is 1–40 of a-z, 0-9, _ and -.");
return;
}
_recordings[c.Key] = new Recording { Name = args[1] };
c.Say($"Recording '{args[1]}'{(_routeFile.Routes.ContainsKey(args[1]) ? " (saving replaces the route of that name)" : "")}. " +
"Walk, and at each point type rnpc path point.");
return;
case "point":
if (rec == null)
{
c.Say("Nothing is being recorded: rnpc path record <name> first.");
return;
}
Vector3 at;
if (c.Player != null && args.Length == 1)
at = c.Player.transform.position;
else
{
PlaceOptions o = null;
string error = args.Length == 2 ? ParseOptions(c, args, 1, out o) : "from the server console, give the point as at=x,y,z";
if (error == null && (o == null || !o.At.HasValue)) error = "give the point as at=x,y,z";
if (error != null)
{
c.Say(error);
return;
}
at = o.At.Value;
}
string off = OffMeshReason(at);
if (off != null)
{
c.Say($"Refused: {off}.");
return;
}
NavMeshHit hit;
SampleNavmesh(at, Scatter, out hit);
if (rec.Points.Count > 0 && !Walkable(rec.Points[rec.Points.Count - 1], hit.position))
{
c.Say($"Refused: an NPC cannot walk here from point {rec.Points.Count}; Rust's navmesh finds no path " +
"(a player can climb what an NPC cannot). Add a point between them, or choose another spot.");
return;
}
rec.Points.Add(hit.position);
string gap = rec.Points.Count > 1 ? $", {Meters(rec.Points[rec.Points.Count - 2], hit.position)} from the last" : "";
c.Say($"Point {rec.Points.Count} at ({hit.position.x:0}, {hit.position.y:0}, {hit.position.z:0}){gap}.");
return;
case "undo":
if (rec == null || rec.Points.Count == 0)
{
c.Say("There is no point to undo.");
return;
}
rec.Points.RemoveAt(rec.Points.Count - 1);
c.Say($"Dropped the last point; {rec.Points.Count} left.");
return;
case "save":
if (rec == null)
{
c.Say("Nothing is being recorded.");
return;
}
string how = args.Length > 1 ? args[1].ToLowerInvariant() : "loop";
if (how != "loop" && how != "back")
{
c.Say("Usage: rnpc path save [loop|back]: loop returns from the last point to the first; back walks it back and forth.");
return;
}
if (rec.Points.Count < 2)
{
c.Say($"A route needs at least two points; '{rec.Name}' has {rec.Points.Count}.");
return;
}
if (how == "loop" && !Walkable(rec.Points[rec.Points.Count - 1], rec.Points[0]))
{
c.Say($"An NPC cannot walk from the last point back to the first, so '{rec.Name}' cannot loop. " +
"Add points that close the loop, or save it as back (back and forth).");
return;
}
var route = new Route { Loop = how == "loop", Points = rec.Points.Select(Pos.Of).ToList() };
string refused = SetRoute(rec.Name, route);
if (refused != null)
{
c.Say("Not saved: " + refused);
return;
}
_recordings.Remove(c.Key);
int users = _placements.Values.Count(ps => ps.Data.Movement != null && ps.Data.Movement.Mode == "route:" + rec.Name);
c.Say($"Saved '{rec.Name}': {route.Points.Count} points, {(route.Loop ? "loops" : "back and forth")}. " +
$"Place a patrol on it: rnpc place <profile> move=route:{rec.Name}{(users > 0 ? $" ({users} placement(s) already use it)" : "")}");
return;
case "cancel":
c.Say(_recordings.Remove(c.Key) ? $"Discarded '{rec.Name}'." : "Nothing is being recorded.");
return;
case "list":
if (_routeFile.Routes.Count == 0)
c.Say("No routes yet: rnpc path record <name>.");
foreach (KeyValuePair<string, Route> kv in _routeFile.Routes.OrderBy(kv => kv.Key))
{
int n = _placements.Values.Count(ps => ps.Data.Movement != null && ps.Data.Movement.Mode == "route:" + kv.Key) +
_placements.Values.Count(ps => ps.Data.Movement == null && _profiles.ContainsKey(ps.Data.Profile) && _profiles[ps.Data.Profile].Movement.Mode == "route:" + kv.Key);
string where = c.Player != null ? $" · first point {Meters(kv.Value.Points[0].V, c.Player.transform.position)}" : "";
c.Say($"{kv.Key} · {kv.Value.Points.Count} points · {(kv.Value.Loop ? "loop" : "back and forth")} · {n} placement(s){where}");
}
if (rec != null)
c.Say($"Recording: '{rec.Name}', {rec.Points.Count} point(s), not saved.");
return;
case "delete":
if (args.Length != 2)
{
c.Say("Usage: rnpc path delete <name>");
return;
}
if (!RunicNpc_RemoveRoute(args[1]))
{
c.Say($"There is no route '{args[1]}'.");
return;
}
TickPlacements();
int waiting = _placements.Values.Count(ps => ps.Waiting != null && ps.Waiting.Contains($"route '{args[1]}'"));
c.Say($"Deleted '{args[1]}'.{(waiting > 0 ? $" {waiting} placement(s) on it now wait for a route of that name." : "")}");
return;
default:
c.Say("Usage: rnpc " + _verbs["path"].Usage);
return;
}
}
// ---- profile: standalone edits from a console (D221) ----
private void CmdProfile(Caller c, string[] args)
{
string sub = args.Length > 0 ? args[0].ToLowerInvariant() : null;
if (sub == "list")
{
CmdProfiles(c, args);
return;
}
if (sub == null || args.Length < 2)
{
c.Say("Usage: rnpc." + _verbs["profile"].Usage);
return;
}
string name = args[1];
Profile existing;
_profileFile.Profiles.TryGetValue(name, out existing);
if (sub == "show")
{
c.Say(existing == null ? $"There is no profile '{name}'." : JsonConvert.SerializeObject(existing, Formatting.Indented));
if (existing != null && _refused.ContainsKey(name))
c.Say($"It is refused: {_refused[name]}");
return;
}
if (_profileFile.Managed)
{
c.Say("This server's profiles are managed by its website (D221): edit them there.");
return;
}
switch (sub)
{
case "create":
if (!NameRule.IsMatch(name))
{
c.Say("A name is 1–40 of a-z, 0-9, _ and -.");
return;
}
if (existing != null)
{
c.Say($"'{name}' exists: rnpc.profile set {name} <field> <value> changes it.");
return;
}
Profile made = new Profile { Names = new List<string> { "Scientist" } };
if (args.Length > 2)
{
Profile from;
if (!args[2].StartsWith("from=") || !_profileFile.Profiles.TryGetValue(args[2].Substring(5), out from))
{
c.Say(args[2].StartsWith("from=") ? $"There is no profile '{args[2].Substring(5)}'." : "Usage: rnpc.profile create <name> [from=<profile>]");
return;
}
made = JObject.FromObject(from).ToObject<Profile>();
}
_profileFile.Profiles[name] = made;
break;
case "set":
if (existing == null)
{
c.Say($"There is no profile '{name}'.");
return;
}
if (args.Length < 4)
{
c.Say("Usage: rnpc.profile set <name> <field> <value>, e.g. health 200, ranges.sense 40, kits a,b, names Bob,Old Jim");
return;
}
string error = SetProfileField(name, existing, args[2], string.Join(" ", args.Skip(3).ToArray()));
if (error != null)
{
c.Say(error);
return;
}
break;
case "delete":
if (existing == null)
{
c.Say($"There is no profile '{name}'.");
return;
}
_profileFile.Profiles.Remove(name);
break;
default:
c.Say("Usage: rnpc." + _verbs["profile"].Usage);
return;
}
SaveProfiles();
RevalidateProfiles();
TickPlacements();
if (sub == "delete")
{
int waiting = _placements.Values.Count(ps => ps.Data.Profile == name);
c.Say($"Deleted '{name}'.{(waiting > 0 ? $" Its {waiting} placement(s) wait for a profile of that name (D237)." : "")}");
}
else
c.Say(_refused.ContainsKey(name) ? $"Saved '{name}', but it cannot spawn yet: {_refused[name]}" : $"Saved '{name}'; it is in use.");
}
/// <summary>One field of a profile, by its JSON path (<c>health</c>, <c>ranges.sense</c>, <c>kits</c>).</summary>
private string SetProfileField(string name, Profile profile, string field, string value)
{
JObject o = JObject.FromObject(profile);
// Stage 5: a profile's own exceptions are keyed by faction (D254), so one is set, or removed
// with none, by name: relations.scientists hostile, relations.profile:warden none.
if (field.StartsWith("relations."))
{
string key = field.Substring(10);
JObject relations = (JObject)o["relations"];
if (value == "none" || value == "-")
relations.Remove(key);
else
relations[key] = value;
_profileFile.Profiles[name] = o.ToObject<Profile>();
return null;
}
if (field == "faction" && (value == "none" || value == "-"))
{
o["faction"] = null;
_profileFile.Profiles[name] = o.ToObject<Profile>();
return null;
}
// Stage 6: the boss box and press E switch on with their defaults and off with none; their own
// fields are then set one at a time (boss.spawnLine …, use.mode …), and a list of objects as
// JSON: boss.phases [{"at":0.5,"line":"…"}].
if ((field == "boss" || field == "use") && (value == "on" || value == "none" || value == "-"))
{
o[field] = value == "on" ? (o[field] is JObject ? o[field] : JObject.FromObject(field == "boss" ? (object)new BossBlock() : new UseBlock { Lines = new List<string> { "Hello." } })) : null;
_profileFile.Profiles[name] = o.ToObject<Profile>();
return null;
}
// D279: a profile made passive starts unhurtable; "hurtByPlayers on" changes that.
if (field == "role" && value == "passive" && (string)o["role"] != "passive")
o["hurtByPlayers"] = false;
JToken target = o.SelectToken(field);
if (target == null || target is JObject)
return target == null
? $"A profile has no field '{field}'. Fields: {string.Join(", ", Flatten(o).ToArray())}"
: $"'{field}' has fields of its own: set them one at a time, e.g. {field}.{((JObject)target).Properties().First().Name}";
JToken replacement;
float f;
if (target is JArray && value.TrimStart().StartsWith("["))
{
try
{
replacement = JArray.Parse(value);
}
catch (Exception e)
{
return $"'{field}' could not be read as JSON: {e.Message}";
}
}
else if (target is JArray)
{
string[] parts = value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
if (field == "healthThresholds")
{
var numbers = new JArray();
foreach (string s in parts)
{
if (!TryFloat(s, out f)) return $"'{s}' is not a number";
numbers.Add(f);
}
replacement = numbers;
}
else
replacement = new JArray(parts.Cast<object>().ToArray());
}
else if (target.Type == JTokenType.Float || target.Type == JTokenType.Integer)
{
if (!TryFloat(value, out f)) return $"'{field}' is a number";
replacement = new JValue(f);
}
else if (target.Type == JTokenType.Boolean)
{
string v = value.ToLowerInvariant();
if (v != "true" && v != "false" && v != "on" && v != "off") return $"'{field}' is true or false";
replacement = new JValue(v == "true" || v == "on");
}
else
replacement = new JValue(value);
target.Replace(replacement);
_profileFile.Profiles[name] = o.ToObject<Profile>();
return null;
}
private static IEnumerable<string> Flatten(JObject o, string prefix = "")
{
foreach (JProperty prop in o.Properties())
{
if (prop.Value is JObject)
foreach (string inner in Flatten((JObject)prop.Value, prefix + prop.Name + "."))
yield return inner;
else
yield return prefix + prop.Name;
}
}
#endregion
#region Console
[ConsoleCommand("rnpc.status")]
private void CmdStatus(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin)
return;
var fired = new List<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);
double fightMs = RunicNpcBrain.FightTicks > 0 ? RunicNpcBrain.FightMs / RunicNpcBrain.FightTicks : 0.0;
double senseMs = SenseRuns > 0 ? SenseMs / SenseRuns : 0.0;
sb.Append($"stage 5: factions={_profileFile.Factions.Count} pairs, tracked scientists={_scientists.Count} animals={_animals.Count}, " +
$"engaged={_npcs.Values.Count(n => n.Engaged)} defenders={_defenders.Count} alarms={Alarms} defences={Defences}, " +
$"fight {fightMs:0.000} ms/think over {RunicNpcBrain.FightTicks}, sense {senseMs:0.000} ms over {SenseRuns}").Append(nl);
sb.Append($"sentries (turrets: always): {_sentryPatch}, answered {SentryPatch.Answered}; zonemanager={(ZoneManager != null ? "loaded" : "absent")}").Append(nl);
CapsConfig caps = _config.Caps;
sb.Append($"caps: total={caps.Total} perOwner={caps.PerOwner} perProfile={caps.PerProfile} spawnsPerSecond={caps.SpawnsPerSecond} (0 = none)").Append(nl);
sb.Append(CostWarning(PlannedTotal()));
arg.ReplyWith(sb.ToString());
}
/// <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;
ReadProfileFile();
_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
}
}