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