Files
runicnpc-rust/plugin/RunicNPC.cs
wtclaude 4ea50c4fd2 fix: escort end, line of sight and rockets; test(harness): stage 5 group (stage 5)
What the rigs' stage 5 runs found in the plugin:

- an escort whose charge was destroyed was never released: Unity's ==
  says a destroyed entity is null, so CheckEscort returned before it
  looked. It now tests the reference itself (D269);
- our own fight's line of sight no longer uses Rust's CanSeeTarget for an
  NPC target: its rays are unbounded, so on open ground they run past the
  target into the terrain behind it. A bounded linecast to the target's
  centre, which counts the target's own collider as seen;
- a rocket's line of sight: Rust's sensed one for a real player, ours for
  an NPC or an animal, which are never in Rust's player memory.

tools/RunicNpcTest.cs 0.5.0 adds `rnt.run s5`: 52 checks of the faction
table and its refusals, faction fights, the leash, shooting back, the alarm,
allies spared and defended (players and buildings), escort, the guard,
the tether, PVE, turrets (`ignore` through the hook, `always` on the map's
own sentries with the org lead's condition: no stock scientist or bandit
guard ever targeted), and the kit's extras used up. Also `rnt.duel`,
`rnt.kitprobe`, `rnt.rocketprobe` and `rnt.list`, for looking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 08:39:33 -05:00

5098 lines
219 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

// Requires: Kits
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
using UnityEngine.AI;
namespace Oxide.Plugins
{
/// <summary>
/// RunicNPC — Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon.
///
/// <para>
/// This is the stage 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 = 4;
/// <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"
};
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();
[JsonIgnore] public bool IsSentry => Role == "sentry";
[JsonIgnore] public bool IsGuard => Role == "guard";
}
/// <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()
{
_profileFile = ReadData<ProfileFile>(DataProfiles);
if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary<string, Profile>();
if (_profileFile.Factions == null) _profileFile.Factions = new List<FactionPair>();
}
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")
return $"role: '{p.Role}' is not roamer, sentry or guard";
string movement = ValidateMovement(p.Movement);
if (movement != null) return movement;
if (!(p.Health > 0f)) return "health: must be above 0";
if (p.DamageDealt < 0f) return "damageDealt: must not be negative";
if (p.DamageTaken == null || p.DamageTaken.Head < 0f || p.DamageTaken.Body < 0f || p.DamageTaken.Legs < 0f)
return "damageTaken: head, body and legs must not be negative";
if (p.AimCone < 0f) return "aimCone: must not be negative";
if (p.Ranges == null || !(p.Ranges.Sense > 0f) || !(p.Ranges.Attack > 0f) || p.Ranges.LoseTarget < p.Ranges.Sense || p.Ranges.Chase < 0f)
return "ranges: sense and attack above 0, loseTarget at least sense, chase not negative";
if (p.VisionCone < -1f || p.VisionCone > 1f) return "visionCone: between -1 and 1";
if (p.SleepDistance < 0f) return "sleepDistance: 0 (never sleeps) or more";
if (p.HealthThresholds == null || p.HealthThresholds.Any(t => !(t > 0f && t < 1f)))
return "healthThresholds: fractions between 0 and 1";
if (!string.IsNullOrEmpty(p.Faction) && (!NameRule.IsMatch(p.Faction) || BuiltInFactions.Contains(p.Faction)))
return $"faction: '{p.Faction}' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)";
if (p.Relations == null)
return "relations: an object, even an empty one";
foreach (KeyValuePair<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";
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 or route:<name>";
if (kind == "wander" && !(m.Radius > 0f))
return "movement.radius: a wanderer's radius must be above 0";
return null;
}
private static bool ParseMode(string mode, out string kind, out string route)
{
kind = null;
route = null;
if (mode == "wander" || mode == "monument")
{
kind = mode;
return true;
}
if (mode != null && mode.StartsWith("route:") && NameRule.IsMatch(mode.Substring(6)))
{
kind = "route";
route = mode.Substring(6);
return true;
}
return false;
}
private string ValidatePlacement(Placement p)
{
if (p == null) return "it is empty";
if (p.Profile == null || !NameRule.IsMatch(p.Profile)) return "profile: a profile name";
if (p.Position == null || float.IsNaN(p.Position.X) || float.IsNaN(p.Position.Y) || float.IsNaN(p.Position.Z))
return "position: x, y and z";
if (p.Count < 1) return "count: at least 1";
if (!(p.Respawn >= 1f)) return "respawn: at least 1 second";
if (p.RespawnMode != "each" && p.RespawnMode != "group") return "respawnMode: each or group";
if (p.Movement != null)
{
string movement = ValidateMovement(p.Movement);
if (movement != null) return movement;
}
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 2573b149-ba5d-458a-9b83-7f5f32c76afc.
// Do not edit by hand: regenerate after a Rust update (D232).
private static readonly string[] SwapNpcFields =
{
"ScientistNPC.RadioChatterEffects",
"ScientistNPC.DeathEffects",
"ScientistNPC.deathStatName",
"ScientistNPC.IdleChatterRepeatRange",
"ScientistNPC.radioChatterType",
"HumanNPC.AdditionalLosBlockingLayer",
"HumanNPC.LootSpawnSlots",
"HumanNPC.aimConeScale",
"HumanNPC.lastDismountTime",
"NPCPlayer.MovementTickStartDelay",
"NPCPlayer.VirtualInfoZone",
"NPCPlayer.finalDestination",
"NPCPlayer.loadouts",
"NPCPlayer.movementMask",
"NPCPlayer.LegacyNavigation",
"NPCPlayer.NavAgent",
"NPCPlayer.damageScale",
"NPCPlayer.shortRange",
"NPCPlayer.attackLengthMaxShortRangeScale",
"BasePlayer.GestureViewModel",
"BasePlayer.currentTeam",
"BasePlayer.PetEntity",
"BasePlayer.lastPlayerVisibility",
"BasePlayer.eyeHistory",
"BasePlayer.tickHistory",
"BasePlayer.fallDamageEffect",
"BasePlayer.drownEffect",
"BasePlayer.playerFlags",
"BasePlayer.Belt",
"BasePlayer.weaponMoveSpeedScale",
"BasePlayer.clothingBlocksAiming",
"BasePlayer.clothingMoveSpeedReduction",
"BasePlayer.clothingWaterSpeedBonus",
"BasePlayer.clothingAccuracyBonus",
"BasePlayer.equippingBlocked",
"BasePlayer.eggVision",
"BasePlayer.designingAIEntity",
"BaseCombatEntity.spawnDeployableCorpseOnDeath",
"BaseCombatEntity.deployableCorpsePrefab",
"BaseCombatEntity.skeletonProperties",
"BaseCombatEntity.baseProtection",
"BaseCombatEntity.startHealth",
"BaseCombatEntity.pickup",
"BaseCombatEntity.repair",
"BaseCombatEntity.ShowHealthInfo",
"BaseCombatEntity.showHealthInfoThreshold",
"BaseCombatEntity.lifestate",
"BaseCombatEntity.sendsHitNotification",
"BaseCombatEntity.sendsMeleeHitNotification",
"BaseCombatEntity.markAttackerHostile",
"BaseCombatEntity.faction",
"BaseEntity.bounds",
"BaseEntity.impactEffect",
"BaseEntity.enableSaving",
"BaseEntity.syncPosition",
"BaseEntity.model",
"BaseEntity.flags",
"BaseEntity.HasBrain",
"BaseNetworkable.postNetworkUpdateComponents",
"BaseNetworkable.prefabID",
"BaseNetworkable.globalBroadcast",
"BaseNetworkable.globalNetworkBehavior",
"BaseNetworkable.globalBuildingBlock",
"BaseNetworkable.networkRange",
};
private static readonly string[] SwapBrainFields =
{
"BaseAIBrain.SendClientCurrentState",
"BaseAIBrain.UseQueuedMovementUpdates",
"BaseAIBrain.AllowedToSleep",
"BaseAIBrain.DefaultDesignSO",
"BaseAIBrain.Designs",
"BaseAIBrain.InstanceSpecificDesign",
"BaseAIBrain.SenseRange",
"BaseAIBrain.AttackRangeMultiplier",
"BaseAIBrain.TargetLostRange",
"BaseAIBrain.VisionCone",
"BaseAIBrain.CheckVisionCone",
"BaseAIBrain.CheckLOS",
"BaseAIBrain.IgnoreNonVisionSneakers",
"BaseAIBrain.IgnoreSneakersMaxDistance",
"BaseAIBrain.IgnoreNonVisionMaxDistance",
"BaseAIBrain.ListenRange",
"BaseAIBrain.SenseTypes",
"BaseAIBrain.HostileTargetsOnly",
"BaseAIBrain.IgnoreSafeZonePlayers",
"BaseAIBrain.MaxGroupSize",
"BaseAIBrain.MemoryDuration",
"BaseAIBrain.RefreshKnownLOS",
"BaseAIBrain.CanBeBlinded",
"BaseAIBrain.BlindDurationMultiplier",
"BaseAIBrain.MovementTickStartDelay",
"BaseAIBrain.ClientCurrentState",
"BaseAIBrain.mainInterestPoint",
"BaseAIBrain.UseAIDesign",
"BaseAIBrain.Pet",
"BaseAIBrain.CanUseHealingItems",
"BaseAIBrain.HealChance",
"BaseAIBrain.HealBelowHealthFraction",
};
// </fieldlist>
private FieldInfo[] _npcFields;
private FieldInfo[] _brainFields;
private readonly List<string> _fieldsMissing = new List<string>();
private readonly List<string> _fieldsAdded = new List<string>();
/// <summary>
/// Resolves the generated field list against the running game. A name Rust has since
/// removed is reported, not fatal. On Carbon, where Rust's assembly is unmodified, the
/// list is also checked against the rule it was generated with, so a field Rust has
/// ADDED is named at boot (D232). On Oxide the rule cannot be applied: its patcher makes
/// private fields public.
/// </summary>
private void ResolveFields()
{
_fieldsMissing.Clear();
_fieldsAdded.Clear();
_npcFields = ResolveFields(typeof(ScientistNPC), SwapNpcFields, _fieldsMissing);
_brainFields = ResolveFields(typeof(ScientistBrain), SwapBrainFields, _fieldsMissing);
if (Framework() == "carbon")
{
_fieldsAdded.AddRange(RuleFields(typeof(ScientistNPC)).Except(SwapNpcFields));
_fieldsAdded.AddRange(RuleFields(typeof(ScientistBrain)).Except(SwapBrainFields));
}
if (_fieldsMissing.Count > 0)
PrintWarning($"The swap's field list names fields this Rust no longer has: {string.Join(", ", _fieldsMissing)}. Regenerate it (tools/fieldlist).");
if (_fieldsAdded.Count > 0)
PrintWarning($"Rust has fields the swap's list does not copy: {string.Join(", ", _fieldsAdded)}. Regenerate it (tools/fieldlist).");
}
private static FieldInfo[] ResolveFields(Type type, string[] names, List<string> missing)
{
var chain = new List<Type>();
for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType)
chain.Add(t);
var found = new List<FieldInfo>();
foreach (string name in names)
{
int dot = name.IndexOf('.');
string typeName = name.Substring(0, dot);
Type owner = chain.FirstOrDefault(t => t.Name == typeName);
FieldInfo f = owner?.GetField(name.Substring(dot + 1),
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
if (f == null)
missing.Add(name);
else
found.Add(f);
}
return found.ToArray();
}
/// <summary>tools/fieldlist's rule, applied at run time: only meaningful on an unmodified assembly.</summary>
private static List<string> RuleFields(Type type)
{
var names = new List<string>();
for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType)
{
foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (f.IsInitOnly || f.IsLiteral)
continue;
bool serialised = (f.IsPublic && (f.Attributes & FieldAttributes.NotSerialized) == 0) || f.IsDefined(typeof(SerializeField), false);
if (serialised && !typeof(Delegate).IsAssignableFrom(f.FieldType))
names.Add($"{t.Name}.{f.Name}");
}
}
return names;
}
private static void CopyFields(FieldInfo[] fields, Component from, Component to)
{
foreach (FieldInfo f in fields)
f.SetValue(to, f.GetValue(from));
}
/// <summary>
/// Makes one of our NPCs, not yet spawned: the prefab's scientist with its two components
/// swapped for ours. Everything the brain reads once, as it starts, is set here (stage 1,
/// Q3: <c>Senses.Init</c> copies the sense values a single time).
/// </summary>
private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, out string error)
{
error = null;
BaseEntity made = GameManager.server.CreateEntity(ResolvePrefab(p.Prefab), at, Quaternion.Euler(0f, yaw, 0f), false);
ScientistNPC stock = made as ScientistNPC;
ScientistBrain stockBrain = stock != null ? stock.GetComponent<ScientistBrain>() : null;
if (stock == null || stock.GetType() != typeof(ScientistNPC) || stockBrain == null || stockBrain.GetType() != typeof(ScientistBrain))
{
error = $"prefab '{p.Prefab}' is not a plain scientist (ScientistNPC with ScientistBrain)";
if (made != null) UnityEngine.Object.Destroy(made.gameObject);
return null;
}
GameObject go = stock.gameObject;
RunicNpcPlayer npc = go.AddComponent<RunicNpcPlayer>();
RunicNpcBrain brain = go.AddComponent<RunicNpcBrain>();
CopyFields(_npcFields, stock, npc);
CopyFields(_brainFields, stockBrain, brain);
UnityEngine.Object.DestroyImmediate(stock, true);
UnityEngine.Object.DestroyImmediate(stockBrain, true);
npc.enableSaving = false;
npc.startHealth = p.Health;
npc.damageScale = p.DamageDealt;
npc.aimConeScale = p.AimCone;
brain.SenseRange = p.Ranges.Sense;
brain.TargetLostRange = p.Ranges.LoseTarget;
brain.VisionCone = p.VisionCone;
// Our own sleep replaces an AI zone's (D235): a zone must never put ours to sleep.
brain.AllowedToSleep = false;
brain.Sentry = p.IsSentry;
return npc;
}
#endregion
#region The NPC
/// <summary>
/// Our NPC. What only a subclass can do (PLAN.md §2): its own name, its name on the
/// victim's death screen, its attack range, and damage taken by body part.
/// </summary>
public class RunicNpcPlayer : ScientistNPC, IAIAttack, 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();
// ---- 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) =>
base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity));
public new bool IsThreat(BaseEntity entity) => IsTarget(entity);
public new bool IsFriendly(BaseEntity entity) => base.IsFriendly(entity);
private BaseCombatEntity _marked;
private BaseCombatEntity.Faction _markedWas;
/// <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())
{
_self?.CheckThresholds(this);
// An unkillable NPC (D271) takes nothing and still answers the attack.
if (info != null && info.Initiator != null)
{
LastHurtAt = Time.time;
_self?.NpcHurt(this, info);
}
}
}
private ulong _pendingAttacker;
private float _healthBefore;
private void Credit(ulong id, float damage)
{
if (damage <= 0f)
return;
float sum;
Contributors.TryGetValue(id, out sum);
Contributors[id] = sum + damage;
}
public override void OnDied(HitInfo info)
{
if (_pendingAttacker != 0UL)
Credit(_pendingAttacker, _healthBefore);
Died = true;
_self?.HandleDied(this, info);
base.OnDied(info);
}
}
/// <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)
_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)
{
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)
{
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)
{
if (Spares(npc, attacker))
return;
npc.RnBrain?.Rouse();
if (real)
{
BaseAIBrain brain = npc.Brain;
if (brain?.Senses?.Memory != null)
brain.Senses.Memory.SetKnown(attacker, npc, brain.Senses);
return;
}
if (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed)
npc.NpcTarget = attacker;
}
// ---- allies: a clan, a team or a player (D257, D270) ----
private sealed class Members
{
public HashSet<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 (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route))
{
error = $"route '{route}' does not exist";
return null;
}
Vector3 position = at;
if (!p.IsSentry)
{
// Rust's sampler reaches further down than across: without the height check, a spot
// on a roof finds the ground below it (stage 2).
NavMeshHit hit;
if (!SampleNavmesh(at, Scatter, out hit) || Mathf.Abs(hit.position.y - at.y) > MaxDrop)
{
error = "the spot is not on Rust's navmesh, and only a sentry may stand off it (D219)";
return null;
}
position = hit.position;
}
if (!TakeSpawnToken())
{
error = $"the spawn-rate cap of {_config.Caps.SpawnsPerSecond}/s is reached";
return null;
}
RunicNpcPlayer npc = CreateNpc(p, position, yaw, out error);
if (npc == null)
return null;
npc.RnName = p.Names[UnityEngine.Random.Range(0, p.Names.Count)];
npc.ProfileName = profileName;
npc.Profile = p;
npc.Owner = owner;
npc.PlacementId = placementId;
npc.Slot = slot;
npc.Home = position;
npc.HomeYaw = yaw;
npc.MoveKind = kind;
npc.MoveRadius = movement.Radius;
npc.RouteName = route;
// CreateEntity(..., startActive: false) leaves the GameObject inactive and skips exactly this
// call, which GameManager.CreatePrefab makes when active is true. Without it the entity still
// spawns and thinks, but Unity never starts the brain: no design, no navigator, no state
// (stage 1, Q1).
npc.gameObject.AwakeFromInstantiate();
npc.Spawn();
npc.InitializeHealth(p.Health, p.Health);
npc.Kit = p.Kits[UnityEngine.Random.Range(0, p.Kits.Count)];
npc.inventory.Strip();
object given = Kits?.Call("GiveKit", npc, npc.Kit);
if (given is string)
PrintWarning($"Kits did not give '{npc.Kit}' to {npc.RnName} ({profileName}): {given}");
npc.EquipWeapon();
_npcs[npc.NetId] = npc;
Index(npc, true);
Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner);
return npc;
}
/// <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.PlacementId != null)
{
PlacementState ps;
if (_placements.TryGetValue(npc.PlacementId, out ps) && npc.Slot >= 0 && npc.Slot < ps.Slots.Length && ps.Slots[npc.Slot].Npc == npc)
{
ps.Slots[npc.Slot].Npc = null;
ps.Slots[npc.Slot].DiedAt = Time.realtimeSinceStartup;
}
}
if (!npc.Died && !_unloading)
Interface.CallHook("OnRunicNpcDespawned", npc, npc.Owner);
}
private int Despawn(IEnumerable<RunicNpcPlayer> npcs)
{
int n = 0;
foreach (RunicNpcPlayer npc in npcs.ToList())
{
if (npc == null || npc.IsDestroyed)
continue;
Unregister(npc);
npc.Kill();
n++;
}
return n;
}
/// <summary>
/// A death. It is unregistered at once, so a placement's respawn delay counts from the death,
/// not from whenever the body's entity is finally removed.
/// </summary>
private void HandleDied(RunicNpcPlayer npc, HitInfo info)
{
var contributors = new Dictionary<ulong, float>(npc.Contributors);
Unregister(npc);
Interface.CallHook("OnRunicNpcDied", npc, npc.ProfileName, npc.Owner, info, contributors);
}
private void 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)
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 (!p.IsSentry && OffMeshReason(at) != null)
at = spot;
}
// D239: the spot was on the navmesh when it was placed. If the world has changed under it (a
// player-built floor destroyed), the NPC stands on the nearest navmesh until the spot returns.
string off = p.IsSentry ? null : OffMeshReason(at);
NavMeshHit nearest;
if (off != null && SampleNavmesh(at, Fallback, out nearest))
{
string note = $"its spot is off the navmesh now ({off}), so its NPCs stand on the nearest, {Meters(at, nearest.position)} away";
if (ps.Note != note)
PrintWarning($"Placement '{ps.Id}': {note}.");
ps.Note = note;
at = nearest.position;
}
else if (off == null && slot == 0)
ps.Note = null;
string error;
RunicNpcPlayer npc = Spawn(data.Profile, p, at, data.Yaw, "placement:" + ps.Id, data.Movement, ps.Id, slot, out error);
if (npc == null)
{
if (ps.LastError != error)
PrintWarning($"Placement '{ps.Id}' could not spawn: {error}. It retries after its respawn delay.");
ps.LastError = error;
ps.Slots[slot].DiedAt = Time.realtimeSinceStartup;
return;
}
ps.LastError = null;
ps.Slots[slot].Npc = npc;
npc.Tether = data.Tether;
}
/// <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 Lifecycle and hooks
private void Init()
{
_self = this;
foreach (string hook in ExpectedHooks)
_hookCounts[hook] = 0L;
RegisterVerbs();
}
private void OnServerInitialized()
{
MarkHook("OnServerInitialized");
ResolveFields();
LoadData();
RevalidateProfiles();
RebuildPlacements();
TrackWorld();
PatchSentries();
_ready = true;
timer.Every(1f, TickPlacements);
Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " +
$"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}");
TickPlacements();
}
private void OnServerSave()
{
MarkHook("OnServerSave");
if (_placementsDirty)
SavePlacements();
}
private void OnEntityKill(BaseNetworkable entity)
{
MarkHook("OnEntityKill");
RunicNpcPlayer npc = entity as RunicNpcPlayer;
if (npc != null)
Unregister(npc);
else
Untrack(entity);
}
/// <summary>A plugin's NPCs die with it (§2: <c>plugin:&lt;name&gt;</c>).</summary>
private void OnPluginUnloaded(Plugin plugin)
{
MarkHook("OnPluginUnloaded");
if (plugin == null || plugin == this)
return;
string owner = "plugin:" + plugin.Name;
int n = Despawn(_npcs.Values.Where(x => x.Owner == owner));
if (n > 0)
Puts($"{plugin.Name} unloaded: its {n} NPC(s) were removed.");
}
private void Unload()
{
_unloading = true;
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;
}
private int RunicNpc_Despawn(ulong netId)
{
RunicNpcPlayer npc;
return _npcs.TryGetValue(netId, out npc) ? Despawn(new[] { npc }) : 0;
}
private int RunicNpc_DespawnOwner(string owner) => owner == null ? 0 : Despawn(_npcs.Values.Where(n => n.Owner == owner));
private List<Dictionary<string, object>> RunicNpc_List(string owner) =>
_npcs.Values.Where(n => owner == null || n.Owner == owner).Select(Describe).ToList();
private static Dictionary<string, object> Describe(RunicNpcPlayer n)
{
Vector3 p = n.transform.position;
RunicNpcBrain brain = n.RnBrain;
return new Dictionary<string, object>
{
["netId"] = n.NetId,
["profile"] = n.ProfileName,
["name"] = n.RnName,
["owner"] = n.Owner,
["placement"] = n.PlacementId,
["kit"] = n.Kit,
["position"] = new Dictionary<string, object> { ["x"] = p.x, ["y"] = p.y, ["z"] = p.z },
["home"] = new Dictionary<string, object> { ["x"] = n.Home.x, ["y"] = n.Home.y, ["z"] = n.Home.z },
["health"] = n.health,
["maxHealth"] = n.MaxHealth(),
["movement"] = n.MoveKind == "route" ? "route:" + n.RouteName : n.MoveKind,
["rest"] = brain != null ? brain.Rest.ToString() : "?",
["state"] = brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none",
["leashes"] = brain != null ? brain.Leashes : 0,
["regrounded"] = brain != null ? brain.Regrounded : 0,
["faction"] = n.Profile != null ? FactionOf(n) : null,
["target"] = n.NpcTarget != null && !n.NpcTarget.IsDestroyed ? n.NpcTarget.ShortPrefabName : null,
["engaged"] = n.Engaged,
["escort"] = n.Escort != null && !n.Escort.IsDestroyed ? (n.Escort is BasePlayer ? ((BasePlayer)n.Escort).UserIDString : n.Escort.ShortPrefabName) : null,
["ally"] = n.AllyClan != 0L ? "clan:" + n.AllyClan : n.AllyTeam != 0UL ? "team:" + n.AllyTeam : n.AllyPlayer != 0UL ? "player:" + n.AllyPlayer : null,
["tether"] = n.Tether,
["shotsMarked"] = n.ShotsMarked,
["kitUsed"] = new Dictionary<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
{
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) && !profile.IsSentry)
{
string off = OffMeshReason(data.Position.V);
if (off != null)
return $"position: {off}, and only a sentry may stand off Rust's navmesh (D219)";
}
error = TetherRefusal(data.Tether, data.Position.V);
if (error != null)
return error;
PlacementState old;
if (_placements.TryGetValue(id, out old))
Despawn(old.Slots.Select(s => s.Npc).Where(n => n != null));
_placementFile.Placements[id] = data;
_placements[id] = NewState(id, data);
_placementsDirty = true;
Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}");
TickPlacements();
PlacementChanged(id, "set");
return null;
}
private bool RunicNpc_RemovePlacement(string id)
{
PlacementState ps;
if (id == null || !_placements.TryGetValue(id, out ps))
return false;
_placements.Remove(id);
_placementFile.Placements.Remove(id);
_placementsDirty = true;
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
PlacementChanged(id, "removed");
return true;
}
/// <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 (!p.IsSentry)
{
string off = OffMeshReason(data.Position.V);
if (off != null)
return $"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219).";
built = IsPlayerBuilt(data.Position.V);
}
string next = NextPlacementId(data.Profile);
error = SetPlacement(next, data);
if (error != null)
return "Not placed: " + error;
id = next;
return null;
}
private void CmdPlace(Caller c, string[] args, bool here)
{
if (args.Length < 1)
{
c.Say($"Usage: {_verbs[here ? "here" : "place"].Usage}");
return;
}
string name = args[0];
Profile p;
if (!_profiles.TryGetValue(name, out p))
{
c.Say(_refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them.");
return;
}
PlaceOptions o;
string error = ParseOptions(c, args, 1, out o);
if (error != null)
{
c.Say(error);
return;
}
Vector3 spot;
float yaw;
if (o.At.HasValue)
{
spot = o.At.Value;
yaw = o.Yaw ?? 0f;
}
else if (c.Player == null)
{
c.Say("From the server console, give the spot as at=x,y,z.");
return;
}
else if (here)
{
spot = c.Player.transform.position;
yaw = c.Player.eyes.rotation.eulerAngles.y;
}
else
{
if (!LookSpot(c.Player, out spot))
{
c.Say($"Look at the ground, a floor or a roof within {LookReach:0} m.");
return;
}
// It faces the admin who placed it.
Vector3 back = c.Player.transform.position - spot;
yaw = back.sqrMagnitude > 0.01f ? Quaternion.LookRotation(new Vector3(back.x, 0f, back.z)).eulerAngles.y : 0f;
}
Movement movement = null;
if (o.Move != null || o.Radius.HasValue)
{
if (p.IsSentry)
{
c.Say($"'{name}' is a sentry and never moves: drop move= and radius=.");
return;
}
movement = new Movement { Mode = o.Move ?? p.Movement.Mode, Radius = o.Radius ?? p.Movement.Radius };
error = ValidateMovement(movement);
if (error != null)
{
c.Say(error);
return;
}
}
var data = new Placement
{
Profile = name,
Position = Pos.Of(spot),
Yaw = yaw,
Count = o.Count ?? 1,
Respawn = o.Respawn ?? 300f,
RespawnMode = o.Mode ?? "each",
Movement = movement,
Tether = o.Tether
};
if (o.Tether != null && p.IsSentry)
{
c.Say($"'{name}' is a sentry and never moves: drop tether=.");
return;
}
string id;
bool built;
error = CreatePlacement(data, out id, out built);
if (error != null)
{
c.Say(error);
return;
}
string kind, route;
ParseMode((movement ?? p.Movement).Mode, out kind, out route);
Movement m = movement ?? p.Movement;
c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " +
$"respawn {data.Respawn:0} s, {data.RespawnMode}{(data.Tether != null ? $", inside zone {data.Tether}" : "")}. Rename it with rnpc rename {id} <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;
}
JToken target = o.SelectToken(field);
if (target == null || target is JObject)
return target == null
? $"A profile has no field '{field}'. Fields: {string.Join(", ", Flatten(o).ToArray())}"
: $"'{field}' has fields of its own: set them one at a time, e.g. {field}.{((JObject)target).Properties().First().Name}";
JToken replacement;
float f;
if (target is JArray)
{
string[] parts = value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
if (field == "healthThresholds")
{
var numbers = new JArray();
foreach (string s in parts)
{
if (!TryFloat(s, out f)) return $"'{s}' is not a number";
numbers.Add(f);
}
replacement = numbers;
}
else
replacement = new JArray(parts.Cast<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
}
}