// Requires: Kits
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
using UnityEngine.AI;
namespace Oxide.Plugins
{
///
/// RunicNPC — Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon.
///
///
/// This is the stage 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): /rnpc in chat and rnpc.<verb> in a console.
///
///
///
/// Kits is required (D217): it is how every RunicNPC NPC is equipped, so the plugin
/// declares it on the first line and neither framework loads RunicNPC without it. That line
/// and requires_plugins in plugin.toml are two statements of one fact, and the
/// PR check holds them equal.
///
///
///
/// Our NPC is Rust's scientist with two components swapped (D223): the prefab's
/// ScientistNPC and ScientistBrain are replaced, before the entity spawns, by
/// and , carrying the prefab's
/// authored fields across. That swap and the brain's states are what a Rust update can
/// break, so they are kept in one place: the "the swap" and "the NPC" regions below.
///
///
[Info("RunicNPC", "Runic Gateway", "0.0.0")]
[Description("Runic Gateway's NPCs: profiles, placements and an API for other plugins.")]
internal class RunicNPC : RustPlugin
{
[PluginReference] private Plugin Kits;
///
/// The version of the API other plugins call (RunicNpc_*, PLAN.md §4). Declared
/// twice, here and as api in plugin.toml, which the release copies into
/// the tarball's manifest so the installer and the bridge can refuse a RunicNPC too old
/// for them before it is loaded. The PR check holds the two equal.
///
///
/// It moves when a call or a raised hook changes shape, not on every release: the
/// release version says what was built, this says what a caller can rely on.
/// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md.
///
///
private const int ApiVersion = 3;
///
/// Every hook this plugin implements. rnpc.status reports which have fired, because
/// a hook that matches nothing is silent on both frameworks: this list is how a renamed
/// one is noticed. The PR check holds it equal to the methods below.
///
private static readonly string[] ExpectedHooks =
{
"OnServerInitialized",
"OnServerSave",
"OnEntityKill",
"OnPluginUnloaded"
};
private readonly Dictionary _hookCounts = new Dictionary();
/// The loaded plugin, for the NPC and brain classes, which Rust instantiates.
private static RunicNPC _self;
private bool _ready;
private bool _unloading;
private static readonly Regex NameRule = new Regex("^[a-z0-9_-]{1,40}$");
private static readonly Regex OwnerRule = new Regex(@"^(run|plugin):\S{1,64}$");
private const string DataProfiles = "RunicNPC/profiles";
private const string DataPlacements = "RunicNPC/placements";
private const string DataRoutes = "RunicNPC/routes";
/// How far from a placement's spot its NPCs after the first are scattered.
private const float Scatter = 3f;
/// How far above or below a roamer's spot the navmesh it is put on may be.
private const float MaxDrop = 2f;
#region Config
private PluginConfig _config;
///
/// The plugin's config. No caps by default (D227): each is 0, meaning none, until an
/// admin sets it. The cost warning is shown instead, wherever NPCs are added.
///
private sealed class PluginConfig
{
[JsonProperty("caps")] public CapsConfig Caps = new CapsConfig();
///
/// Milliseconds per server frame the placement queue may spend spawning. Stage 1
/// measured about 5 ms per NPC, and 100 in one frame as a half-second hitch.
///
[JsonProperty("spawnBudgetMs")] public float SpawnBudgetMs = 8f;
}
private sealed class CapsConfig
{
[JsonProperty("total")] public int Total;
[JsonProperty("perOwner")] public int PerOwner;
[JsonProperty("perProfile")] public int PerProfile;
[JsonProperty("spawnsPerSecond")] public float SpawnsPerSecond;
}
protected override void LoadDefaultConfig() => _config = new PluginConfig();
protected override void LoadConfig()
{
base.LoadConfig();
try
{
_config = Config.ReadObject();
}
catch (Exception e)
{
PrintError($"The config could not be read, so the defaults are used: {e.Message}");
_config = null;
}
if (_config == null)
_config = new PluginConfig();
if (_config.Caps == null)
_config.Caps = new CapsConfig();
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(_config, true);
#endregion
#region Data: profiles, placements, routes
/// A position in a data file.
public sealed class Pos
{
[JsonProperty("x")] public float X;
[JsonProperty("y")] public float Y;
[JsonProperty("z")] public float Z;
[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 };
}
/// How a roamer moves (D233): wander, monument or route:<name>.
public sealed class Movement
{
[JsonProperty("mode")] public string Mode = "wander";
[JsonProperty("radius")] public float Radius = 20f;
}
public sealed class DamageTaken
{
[JsonProperty("head")] public float Head = 1f;
[JsonProperty("body")] public float Body = 1f;
[JsonProperty("legs")] public float Legs = 1f;
}
public sealed class Ranges
{
[JsonProperty("sense")] public float Sense = 30f;
[JsonProperty("loseTarget")] public float LoseTarget = 40f;
[JsonProperty("chase")] public float Chase = 40f;
[JsonProperty("attack")] public float Attack = 30f;
}
/// A named description of an NPC, in the shape the org lead approved (D238).
public sealed class Profile
{
[JsonProperty("names")] public List Names = new List();
[JsonProperty("kits")] public List Kits = new List();
[JsonProperty("prefab")] public string Prefab = "scientistnpc_roam";
[JsonProperty("role")] public string Role = "roamer";
[JsonProperty("movement")] public Movement Movement = new Movement();
[JsonProperty("health")] public float Health = 150f;
[JsonProperty("damageDealt")] public float DamageDealt = 1f;
[JsonProperty("damageTaken")] public DamageTaken DamageTaken = new DamageTaken();
[JsonProperty("aimCone")] public float AimCone = 2f;
[JsonProperty("ranges")] public Ranges Ranges = new Ranges();
[JsonProperty("visionCone")] public float VisionCone = -0.8f;
[JsonProperty("sleepDistance")] public float SleepDistance = 160f;
[JsonProperty("healthThresholds")] public List HealthThresholds = new List();
[JsonIgnore] public bool IsSentry => Role == "sentry";
}
private sealed class ProfileFile
{
/// True once a site has pushed profiles (D221); in-game edits are then refused.
[JsonProperty("managed")] public bool Managed;
[JsonProperty("profiles")] public Dictionary Profiles = new Dictionary();
}
///
/// An admin's (or the site's) persistent placement (D222). Only this is saved: its NPCs
/// never are, and are spawned fresh from it at boot (§2).
///
public sealed class Placement
{
[JsonProperty("profile")] public string Profile;
[JsonProperty("position")] public Pos Position;
[JsonProperty("yaw")] public float Yaw;
[JsonProperty("count")] public int Count = 1;
/// Seconds from a death to the respawn.
[JsonProperty("respawn")] public float Respawn = 300f;
/// each (the default) or group (D236).
[JsonProperty("respawnMode")] public string RespawnMode = "each";
/// Overrides the profile's movement for this placement only (D233); null keeps it.
[JsonProperty("movement", NullValueHandling = NullValueHandling.Ignore)] public Movement Movement;
}
private sealed class PlacementFile
{
[JsonProperty("placements")] public Dictionary Placements = new Dictionary();
}
///
/// Points an admin records in game (stage 3) and a route:<name> roamer walks in
/// order (D233, D234). loop goes from the last point back to the first; otherwise the
/// route is walked back and forth.
///
public sealed class Route
{
[JsonProperty("points")] public List Points = new List();
[JsonProperty("loop")] public bool Loop = true;
}
private sealed class RouteFile
{
[JsonProperty("routes")] public Dictionary Routes = new Dictionary();
}
private ProfileFile _profileFile = new ProfileFile();
private PlacementFile _placementFile = new PlacementFile();
private RouteFile _routeFile = new RouteFile();
/// The profiles that passed validation. Only these spawn.
private readonly Dictionary _profiles = new Dictionary();
/// The profiles that did not, and why.
private readonly Dictionary _refused = new Dictionary();
/// The one flag that marks placements for saving, on every change including removal (§2).
private bool _placementsDirty;
private T ReadData(string name) where T : class, new()
{
try
{
T value = Interface.Oxide.DataFileSystem.ReadObject(name);
return value ?? new T();
}
catch (Exception e)
{
PrintError($"data/{name}.json could not be read and is treated as empty: {e.Message}");
return new T();
}
}
private void LoadData()
{
_profileFile = ReadData(DataProfiles);
if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary();
_routeFile = ReadData(DataRoutes);
if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary();
_placementFile = ReadData(DataPlacements);
if (_placementFile.Placements == null) _placementFile.Placements = new Dictionary();
// Create the files on first boot: the plugin must make its own data directory, because one
// made from outside the game (a panel's file API) is not writable by it (PLAN.md §1.5).
SaveProfiles();
SaveRoutes();
SavePlacements();
}
private void SaveProfiles() => Interface.Oxide.DataFileSystem.WriteObject(DataProfiles, _profileFile);
private void SaveRoutes() => Interface.Oxide.DataFileSystem.WriteObject(DataRoutes, _routeFile);
private void SavePlacements()
{
Interface.Oxide.DataFileSystem.WriteObject(DataPlacements, _placementFile);
_placementsDirty = false;
}
/// Validates every profile in the file; only the valid ones are used.
private void RevalidateProfiles()
{
_profiles.Clear();
_refused.Clear();
foreach (KeyValuePair kv in _profileFile.Profiles)
{
string error = NameRule.IsMatch(kv.Key) ? ValidateProfile(kv.Value) : "the name must be 1–40 of a-z, 0-9, _ and -";
if (error == null)
_profiles[kv.Key] = kv.Value;
else
_refused[kv.Key] = error;
}
foreach (KeyValuePair kv in _refused)
PrintWarning($"Profile '{kv.Key}' is refused: {kv.Value}");
}
/// Null when the profile can spawn; otherwise the reason, for the log and the caller.
private string ValidateProfile(Profile p)
{
if (p == null) return "it is empty";
if (p.Names == null || p.Names.Count == 0 || p.Names.Any(string.IsNullOrWhiteSpace))
return "names: give at least one, and no blank ones";
if (p.Kits == null || p.Kits.Count == 0)
return "kits: give at least one; Kits is how an NPC is equipped (D217)";
foreach (string kit in p.Kits)
if (!KitExists(kit))
return $"kits: this server has no kit '{kit}'";
if (ResolvePrefab(p.Prefab) == null)
return $"prefab: '{p.Prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)";
if (p.Role != "roamer" && p.Role != "sentry")
return $"role: '{p.Role}' is not roamer or sentry";
string movement = ValidateMovement(p.Movement);
if (movement != null) return movement;
if (!(p.Health > 0f)) return "health: must be above 0";
if (p.DamageDealt < 0f) return "damageDealt: must not be negative";
if (p.DamageTaken == null || p.DamageTaken.Head < 0f || p.DamageTaken.Body < 0f || p.DamageTaken.Legs < 0f)
return "damageTaken: head, body and legs must not be negative";
if (p.AimCone < 0f) return "aimCone: must not be negative";
if (p.Ranges == null || !(p.Ranges.Sense > 0f) || !(p.Ranges.Attack > 0f) || p.Ranges.LoseTarget < p.Ranges.Sense || p.Ranges.Chase < 0f)
return "ranges: sense and attack above 0, loseTarget at least sense, chase not negative";
if (p.VisionCone < -1f || p.VisionCone > 1f) return "visionCone: between -1 and 1";
if (p.SleepDistance < 0f) return "sleepDistance: 0 (never sleeps) or more";
if (p.HealthThresholds == null || p.HealthThresholds.Any(t => !(t > 0f && t < 1f)))
return "healthThresholds: fractions between 0 and 1";
return null;
}
private static string ValidateMovement(Movement m)
{
if (m == null) return "movement: missing";
string kind, route;
if (!ParseMode(m.Mode, out kind, out route))
return $"movement.mode: '{m.Mode}' is not wander, monument or route:";
if (kind == "wander" && !(m.Radius > 0f))
return "movement.radius: a wanderer's radius must be above 0";
return null;
}
private static bool ParseMode(string mode, out string kind, out string route)
{
kind = null;
route = null;
if (mode == "wander" || mode == "monument")
{
kind = mode;
return true;
}
if (mode != null && mode.StartsWith("route:") && NameRule.IsMatch(mode.Substring(6)))
{
kind = "route";
route = mode.Substring(6);
return true;
}
return false;
}
private string ValidatePlacement(Placement p)
{
if (p == null) return "it is empty";
if (p.Profile == null || !NameRule.IsMatch(p.Profile)) return "profile: a profile name";
if (p.Position == null || float.IsNaN(p.Position.X) || float.IsNaN(p.Position.Y) || float.IsNaN(p.Position.Z))
return "position: x, y and z";
if (p.Count < 1) return "count: at least 1";
if (!(p.Respawn >= 1f)) return "respawn: at least 1 second";
if (p.RespawnMode != "each" && p.RespawnMode != "group") return "respawnMode: each or group";
if (p.Movement != null)
{
string movement = ValidateMovement(p.Movement);
if (movement != null) return movement;
}
return null;
}
private static string ValidateRoute(Route r)
{
if (r == null || r.Points == null || r.Points.Count < 2) return "points: at least two";
for (int i = 0; i < r.Points.Count; i++)
{
NavMeshHit hit;
if (r.Points[i] == null || !SampleNavmesh(r.Points[i].V, 3f, out hit))
return $"points[{i}] is not on Rust's navmesh";
}
return null;
}
#endregion
#region Rust's facts: kits, prefabs, the navmesh
private bool KitExists(string kit) => Kits != null && !string.IsNullOrEmpty(kit) && Kits.Call("IsKit", kit) is bool b && b;
private static Dictionary _prefabPaths;
/// A scientist prefab's full path from its short name, read from the game's manifest.
private static string ResolvePrefab(string shortName)
{
if (string.IsNullOrEmpty(shortName) || !shortName.StartsWith("scientistnpc_"))
return null;
if (_prefabPaths == null)
{
_prefabPaths = new Dictionary();
// prefabProperties lists every prefab; the manifest's `entities` does not include the NPCs.
foreach (GameManifest.PrefabProperties prefab in GameManifest.Current.prefabProperties)
{
string path = prefab.name;
if (!path.StartsWith("assets/rust.ai/agents/npcplayer/humannpc/") || !path.EndsWith(".prefab"))
continue;
string name = path.Substring(path.LastIndexOf('/') + 1);
_prefabPaths[name.Substring(0, name.Length - ".prefab".Length)] = path;
}
}
string full;
return _prefabPaths.TryGetValue(shortName, out full) ? full : null;
}
///
/// Rust's own navmesh, the one a scientist walks (stage 1, Q4): its navigator is a Gen2
/// RustNavMeshAgent, and Unity's NavMesh queries see none of the open world.
///
private static bool SampleNavmesh(Vector3 p, float radius, out NavMeshHit hit) =>
Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, radius, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas);
/// False until the map's navmesh is built: 8–10 minutes on a map's first boot (stage 1, Q4).
private static bool NavmeshReady() =>
Rust.Ai.Gen2.Nav.RustNavigation.Instance != null && Rust.Ai.Gen2.Nav.RustNavigation.Instance.IsDefaultNavmeshBuilt();
private static string Framework() =>
AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide";
#endregion
#region The swap (D223, D232)
//
// Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 2573b149-ba5d-458a-9b83-7f5f32c76afc.
// Do not edit by hand: regenerate after a Rust update (D232).
private static readonly string[] SwapNpcFields =
{
"ScientistNPC.RadioChatterEffects",
"ScientistNPC.DeathEffects",
"ScientistNPC.deathStatName",
"ScientistNPC.IdleChatterRepeatRange",
"ScientistNPC.radioChatterType",
"HumanNPC.AdditionalLosBlockingLayer",
"HumanNPC.LootSpawnSlots",
"HumanNPC.aimConeScale",
"HumanNPC.lastDismountTime",
"NPCPlayer.MovementTickStartDelay",
"NPCPlayer.VirtualInfoZone",
"NPCPlayer.finalDestination",
"NPCPlayer.loadouts",
"NPCPlayer.movementMask",
"NPCPlayer.LegacyNavigation",
"NPCPlayer.NavAgent",
"NPCPlayer.damageScale",
"NPCPlayer.shortRange",
"NPCPlayer.attackLengthMaxShortRangeScale",
"BasePlayer.GestureViewModel",
"BasePlayer.currentTeam",
"BasePlayer.PetEntity",
"BasePlayer.lastPlayerVisibility",
"BasePlayer.eyeHistory",
"BasePlayer.tickHistory",
"BasePlayer.fallDamageEffect",
"BasePlayer.drownEffect",
"BasePlayer.playerFlags",
"BasePlayer.Belt",
"BasePlayer.weaponMoveSpeedScale",
"BasePlayer.clothingBlocksAiming",
"BasePlayer.clothingMoveSpeedReduction",
"BasePlayer.clothingWaterSpeedBonus",
"BasePlayer.clothingAccuracyBonus",
"BasePlayer.equippingBlocked",
"BasePlayer.eggVision",
"BasePlayer.designingAIEntity",
"BaseCombatEntity.spawnDeployableCorpseOnDeath",
"BaseCombatEntity.deployableCorpsePrefab",
"BaseCombatEntity.skeletonProperties",
"BaseCombatEntity.baseProtection",
"BaseCombatEntity.startHealth",
"BaseCombatEntity.pickup",
"BaseCombatEntity.repair",
"BaseCombatEntity.ShowHealthInfo",
"BaseCombatEntity.showHealthInfoThreshold",
"BaseCombatEntity.lifestate",
"BaseCombatEntity.sendsHitNotification",
"BaseCombatEntity.sendsMeleeHitNotification",
"BaseCombatEntity.markAttackerHostile",
"BaseCombatEntity.faction",
"BaseEntity.bounds",
"BaseEntity.impactEffect",
"BaseEntity.enableSaving",
"BaseEntity.syncPosition",
"BaseEntity.model",
"BaseEntity.flags",
"BaseEntity.HasBrain",
"BaseNetworkable.postNetworkUpdateComponents",
"BaseNetworkable.prefabID",
"BaseNetworkable.globalBroadcast",
"BaseNetworkable.globalNetworkBehavior",
"BaseNetworkable.globalBuildingBlock",
"BaseNetworkable.networkRange",
};
private static readonly string[] SwapBrainFields =
{
"BaseAIBrain.SendClientCurrentState",
"BaseAIBrain.UseQueuedMovementUpdates",
"BaseAIBrain.AllowedToSleep",
"BaseAIBrain.DefaultDesignSO",
"BaseAIBrain.Designs",
"BaseAIBrain.InstanceSpecificDesign",
"BaseAIBrain.SenseRange",
"BaseAIBrain.AttackRangeMultiplier",
"BaseAIBrain.TargetLostRange",
"BaseAIBrain.VisionCone",
"BaseAIBrain.CheckVisionCone",
"BaseAIBrain.CheckLOS",
"BaseAIBrain.IgnoreNonVisionSneakers",
"BaseAIBrain.IgnoreSneakersMaxDistance",
"BaseAIBrain.IgnoreNonVisionMaxDistance",
"BaseAIBrain.ListenRange",
"BaseAIBrain.SenseTypes",
"BaseAIBrain.HostileTargetsOnly",
"BaseAIBrain.IgnoreSafeZonePlayers",
"BaseAIBrain.MaxGroupSize",
"BaseAIBrain.MemoryDuration",
"BaseAIBrain.RefreshKnownLOS",
"BaseAIBrain.CanBeBlinded",
"BaseAIBrain.BlindDurationMultiplier",
"BaseAIBrain.MovementTickStartDelay",
"BaseAIBrain.ClientCurrentState",
"BaseAIBrain.mainInterestPoint",
"BaseAIBrain.UseAIDesign",
"BaseAIBrain.Pet",
"BaseAIBrain.CanUseHealingItems",
"BaseAIBrain.HealChance",
"BaseAIBrain.HealBelowHealthFraction",
};
//
private FieldInfo[] _npcFields;
private FieldInfo[] _brainFields;
private readonly List _fieldsMissing = new List();
private readonly List _fieldsAdded = new List();
///
/// Resolves the generated field list against the running game. A name Rust has since
/// removed is reported, not fatal. On Carbon, where Rust's assembly is unmodified, the
/// list is also checked against the rule it was generated with, so a field Rust has
/// ADDED is named at boot (D232). On Oxide the rule cannot be applied: its patcher makes
/// private fields public.
///
private void ResolveFields()
{
_fieldsMissing.Clear();
_fieldsAdded.Clear();
_npcFields = ResolveFields(typeof(ScientistNPC), SwapNpcFields, _fieldsMissing);
_brainFields = ResolveFields(typeof(ScientistBrain), SwapBrainFields, _fieldsMissing);
if (Framework() == "carbon")
{
_fieldsAdded.AddRange(RuleFields(typeof(ScientistNPC)).Except(SwapNpcFields));
_fieldsAdded.AddRange(RuleFields(typeof(ScientistBrain)).Except(SwapBrainFields));
}
if (_fieldsMissing.Count > 0)
PrintWarning($"The swap's field list names fields this Rust no longer has: {string.Join(", ", _fieldsMissing)}. Regenerate it (tools/fieldlist).");
if (_fieldsAdded.Count > 0)
PrintWarning($"Rust has fields the swap's list does not copy: {string.Join(", ", _fieldsAdded)}. Regenerate it (tools/fieldlist).");
}
private static FieldInfo[] ResolveFields(Type type, string[] names, List missing)
{
var chain = new List();
for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType)
chain.Add(t);
var found = new List();
foreach (string name in names)
{
int dot = name.IndexOf('.');
string typeName = name.Substring(0, dot);
Type owner = chain.FirstOrDefault(t => t.Name == typeName);
FieldInfo f = owner?.GetField(name.Substring(dot + 1),
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
if (f == null)
missing.Add(name);
else
found.Add(f);
}
return found.ToArray();
}
/// tools/fieldlist's rule, applied at run time: only meaningful on an unmodified assembly.
private static List RuleFields(Type type)
{
var names = new List();
for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType)
{
foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (f.IsInitOnly || f.IsLiteral)
continue;
bool serialised = (f.IsPublic && (f.Attributes & FieldAttributes.NotSerialized) == 0) || f.IsDefined(typeof(SerializeField), false);
if (serialised && !typeof(Delegate).IsAssignableFrom(f.FieldType))
names.Add($"{t.Name}.{f.Name}");
}
}
return names;
}
private static void CopyFields(FieldInfo[] fields, Component from, Component to)
{
foreach (FieldInfo f in fields)
f.SetValue(to, f.GetValue(from));
}
///
/// Makes one of our NPCs, not yet spawned: the prefab's scientist with its two components
/// swapped for ours. Everything the brain reads once, as it starts, is set here (stage 1,
/// Q3: Senses.Init copies the sense values a single time).
///
private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, out string error)
{
error = null;
BaseEntity made = GameManager.server.CreateEntity(ResolvePrefab(p.Prefab), at, Quaternion.Euler(0f, yaw, 0f), false);
ScientistNPC stock = made as ScientistNPC;
ScientistBrain stockBrain = stock != null ? stock.GetComponent() : null;
if (stock == null || stock.GetType() != typeof(ScientistNPC) || stockBrain == null || stockBrain.GetType() != typeof(ScientistBrain))
{
error = $"prefab '{p.Prefab}' is not a plain scientist (ScientistNPC with ScientistBrain)";
if (made != null) UnityEngine.Object.Destroy(made.gameObject);
return null;
}
GameObject go = stock.gameObject;
RunicNpcPlayer npc = go.AddComponent();
RunicNpcBrain brain = go.AddComponent();
CopyFields(_npcFields, stock, npc);
CopyFields(_brainFields, stockBrain, brain);
UnityEngine.Object.DestroyImmediate(stock, true);
UnityEngine.Object.DestroyImmediate(stockBrain, true);
npc.enableSaving = false;
npc.startHealth = p.Health;
npc.damageScale = p.DamageDealt;
npc.aimConeScale = p.AimCone;
brain.SenseRange = p.Ranges.Sense;
brain.TargetLostRange = p.Ranges.LoseTarget;
brain.VisionCone = p.VisionCone;
// Our own sleep replaces an AI zone's (D235): a zone must never put ours to sleep.
brain.AllowedToSleep = false;
brain.Sentry = p.IsSentry;
return npc;
}
#endregion
#region The NPC
///
/// Our NPC. What only a subclass can do (PLAN.md §2): its own name, its name on the
/// victim's death screen, its attack range, and damage taken by body part.
///
public class RunicNpcPlayer : ScientistNPC, IAIAttack
{
public string RnName = "Scientist";
public string ProfileName;
public Profile Profile;
public string Owner;
public string PlacementId;
public int Slot = -1;
public Vector3 Home;
public string MoveKind = "wander";
public float MoveRadius = 20f;
public string RouteName;
public string Kit;
public bool Died;
public readonly Dictionary Contributors = new Dictionary();
public readonly HashSet ThresholdsFired = new HashSet();
public ulong NetId => net != null ? net.ID.Value : 0UL;
public RunicNpcBrain RnBrain => Brain as RunicNpcBrain;
public override string displayName => RnName;
///
/// HumanNPC.AttackerInfo writes the prefab's short name as the killer on the victim's
/// death screen, after BasePlayer's display name (stage 1, Q6). Ours puts the name back.
///
public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info)
{
base.AttackerInfo(info);
info.attackerName = displayName;
}
///
/// The profile's attack range replaces the weapon's. HumanNPC.TickAttack asks through
/// IAIAttack, which this class re-implements, so the call lands here.
///
public new bool IsTargetInRange(BaseEntity entity, out float dist)
{
dist = Vector3.Distance(entity.transform.position, transform.position);
return dist <= EngagementRange();
}
public new float EngagementRange() => Profile != null ? Profile.Ranges.Attack : base.EngagementRange();
public override void Hurt(HitInfo info)
{
if (Profile != null && info != null)
{
// A hit with no bone (fire, an explosion, a fall) reports every area at once,
// (HitArea)(-1), so only an exact area counts as head or legs; the rest is body.
HitArea area = info.boneArea;
float scale = area == HitArea.Head ? Profile.DamageTaken.Head
: area == HitArea.Leg || area == HitArea.Foot ? Profile.DamageTaken.Legs
: Profile.DamageTaken.Body;
if (!Mathf.Approximately(scale, 1f))
info.damageTypes.ScaleAll(scale);
}
// A contributor is credited with the health it actually took, after Rust's own
// protection and never past zero. A killing blow is credited in OnDied, which
// base.Hurt reaches before it returns, so OnRunicNpcDied carries it.
BasePlayer attacker = info?.InitiatorPlayer;
ulong attackerId = attacker != null && !attacker.IsNpc ? (ulong)attacker.userID : 0UL;
_pendingAttacker = attackerId;
_healthBefore = health;
base.Hurt(info);
_pendingAttacker = 0UL;
if (attackerId != 0UL && !Died)
Credit(attackerId, _healthBefore - health);
if (Profile != null && !IsDead())
_self?.CheckThresholds(this);
}
private ulong _pendingAttacker;
private float _healthBefore;
private void Credit(ulong id, float damage)
{
if (damage <= 0f)
return;
float sum;
Contributors.TryGetValue(id, out sum);
Contributors[id] = sum + damage;
}
public override void OnDied(HitInfo info)
{
if (_pendingAttacker != 0UL)
Credit(_pendingAttacker, _healthBefore);
Died = true;
_self?.HandleDied(this, info);
base.OnDied(info);
}
}
///
/// Our brain: Rust's scientist brain, whose AI design still decides when to chase, fight
/// and take cover, with three things of ours (D233, D235):
///
///
/// the roam state replaced by our wander or route follower (a monument
/// roamer keeps Rust's own);
/// a sentry's moving states replaced by one that holds its spot and turns;
/// sleep: past the profile's distance from every player it walks home, then
/// stops thinking; a player in range wakes it.
///
///
/// The design references states by type, so a replaced state keeps its events: the
/// shooting still happens where Rust's design puts it.
///
public class RunicNpcBrain : ScientistBrain
{
public bool Sentry;
public enum RestState
{
Awake,
GoingHome,
Asleep
}
public RestState Rest = RestState.Awake;
public int Leashes;
private float _nextCheck;
private bool _playerNear = true;
private float _homeBy;
private static readonly BasePlayer[] NearBuffer = new BasePlayer[4];
private static readonly AIState[] SentryHolds =
{
AIState.Roam, AIState.Chase, AIState.Combat, AIState.TakeCover, AIState.Cover, AIState.MoveTowards,
AIState.MoveToVector3, AIState.FollowPath, AIState.NavigateHome, AIState.Flee, AIState.Blinded
};
private static readonly HashSet Aggressive = new HashSet
{
AIState.Chase, AIState.Combat, AIState.TakeCover, AIState.Cover
};
public override void AddStates()
{
base.AddStates();
RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer;
if (npc == null)
return;
if (Sentry)
{
foreach (AIState type in SentryHolds)
if (states.ContainsKey(type))
Replace(new HoldState(type, Aggressive.Contains(type)));
}
else if (npc.MoveKind == "wander" || npc.MoveKind == "route")
{
// Rust's own roam and chase both need an AI zone's move points, so outside a
// monument they do nothing (stage 1, Q3; stage 2 found the chase's Error branch).
// A monument roamer keeps both of Rust's.
Replace(npc.MoveKind == "wander" ? (BasicAIState)new WanderState() : new RouteState());
Replace(new PursueState());
}
}
private void Replace(BasicAIState state)
{
states.Remove(state.StateType);
AddState(state);
}
public override void Think(float delta)
{
RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer;
// Rust's think manager can reach a brain before Unity has started it, and until then
// it has no navigator and no state: leave that think to Rust.
if (npc == null || npc.Profile == null || npc.IsDead() || Navigator == null)
{
base.Think(delta);
return;
}
float now = Time.time;
if (now >= _nextCheck)
{
_nextCheck = now + 2f;
_playerNear = npc.Profile.SleepDistance <= 0f || PlayerWithin(npc.transform.position, npc.Profile.SleepDistance);
if (!Sentry && !Navigator.IsOnNavMeshLink)
Reground(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.Home) <= 2f || now >= _homeBy)
Sleep();
else if (!Navigator.Moving && !Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal))
Sleep();
return;
default:
if (!_playerNear && (CurrentState == null || !CurrentState.AgrresiveState))
{
// D235: walk home first, then sleep. Nothing thinks on the way: no player
// is within the sleep distance, so there is nothing to sense or fight.
Rest = RestState.GoingHome;
_homeBy = now + 120f;
lastThinkTime = now;
if (Sentry || !Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal))
Sleep();
return;
}
break;
}
if (!Sentry && npc.Profile.Ranges.Chase > 0f && CurrentState != null && CurrentState.AgrresiveState &&
Flat(npc.transform.position - npc.Home) > npc.Profile.Ranges.Chase)
Leash(npc);
base.Think(delta);
}
///
/// 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.
///
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;
/// Past its chase range from home: forget the target and head back.
public void Leash(RunicNpcPlayer npc)
{
Leashes++;
Rust.Ai.SimpleAIMemory memory = Senses.Memory;
memory.All.Clear();
memory.Players.Clear();
memory.Targets.Clear();
memory.Threats.Clear();
memory.LOS.Clear();
Events.Memory.Entity.Clear();
Senses.DelaySenseUpdate(5f);
Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Fast);
}
private void Sleep()
{
Rest = RestState.Asleep;
Navigator.Stop();
((IAISleepable)this).SleepAI();
}
private static bool PlayerWithin(Vector3 at, float distance) =>
BaseEntity.Query.Server.GetPlayersInSphere(at, distance, NearBuffer, p => !p.IsNpc && p.IsAlive() && !p.IsSleeping()) > 0;
private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude;
}
/// D233 wander: a walkable point within the radius of home, walk there, finish.
public class WanderState : BaseAIBrain.BasicAIState
{
private StateStatus _status = StateStatus.Error;
public WanderState() : base(AIState.Roam)
{
}
public override void StateEnter(BaseAIBrain brain, BaseEntity entity)
{
base.StateEnter(brain, entity);
_status = StateStatus.Error;
RunicNpcPlayer npc = entity as RunicNpcPlayer;
if (npc == null)
return;
for (int i = 0; i < 6; i++)
{
Vector2 offset = UnityEngine.Random.insideUnitCircle * npc.MoveRadius;
NavMeshHit hit;
if (SampleNavmesh(npc.Home + new Vector3(offset.x, 0f, offset.y), 3f, out hit) &&
brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Slow))
{
_status = StateStatus.Running;
return;
}
}
}
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
{
base.StateThink(delta, brain, entity);
if (_status == StateStatus.Error)
return _status;
return brain.Navigator.Moving ? StateStatus.Running : StateStatus.Finished;
}
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
{
base.StateLeave(brain, entity);
brain.Navigator.Stop();
}
}
/// D233 route:<name>: walks a recorded route's points in order.
public class RouteState : BaseAIBrain.BasicAIState
{
private int _index = -1;
private int _step = 1;
private StateStatus _status = StateStatus.Error;
public int Index => _index;
public RouteState() : base(AIState.Roam)
{
}
public override void StateEnter(BaseAIBrain brain, BaseEntity entity)
{
base.StateEnter(brain, entity);
_status = StateStatus.Error;
RunicNpcPlayer npc = entity as RunicNpcPlayer;
Route route = npc != null ? _self?.RouteFor(npc) : null;
if (route == null)
return;
if (_index < 0 || _index >= route.Points.Count)
_index = Nearest(route, entity.transform.position);
_status = Go(brain, route) ? StateStatus.Running : StateStatus.Error;
}
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
{
base.StateThink(delta, brain, entity);
if (_status == StateStatus.Error)
return _status;
RunicNpcPlayer npc = entity as RunicNpcPlayer;
Route route = npc != null ? _self?.RouteFor(npc) : null;
if (route == null)
return _status = StateStatus.Error;
if (_index >= route.Points.Count)
_index = Nearest(route, entity.transform.position);
if (!brain.Navigator.Moving || Vector3.Distance(entity.transform.position, route.Points[_index].V) < 1.5f)
{
Advance(route);
if (!Go(brain, route))
return _status = StateStatus.Error;
}
return StateStatus.Running;
}
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
{
base.StateLeave(brain, entity);
brain.Navigator.Stop();
}
/// Sets off for the current point, skipping any Rust cannot path to.
private bool Go(BaseAIBrain brain, Route route)
{
for (int tries = 0; tries < route.Points.Count; tries++)
{
if (brain.Navigator.SetDestination(route.Points[_index].V, BaseNavigator.NavigationSpeed.Slow))
return true;
Advance(route);
}
return false;
}
private void Advance(Route route)
{
int n = route.Points.Count;
if (route.Loop)
{
_index = (_index + 1) % n;
return;
}
if (_index + _step < 0 || _index + _step >= n)
_step = -_step;
_index += _step;
}
private static int Nearest(Route route, Vector3 at)
{
int best = 0;
float bestDistance = float.MaxValue;
for (int i = 0; i < route.Points.Count; i++)
{
float d = (route.Points[i].V - at).sqrMagnitude;
if (d < bestDistance)
{
bestDistance = d;
best = i;
}
}
return best;
}
}
///
/// Our chase, for a wander or route roamer: close to three quarters of the attack
/// range, never past the chase range from home. A target that stays out of reach while ours
/// stands at that edge for 5 s is given up (the leash), and ours walks home.
///
public class PursueState : BaseAIBrain.BasicAIState
{
private float _next;
private float _edgeSince = -1f;
public PursueState() : base(AIState.Chase)
{
AgrresiveState = true;
}
public override void StateEnter(BaseAIBrain brain, BaseEntity entity)
{
base.StateEnter(brain, entity);
_next = 0f;
_edgeSince = -1f;
}
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
{
base.StateThink(delta, brain, entity);
BaseEntity target = brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot);
RunicNpcPlayer npc = entity as RunicNpcPlayer;
if (target == null || npc == null || npc.Profile == null)
return StateStatus.Error;
Vector3 here = entity.transform.position;
Vector3 there = target.transform.position;
float distance = Vector3.Distance(here, there);
if (distance <= 10f || brain.Senses.Memory.IsLOS(target))
brain.Navigator.SetFacingDirectionEntity(target);
else
brain.Navigator.ClearFacingDirectionOverride();
if (Time.time < _next)
return StateStatus.Running;
_next = Time.time + UnityEngine.Random.Range(0.5f, 1f);
float stopAt = Mathf.Max(2f, npc.Profile.Ranges.Attack * 0.75f);
if (distance <= stopAt)
{
brain.Navigator.Stop();
_edgeSince = -1f;
return StateStatus.Running;
}
Vector3 goal = there - (there - here).normalized * stopAt;
float chase = npc.Profile.Ranges.Chase;
Vector3 fromHome = goal - npc.Home;
fromHome.y = 0f;
bool clamped = chase > 0f && fromHome.magnitude > chase;
if (clamped)
goal = npc.Home + fromHome.normalized * chase;
if (clamped && distance > npc.Profile.Ranges.Attack)
{
if (_edgeSince < 0f)
_edgeSince = Time.time;
if (Time.time - _edgeSince >= 5f)
{
(brain as RunicNpcBrain)?.Leash(npc);
return StateStatus.Error;
}
}
else
_edgeSince = -1f;
NavMeshHit hit;
if (!SampleNavmesh(goal, 3f, out hit))
return StateStatus.Error;
brain.Navigator.SetDestination(hit.position, distance > 10f ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal);
return StateStatus.Running;
}
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
{
base.StateLeave(brain, entity);
brain.Navigator.Stop();
brain.Navigator.ClearFacingDirectionOverride();
}
}
///
/// A sentry's stand-in for every state that would move it: it stops, and turns to face the
/// design's current target. The design's attack events still run in it.
///
public class HoldState : BaseAIBrain.BasicAIState
{
public HoldState(AIState type, bool aggressive) : base(type)
{
AgrresiveState = aggressive;
}
public override void StateEnter(BaseAIBrain brain, BaseEntity entity)
{
base.StateEnter(brain, entity);
brain.Navigator.Stop();
}
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
{
base.StateThink(delta, brain, entity);
BaseEntity target = brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot);
if (target != null)
brain.Navigator.SetFacingDirectionEntity(target);
else
brain.Navigator.ClearFacingDirectionOverride();
return StateStatus.Running;
}
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
{
base.StateLeave(brain, entity);
brain.Navigator.ClearFacingDirectionOverride();
}
}
#endregion
#region Registry, owners, spawning
/// Every live NPC of ours, by net id.
private readonly Dictionary _npcs = new Dictionary();
private sealed class Slot
{
public RunicNpcPlayer Npc;
public float DiedAt = float.NegativeInfinity;
public bool Queued;
}
private sealed class PlacementState
{
public string Id;
public Placement Data;
public Slot[] Slots;
public string Waiting;
public string LastError;
/// Set while its spot is off the navmesh and its NPCs stand on the nearest (D239).
public string Note;
}
private readonly Dictionary _placements = new Dictionary();
private sealed class SpawnRequest
{
public PlacementState Placement;
public int Slot;
}
private readonly Queue _queue = new Queue();
private bool _pumpScheduled;
private bool _navmeshWaitLogged;
private float _tokens;
private float _tokensAt;
private int CountOwner(string owner) => _npcs.Values.Count(n => n.Owner == owner);
private int CountProfile(string profile) => _npcs.Values.Count(n => n.ProfileName == profile);
/// Null when a cap allows one more; otherwise which cap, for the caller.
private string CapRefusal(string owner, string profile)
{
CapsConfig caps = _config.Caps;
if (caps.Total > 0 && _npcs.Count >= caps.Total)
return $"the server's cap of {caps.Total} RunicNPC NPCs is reached";
if (caps.PerOwner > 0 && CountOwner(owner) >= caps.PerOwner)
return $"the cap of {caps.PerOwner} per owner is reached for {owner}";
if (caps.PerProfile > 0 && CountProfile(profile) >= caps.PerProfile)
return $"the cap of {caps.PerProfile} per profile is reached for '{profile}'";
return null;
}
/// The spawn-rate cap, a token bucket; always true when no rate is set.
private bool TakeSpawnToken()
{
float rate = _config.Caps.SpawnsPerSecond;
if (rate <= 0f)
return true;
float now = Time.realtimeSinceStartup;
_tokens = Mathf.Min(Mathf.Max(1f, rate), _tokens + (now - _tokensAt) * rate);
_tokensAt = now;
if (_tokens < 1f)
return false;
_tokens -= 1f;
return true;
}
private static Profile Effective(Profile profile, JObject overrides)
{
JObject merged = JObject.FromObject(profile);
if (overrides != null)
merged.Merge(overrides, new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Replace });
return merged.ToObject();
}
///
/// Spawns one NPC now, or returns null with the reason. Every path into the world comes
/// through here: the API, and the placement queue.
///
private RunicNpcPlayer Spawn(string profileName, Profile p, Vector3 at, float yaw, string owner, Movement movementOverride,
string placementId, int slot, out string error)
{
if (!_ready || _unloading) { error = "RunicNPC is not ready"; return null; }
if (!NavmeshReady()) { error = "the map's navmesh is not built yet"; return null; }
error = CapRefusal(owner, profileName);
if (error != null) return null;
Movement movement = movementOverride ?? p.Movement;
string kind, route;
ParseMode(movement.Mode, out kind, out route);
if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route))
{
error = $"route '{route}' does not exist";
return null;
}
Vector3 position = at;
if (!p.IsSentry)
{
// Rust's sampler reaches further down than across: without the height check, a spot
// on a roof finds the ground below it (stage 2).
NavMeshHit hit;
if (!SampleNavmesh(at, Scatter, out hit) || Mathf.Abs(hit.position.y - at.y) > MaxDrop)
{
error = "the spot is not on Rust's navmesh, and only a sentry may stand off it (D219)";
return null;
}
position = hit.position;
}
if (!TakeSpawnToken())
{
error = $"the spawn-rate cap of {_config.Caps.SpawnsPerSecond}/s is reached";
return null;
}
RunicNpcPlayer npc = CreateNpc(p, position, yaw, out error);
if (npc == null)
return null;
npc.RnName = p.Names[UnityEngine.Random.Range(0, p.Names.Count)];
npc.ProfileName = profileName;
npc.Profile = p;
npc.Owner = owner;
npc.PlacementId = placementId;
npc.Slot = slot;
npc.Home = position;
npc.MoveKind = kind;
npc.MoveRadius = movement.Radius;
npc.RouteName = route;
// CreateEntity(..., startActive: false) leaves the GameObject inactive and skips exactly this
// call, which GameManager.CreatePrefab makes when active is true. Without it the entity still
// spawns and thinks, but Unity never starts the brain: no design, no navigator, no state
// (stage 1, Q1).
npc.gameObject.AwakeFromInstantiate();
npc.Spawn();
npc.InitializeHealth(p.Health, p.Health);
npc.Kit = p.Kits[UnityEngine.Random.Range(0, p.Kits.Count)];
npc.inventory.Strip();
object given = Kits?.Call("GiveKit", npc, npc.Kit);
if (given is string)
PrintWarning($"Kits did not give '{npc.Kit}' to {npc.RnName} ({profileName}): {given}");
npc.EquipWeapon();
_npcs[npc.NetId] = npc;
Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner);
return npc;
}
/// Removes one NPC from the registry, however it went: death, despawn or unload.
private void Unregister(RunicNpcPlayer npc)
{
if (!_npcs.Remove(npc.NetId))
return;
if (npc.PlacementId != null)
{
PlacementState ps;
if (_placements.TryGetValue(npc.PlacementId, out ps) && npc.Slot >= 0 && npc.Slot < ps.Slots.Length && ps.Slots[npc.Slot].Npc == npc)
{
ps.Slots[npc.Slot].Npc = null;
ps.Slots[npc.Slot].DiedAt = Time.realtimeSinceStartup;
}
}
if (!npc.Died && !_unloading)
Interface.CallHook("OnRunicNpcDespawned", npc, npc.Owner);
}
private int Despawn(IEnumerable npcs)
{
int n = 0;
foreach (RunicNpcPlayer npc in npcs.ToList())
{
if (npc == null || npc.IsDestroyed)
continue;
Unregister(npc);
npc.Kill();
n++;
}
return n;
}
///
/// A death. It is unregistered at once, so a placement's respawn delay counts from the death,
/// not from whenever the body's entity is finally removed.
///
private void HandleDied(RunicNpcPlayer npc, HitInfo info)
{
var contributors = new Dictionary(npc.Contributors);
Unregister(npc);
Interface.CallHook("OnRunicNpcDied", npc, npc.ProfileName, npc.Owner, info, contributors);
}
private void 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 kv in _placementFile.Placements)
{
string error = ValidatePlacement(kv.Value);
if (error != null)
{
PrintWarning($"Placement '{kv.Key}' is ignored: {error}");
continue;
}
_placements[kv.Key] = NewState(kv.Key, kv.Value);
}
}
private static PlacementState NewState(string id, Placement data)
{
var ps = new PlacementState { Id = id, Data = data, Slots = new Slot[data.Count] };
for (int i = 0; i < ps.Slots.Length; i++)
ps.Slots[i] = new Slot();
return ps;
}
/// Why a placement cannot spawn right now, or null (D237: it waits, it is not removed).
private string WaitReason(Placement data)
{
Profile p;
if (!_profiles.TryGetValue(data.Profile, out p))
return _refused.ContainsKey(data.Profile) ? $"profile '{data.Profile}' is refused: {_refused[data.Profile]}" : $"profile '{data.Profile}' is missing";
Movement movement = data.Movement ?? p.Movement;
string kind, route;
ParseMode(movement.Mode, out kind, out route);
if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route))
return $"route '{route}' is missing";
return null;
}
/// Once a second: what each placement is waiting for, and which slots are due.
private void TickPlacements()
{
if (_placementsDirty)
SavePlacements();
if (!_ready || _unloading)
return;
float now = Time.realtimeSinceStartup;
bool queued = false;
foreach (PlacementState ps in _placements.Values)
{
string waiting = WaitReason(ps.Data);
if (waiting != null)
{
if (ps.Waiting != waiting)
PrintWarning($"Placement '{ps.Id}' waits: {waiting}");
ps.Waiting = waiting;
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
foreach (Slot s in ps.Slots)
{
s.DiedAt = float.NegativeInfinity;
s.Queued = false;
}
continue;
}
if (ps.Waiting != null)
Puts($"Placement '{ps.Id}' no longer waits.");
ps.Waiting = null;
if (ps.Data.RespawnMode == "group")
{
if (ps.Slots.All(s => s.Npc == null && !s.Queued) && now >= ps.Slots.Max(s => s.DiedAt) + ps.Data.Respawn)
for (int i = 0; i < ps.Slots.Length; i++)
queued |= Enqueue(ps, i);
}
else
{
for (int i = 0; i < ps.Slots.Length; i++)
if (ps.Slots[i].Npc == null && !ps.Slots[i].Queued && now >= ps.Slots[i].DiedAt + ps.Data.Respawn)
queued |= Enqueue(ps, i);
}
}
if (queued)
SchedulePump();
}
private bool Enqueue(PlacementState ps, int slot)
{
ps.Slots[slot].Queued = true;
_queue.Enqueue(new SpawnRequest { Placement = ps, Slot = slot });
return true;
}
private void SchedulePump()
{
if (_pumpScheduled || _unloading)
return;
_pumpScheduled = true;
NextFrame(Pump);
}
private void PumpLater(float seconds)
{
_pumpScheduled = true;
timer.Once(seconds, () =>
{
_pumpScheduled = false;
SchedulePump();
});
}
///
/// Spawns queued placement slots within the frame budget, and carries the rest to the next
/// frame (stage 1, Q5). Nothing spawns until the map's navmesh is built (Q4).
///
private void Pump()
{
_pumpScheduled = false;
if (_unloading)
return;
if (!NavmeshReady())
{
if (!_navmeshWaitLogged)
Puts($"Waiting for the map's navmesh before spawning {_queue.Count} placement NPC(s); on a map's first boot this takes minutes.");
_navmeshWaitLogged = true;
PumpLater(5f);
return;
}
Stopwatch clock = Stopwatch.StartNew();
while (_queue.Count > 0 && clock.Elapsed.TotalMilliseconds < _config.SpawnBudgetMs)
{
SpawnRequest r = _queue.Peek();
if (_config.Caps.SpawnsPerSecond > 0f && _tokens < 1f && !WouldHaveToken())
{
PumpLater(0.25f);
return;
}
_queue.Dequeue();
Slot slot = r.Placement.Slots.Length > r.Slot ? r.Placement.Slots[r.Slot] : null;
if (slot == null || !_placements.ContainsKey(r.Placement.Id) || _placements[r.Placement.Id] != r.Placement)
continue;
slot.Queued = false;
if (slot.Npc != null || r.Placement.Waiting != null)
continue;
SpawnSlot(r.Placement, r.Slot);
}
if (_queue.Count > 0)
SchedulePump();
}
private bool WouldHaveToken()
{
float rate = _config.Caps.SpawnsPerSecond;
return _tokens + (Time.realtimeSinceStartup - _tokensAt) * rate >= 1f;
}
private void SpawnSlot(PlacementState ps, int slot)
{
Placement data = ps.Data;
Profile p = _profiles[data.Profile];
Vector3 spot = data.Position.V;
Vector3 at = spot;
if (slot > 0)
{
// 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;
}
/// The count a placement plans, whether or not its NPCs are alive now.
private int PlannedTotal() =>
_placements.Values.Sum(ps => ps.Slots.Length) + _npcs.Values.Count(n => n.PlacementId == null);
#endregion
#region The cost warning (D227)
///
/// What NPCs cost the server, from stage 1's measurements on both
/// frameworks (PLAN.md §9, stage 1, Q5). The rig ran about 50 fps empty, so the warning
/// says what NPCs ADD to a frame, not an fps.
///
private static string CostWarning(int total)
{
if (total <= 0)
return "RunicNPC: no NPCs on this server.";
float hundreds = total / 100f;
string estimate = total > 100 ? " (measured up to 100; beyond that this is an estimate)" : "";
return $"RunicNPC: {total} NPC(s) on this server{estimate}. Measured on a test server, they add about " +
$"{Mathf.Max(0.1f, hundreds * 1.5f):0.#} ms to every server frame while idle. If all fight at once they add about " +
$"{hundreds * 5f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " +
$"{Mathf.Max(0.3f, hundreds * 2.5f):0.#} s.";
}
#endregion
#region Lifecycle and hooks
private void Init()
{
_self = this;
foreach (string hook in ExpectedHooks)
_hookCounts[hook] = 0L;
RegisterVerbs();
}
private void OnServerInitialized()
{
MarkHook("OnServerInitialized");
ResolveFields();
LoadData();
RevalidateProfiles();
RebuildPlacements();
_ready = true;
timer.Every(1f, TickPlacements);
Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " +
$"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}");
TickPlacements();
}
private void OnServerSave()
{
MarkHook("OnServerSave");
if (_placementsDirty)
SavePlacements();
}
private void OnEntityKill(BaseNetworkable entity)
{
MarkHook("OnEntityKill");
RunicNpcPlayer npc = entity as RunicNpcPlayer;
if (npc != null)
Unregister(npc);
}
/// A plugin's NPCs die with it (§2: plugin:<name>).
private void OnPluginUnloaded(Plugin plugin)
{
MarkHook("OnPluginUnloaded");
if (plugin == null || plugin == this)
return;
string owner = "plugin:" + plugin.Name;
int n = Despawn(_npcs.Values.Where(x => x.Owner == owner));
if (n > 0)
Puts($"{plugin.Name} unloaded: its {n} NPC(s) were removed.");
}
private void Unload()
{
_unloading = true;
Despawn(_npcs.Values);
_npcs.Clear();
if (_placementsDirty)
SavePlacements();
_self = null;
}
private void MarkHook(string name)
{
long count;
_hookCounts.TryGetValue(name, out count);
_hookCounts[name] = count + 1;
}
#endregion
#region The API (PLAN.md §4, docs/runicnpc/API.md)
// Every call is private: Oxide's Call reaches a non-public method by name, and a public one only
// with [HookMethod] (the PR check holds this). Each returns a value or null and says why in the log.
private int RunicNpc_ApiVersion() => ApiVersion;
private BasePlayer RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)
{
string error = null;
RunicNpcPlayer npc = null;
Profile p;
if (owner == null || !OwnerRule.IsMatch(owner))
error = "owner must be run: or plugin: (placement: owners are RunicNPC's own)";
else if (profile == null || !_profiles.TryGetValue(profile, out p))
error = profile != null && _refused.ContainsKey(profile) ? $"profile '{profile}' is refused: {_refused[profile]}" : $"no profile '{profile}'";
else
{
Profile effective = p;
if (overrides != null)
{
try
{
effective = Effective(p, overrides);
error = ValidateProfile(effective);
}
catch (Exception e)
{
error = "overrides could not be applied: " + e.Message;
}
}
if (error == null)
npc = Spawn(profile, effective, at, 0f, owner, null, null, -1, out error);
}
if (npc == null)
PrintWarning($"RunicNpc_Spawn('{profile}', {owner}) refused: {error}");
return npc;
}
private int RunicNpc_Despawn(ulong netId)
{
RunicNpcPlayer npc;
return _npcs.TryGetValue(netId, out npc) ? Despawn(new[] { npc }) : 0;
}
private int RunicNpc_DespawnOwner(string owner) => owner == null ? 0 : Despawn(_npcs.Values.Where(n => n.Owner == owner));
private List> RunicNpc_List(string owner) =>
_npcs.Values.Where(n => owner == null || n.Owner == owner).Select(Describe).ToList();
private static Dictionary Describe(RunicNpcPlayer n)
{
Vector3 p = n.transform.position;
RunicNpcBrain brain = n.RnBrain;
return new Dictionary
{
["netId"] = n.NetId,
["profile"] = n.ProfileName,
["name"] = n.RnName,
["owner"] = n.Owner,
["placement"] = n.PlacementId,
["kit"] = n.Kit,
["position"] = new Dictionary { ["x"] = p.x, ["y"] = p.y, ["z"] = p.z },
["home"] = new Dictionary { ["x"] = n.Home.x, ["y"] = n.Home.y, ["z"] = n.Home.z },
["health"] = n.health,
["maxHealth"] = n.MaxHealth(),
["movement"] = n.MoveKind == "route" ? "route:" + n.RouteName : n.MoveKind,
["rest"] = brain != null ? brain.Rest.ToString() : "?",
["state"] = brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none",
["leashes"] = brain != null ? brain.Leashes : 0,
["regrounded"] = brain != null ? brain.Regrounded : 0
};
}
private JObject RunicNpc_Profiles()
{
JObject all = JObject.FromObject(_profileFile);
all["refused"] = JObject.FromObject(_refused);
return all;
}
///
/// Replaces the whole profile set, as a site does on every push (D221), and marks the
/// server managed. Returns the profiles refused and why; the rest are in use at once.
///
private Dictionary RunicNpc_SetProfiles(JObject all)
{
Dictionary profiles;
try
{
profiles = all != null ? all.ToObject>() : new Dictionary();
}
catch (Exception e)
{
return new Dictionary { ["*"] = "the profile set could not be read: " + e.Message };
}
_profileFile.Profiles = profiles;
_profileFile.Managed = true;
SaveProfiles();
RevalidateProfiles();
TickPlacements();
return new Dictionary(_refused);
}
private List> RunicNpc_Placements() => _placements.Values.Select(ps => new Dictionary
{
["id"] = ps.Id,
["placement"] = JObject.FromObject(ps.Data),
["alive"] = ps.Slots.Count(s => s.Npc != null),
["waiting"] = ps.Waiting,
["lastError"] = ps.LastError,
["note"] = ps.Note
}).ToList();
/// Adds or replaces a placement (D222). Null on success; otherwise why not.
private string RunicNpc_SetPlacement(string id, JObject placement)
{
if (id == null || !NameRule.IsMatch(id))
return "id: 1–40 of a-z, 0-9, _ and -";
Placement data;
try
{
data = placement?.ToObject();
}
catch (Exception e)
{
return "the placement could not be read: " + e.Message;
}
return SetPlacement(id, data);
}
///
/// Adds or replaces a placement, for the API and rnpc place 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, ).
///
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)";
}
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;
}
///
/// API 3 (D249): creates a placement and names it as rnpc place does (D241, D246). A
/// position without y 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 {id, position, built, cost}, or {error}.
///
private Dictionary RunicNpc_AddPlacement(JObject placement)
{
Placement data;
try
{
data = placement?.ToObject();
}
catch (Exception e)
{
return new Dictionary { ["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 { ["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 { ["error"] = error };
}
return new Dictionary
{
["id"] = id,
["position"] = new Dictionary { ["x"] = data.Position.X, ["y"] = data.Position.Y, ["z"] = data.Position.Z },
["built"] = built,
["cost"] = CostWarning(PlannedTotal())
};
}
/// API 3 (D249): renames a placement, as rnpc rename does. Null on success.
private string RunicNpc_RenamePlacement(string from, string to) => from == null || to == null ? "Name both placements." : RenamePlacement(from, to);
/// API 3 (D249): respawns a placement's NPCs now, as rnpc respawn. How many, or -1 if there is no such placement.
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;
}
///
/// 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.
///
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;
}
///
/// API 3 (D249): raised whenever a placement is set, removed or renamed, from the API or in
/// game alike. change is set, removed or renamed (with previous).
///
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);
/// Adds or replaces a route (D234). Every point must be on Rust's navmesh.
private string RunicNpc_SetRoute(string name, JObject route)
{
if (name == null || !NameRule.IsMatch(name))
return "name: 1–40 of a-z, 0-9, _ and -";
Route data;
try
{
data = route?.ToObject();
}
catch (Exception e)
{
return "the route could not be read: " + e.Message;
}
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;
/// The cost warning (D227) for the NPCs this server plans, plus .
private string RunicNpc_CostWarning(int adding) => CostWarning(PlannedTotal() + Math.Max(0, adding));
#endregion
#region In game: the commands (PLAN.md §5, stage 3)
// `/rnpc ` in chat and `rnpc.` 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";
/// How far an admin's look reaches, for place, info and remove.
private const float LookReach = 100f;
/// How far below or around a spot the navmesh is looked for when saying why it is off it.
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");
/// What a map point is put down on (D245): terrain and rock, never a building or a tree.
private static readonly int MapGroundMask = LayerMask.GetMask("Terrain", "World");
/// Who ran a command, and where the answer goes.
private sealed class Caller
{
/// Null for the server console.
public BasePlayer Player;
public bool Chat;
public readonly List Lines = new List();
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 Run;
public bool ConsoleOnly;
}
private Dictionary _verbs;
/// A route being recorded, per admin (D240). Kept in memory only: a reload discards it.
private sealed class Recording
{
public string Name;
public readonly List Points = new List();
}
private readonly Dictionary _recordings = new Dictionary();
private void RegisterVerbs()
{
permission.RegisterPermission(PermPlace, this);
permission.RegisterPermission(PermAdmin, this);
_verbs = new Dictionary
{
["place"] = new Verb { Permission = PermPlace, Usage = "place [count= respawn= mode=each|group move=wander|monument|route: radius=]", Run = (c, a) => CmdPlace(c, a, false) },
["here"] = new Verb { Permission = PermPlace, Usage = "here [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 ", 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 | point | undo | save [loop|back] | cancel | list | delete ", Run = CmdPath },
["tp"] = new Verb { Permission = PermAdmin, Usage = "tp ", Run = CmdTp },
["respawn"] = new Verb { Permission = PermAdmin, Usage = "respawn ", Run = CmdRespawn },
["clear"] = new Verb { Permission = PermAdmin, Usage = "clear ", Run = CmdClear },
["profile"] = new Verb { Permission = PermAdmin, Usage = "profile list | show | create [from=] | set | delete ", Run = CmdProfile, 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 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");
// ---- where an admin stands and looks ----
/// The ground, a floor or a roof the admin is looking at, within .
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;
}
///
/// 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.
///
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;
}
/// Null when a roamer may stand here (on Rust's navmesh, within 2 m up or down); otherwise why not.
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;
///
/// 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).
///
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;
}
/// D239: a spot on something players built, which can be destroyed under the NPC.
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;
}
///
/// D242's key=value options, in any order. The server console has no position, so it
/// alone may also give at=x,y,z and yaw=.
///
private static string ParseOptions(Caller c, string[] args, int from, out PlaceOptions o)
{
o = new PlaceOptions();
var seen = new HashSet();
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=";
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:";
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;
default:
return $"there is no option {key}=. Options: count= respawn= mode= move= radius=";
}
}
return null;
}
/// D241: the profile's name and the first free number.
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;
}
}
///
/// Checks, names and sets a NEW placement: rnpc place, rnpc here and
/// RunicNpc_AddPlacement 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.
///
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
};
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}. Rename it with rnpc rename {id} .");
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 ----
/// A placement named in the command, or that of the NPC the admin is looking at.
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 ");
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 places = _placements.Values.Where(ps => Vector3.Distance(ps.Data.Position.V, here) <= radius)
.OrderBy(ps => Vector3.Distance(ps.Data.Position.V, here)).ToList();
List 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");
}
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 makes one.");
return;
}
c.Say(_profileFile.Managed ? "Profiles (managed by this server's website):" : "Profiles:");
foreach (KeyValuePair 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 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 ");
return;
}
c.Player.Teleport(ps.Data.Position.V + Vector3.up * 0.2f);
c.Say($"At {ps.Id}.");
}
/// A placement's NPCs, despawned and spawned fresh now, without waiting for the delay.
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 ");
return;
}
List 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 { ps };
}
int n = which.Sum(RespawnNow);
TickPlacements();
List 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 , e.g. run:, plugin: or placement:");
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 ; 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 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 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 .");
foreach (KeyValuePair 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 ");
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} changes it.");
return;
}
Profile made = new Profile { Names = new List { "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 [from=]");
return;
}
made = JObject.FromObject(from).ToObject();
}
_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 , 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.");
}
/// One field of a profile, by its JSON path (health, ranges.sense, kits).
private string SetProfileField(string name, Profile profile, string field, string value)
{
JObject o = JObject.FromObject(profile);
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