What the rigs' stage 5 runs found in the plugin: - an escort whose charge was destroyed was never released: Unity's == says a destroyed entity is null, so CheckEscort returned before it looked. It now tests the reference itself (D269); - our own fight's line of sight no longer uses Rust's CanSeeTarget for an NPC target: its rays are unbounded, so on open ground they run past the target into the terrain behind it. A bounded linecast to the target's centre, which counts the target's own collider as seen; - a rocket's line of sight: Rust's sensed one for a real player, ours for an NPC or an animal, which are never in Rust's player memory. tools/RunicNpcTest.cs 0.5.0 adds `rnt.run s5`: 52 checks of the faction table and its refusals, faction fights, the leash, shooting back, the alarm, allies spared and defended (players and buildings), escort, the guard, the tether, PVE, turrets (`ignore` through the hook, `always` on the map's own sentries with the org lead's condition: no stock scientist or bandit guard ever targeted), and the kit's extras used up. Also `rnt.duel`, `rnt.kitprobe`, `rnt.rocketprobe` and `rnt.list`, for looking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
5098 lines
219 KiB
C#
5098 lines
219 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 = 4;
|
||
|
||
/// <summary>
|
||
/// Every hook this plugin implements. <c>rnpc.status</c> reports which have fired, because
|
||
/// a hook that matches nothing is silent on both frameworks: this list is how a renamed
|
||
/// one is noticed. The PR check holds it equal to the methods below.
|
||
/// </summary>
|
||
private static readonly string[] ExpectedHooks =
|
||
{
|
||
"OnServerInitialized",
|
||
"OnServerSave",
|
||
"OnEntityKill",
|
||
"OnPluginUnloaded",
|
||
"OnEntitySpawned",
|
||
"OnEntityTakeDamage",
|
||
"CanBeTargeted",
|
||
"CanEntityTakeDamage"
|
||
};
|
||
|
||
private readonly Dictionary<string, long> _hookCounts = new Dictionary<string, long>();
|
||
|
||
/// <summary>The loaded plugin, for the NPC and brain classes, which Rust instantiates.</summary>
|
||
private static RunicNPC _self;
|
||
|
||
private bool _ready;
|
||
private bool _unloading;
|
||
|
||
private static readonly Regex NameRule = new Regex("^[a-z0-9_-]{1,40}$");
|
||
private static readonly Regex OwnerRule = new Regex(@"^(run|plugin):\S{1,64}$");
|
||
|
||
private const string DataProfiles = "RunicNPC/profiles";
|
||
private const string DataPlacements = "RunicNPC/placements";
|
||
private const string DataRoutes = "RunicNPC/routes";
|
||
|
||
/// <summary>How far from a placement's spot its NPCs after the first are scattered.</summary>
|
||
private const float Scatter = 3f;
|
||
|
||
/// <summary>How far above or below a roamer's spot the navmesh it is put on may be.</summary>
|
||
private const float MaxDrop = 2f;
|
||
|
||
#region Config
|
||
|
||
private PluginConfig _config;
|
||
|
||
/// <summary>
|
||
/// The plugin's config. <b>No caps by default</b> (D227): each is 0, meaning none, until an
|
||
/// admin sets it. The cost warning is shown instead, wherever NPCs are added.
|
||
/// </summary>
|
||
private sealed class PluginConfig
|
||
{
|
||
[JsonProperty("caps")] public CapsConfig Caps = new CapsConfig();
|
||
|
||
/// <summary>
|
||
/// Milliseconds per server frame the placement queue may spend spawning. Stage 1
|
||
/// measured about 5 ms per NPC, and 100 in one frame as a half-second hitch.
|
||
/// </summary>
|
||
[JsonProperty("spawnBudgetMs")] public float SpawnBudgetMs = 8f;
|
||
}
|
||
|
||
private sealed class CapsConfig
|
||
{
|
||
[JsonProperty("total")] public int Total;
|
||
[JsonProperty("perOwner")] public int PerOwner;
|
||
[JsonProperty("perProfile")] public int PerProfile;
|
||
[JsonProperty("spawnsPerSecond")] public float SpawnsPerSecond;
|
||
}
|
||
|
||
protected override void LoadDefaultConfig() => _config = new PluginConfig();
|
||
|
||
protected override void LoadConfig()
|
||
{
|
||
base.LoadConfig();
|
||
try
|
||
{
|
||
_config = Config.ReadObject<PluginConfig>();
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
PrintError($"The config could not be read, so the defaults are used: {e.Message}");
|
||
_config = null;
|
||
}
|
||
|
||
if (_config == null)
|
||
_config = new PluginConfig();
|
||
if (_config.Caps == null)
|
||
_config.Caps = new CapsConfig();
|
||
SaveConfig();
|
||
}
|
||
|
||
protected override void SaveConfig() => Config.WriteObject(_config, true);
|
||
|
||
#endregion
|
||
|
||
#region Data: profiles, placements, routes
|
||
|
||
/// <summary>A position in a data file.</summary>
|
||
public sealed class Pos
|
||
{
|
||
[JsonProperty("x")] public float X;
|
||
[JsonProperty("y")] public float Y;
|
||
[JsonProperty("z")] public float Z;
|
||
|
||
[JsonIgnore] public Vector3 V => new Vector3(X, Y, Z);
|
||
|
||
public static Pos Of(Vector3 v) => new Pos { X = v.x, Y = v.y, Z = v.z };
|
||
}
|
||
|
||
/// <summary>How a roamer moves (D233): <c>wander</c>, <c>monument</c> or <c>route:<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>();
|
||
|
||
// ---- stage 5 (D254–D261, D268, D271) ----
|
||
|
||
/// <summary>Its faction (D254), or null: then its allies are its own profile's NPCs (D256).</summary>
|
||
[JsonProperty("faction")] public string Faction;
|
||
|
||
/// <summary>
|
||
/// Its own exceptions, which win over the faction table (D254): a faction, <c>scientists</c>,
|
||
/// <c>animals</c> or <c>profile:<name></c>, each <c>hostile</c>, <c>neutral</c> or <c>allied</c>.
|
||
/// </summary>
|
||
[JsonProperty("relations")] public Dictionary<string, string> Relations = new Dictionary<string, string>();
|
||
|
||
/// <summary>Metres within which its allies join a fight one of them is in (D256); 0 is off.</summary>
|
||
[JsonProperty("alertRadius")] public float AlertRadius = 40f;
|
||
|
||
/// <summary><c>default</c>, <c>ignore</c> or <c>always</c> (D259, D264, D267).</summary>
|
||
[JsonProperty("turrets")] public string Turrets = "default";
|
||
|
||
/// <summary>D271: off makes it unkillable by players, on every server, PVE or not.</summary>
|
||
[JsonProperty("hurtByPlayers")] public bool HurtByPlayers = true;
|
||
|
||
/// <summary>D271: off makes it harmless to players; it still fights NPCs and turrets.</summary>
|
||
[JsonProperty("hurtsPlayers")] public bool HurtsPlayers = true;
|
||
|
||
/// <summary>The kit's extras it uses (D260); all off by default, and each one used is used up (D265).</summary>
|
||
[JsonProperty("kitUse")] public KitUse KitUse = new KitUse();
|
||
|
||
[JsonIgnore] public bool IsSentry => Role == "sentry";
|
||
|
||
[JsonIgnore] public bool IsGuard => Role == "guard";
|
||
}
|
||
|
||
/// <summary>D260: the behaviours a kit's extra items allow, each opted into.</summary>
|
||
public sealed class KitUse
|
||
{
|
||
[JsonProperty("heal")] public bool Heal;
|
||
[JsonProperty("grenades")] public bool Grenades;
|
||
[JsonProperty("melee")] public bool Melee;
|
||
[JsonProperty("rockets")] public bool Rockets;
|
||
[JsonProperty("flamethrower")] public bool Flamethrower;
|
||
|
||
[JsonIgnore] public bool Any => Heal || Grenades || Melee || Rockets || Flamethrower;
|
||
}
|
||
|
||
/// <summary>One row of the faction table: a pair, both ways (D268).</summary>
|
||
public sealed class FactionPair
|
||
{
|
||
[JsonProperty("a")] public string A;
|
||
[JsonProperty("b")] public string B;
|
||
[JsonProperty("relation")] public string Relation = "neutral";
|
||
}
|
||
|
||
private sealed class ProfileFile
|
||
{
|
||
/// <summary>True once a site has pushed profiles (D221); in-game edits are then refused.</summary>
|
||
[JsonProperty("managed")] public bool Managed;
|
||
[JsonProperty("profiles")] public Dictionary<string, Profile> Profiles = new Dictionary<string, Profile>();
|
||
|
||
/// <summary>The faction table (D254), pushed with the profiles; <c>rnpc.faction</c> edits it standalone.</summary>
|
||
[JsonProperty("factions")] public List<FactionPair> Factions = new List<FactionPair>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// An admin's (or the site's) persistent placement (D222). Only this is saved: its NPCs
|
||
/// never are, and are spawned fresh from it at boot (§2).
|
||
/// </summary>
|
||
public sealed class Placement
|
||
{
|
||
[JsonProperty("profile")] public string Profile;
|
||
[JsonProperty("position")] public Pos Position;
|
||
[JsonProperty("yaw")] public float Yaw;
|
||
[JsonProperty("count")] public int Count = 1;
|
||
|
||
/// <summary>Seconds from a death to the respawn.</summary>
|
||
[JsonProperty("respawn")] public float Respawn = 300f;
|
||
|
||
/// <summary><c>each</c> (the default) or <c>group</c> (D236).</summary>
|
||
[JsonProperty("respawnMode")] public string RespawnMode = "each";
|
||
|
||
/// <summary>Overrides the profile's movement for this placement only (D233); null keeps it.</summary>
|
||
[JsonProperty("movement", NullValueHandling = NullValueHandling.Ignore)] public Movement Movement;
|
||
|
||
/// <summary>A ZoneManager zone its NPCs never leave (D272); null for none.</summary>
|
||
[JsonProperty("tether", NullValueHandling = NullValueHandling.Ignore)] public string Tether;
|
||
}
|
||
|
||
private sealed class PlacementFile
|
||
{
|
||
[JsonProperty("placements")] public Dictionary<string, Placement> Placements = new Dictionary<string, Placement>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Points an admin records in game (stage 3) and a <c>route:<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()
|
||
{
|
||
ReadProfileFile();
|
||
_routeFile = ReadData<RouteFile>(DataRoutes);
|
||
if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary<string, Route>();
|
||
_placementFile = ReadData<PlacementFile>(DataPlacements);
|
||
if (_placementFile.Placements == null) _placementFile.Placements = new Dictionary<string, Placement>();
|
||
|
||
// Create the files on first boot: the plugin must make its own data directory, because one
|
||
// made from outside the game (a panel's file API) is not writable by it (PLAN.md §1.5).
|
||
SaveProfiles();
|
||
SaveRoutes();
|
||
SavePlacements();
|
||
}
|
||
|
||
private void ReadProfileFile()
|
||
{
|
||
_profileFile = ReadData<ProfileFile>(DataProfiles);
|
||
if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary<string, Profile>();
|
||
if (_profileFile.Factions == null) _profileFile.Factions = new List<FactionPair>();
|
||
}
|
||
|
||
private void SaveProfiles() => Interface.Oxide.DataFileSystem.WriteObject(DataProfiles, _profileFile);
|
||
|
||
private void SaveRoutes() => Interface.Oxide.DataFileSystem.WriteObject(DataRoutes, _routeFile);
|
||
|
||
private void SavePlacements()
|
||
{
|
||
Interface.Oxide.DataFileSystem.WriteObject(DataPlacements, _placementFile);
|
||
_placementsDirty = false;
|
||
}
|
||
|
||
/// <summary>Validates every profile in the file; only the valid ones are used.</summary>
|
||
private void RevalidateProfiles()
|
||
{
|
||
_profiles.Clear();
|
||
_refused.Clear();
|
||
foreach (KeyValuePair<string, Profile> kv in _profileFile.Profiles)
|
||
{
|
||
string error = NameRule.IsMatch(kv.Key) ? ValidateProfile(kv.Value) : "the name must be 1–40 of a-z, 0-9, _ and -";
|
||
if (error == null)
|
||
_profiles[kv.Key] = kv.Value;
|
||
else
|
||
_refused[kv.Key] = error;
|
||
}
|
||
|
||
foreach (KeyValuePair<string, string> kv in _refused)
|
||
PrintWarning($"Profile '{kv.Key}' is refused: {kv.Value}");
|
||
|
||
string factions = ValidateFactions(_profileFile.Factions);
|
||
if (factions != null)
|
||
{
|
||
PrintWarning($"The faction table is ignored: {factions}");
|
||
_factionTable.Clear();
|
||
}
|
||
else
|
||
BuildFactionTable(_profileFile.Factions);
|
||
|
||
RebuildHunts();
|
||
}
|
||
|
||
/// <summary>Null when the profile can spawn; otherwise the reason, for the log and the caller.</summary>
|
||
private string ValidateProfile(Profile p)
|
||
{
|
||
if (p == null) return "it is empty";
|
||
if (p.Names == null || p.Names.Count == 0 || p.Names.Any(string.IsNullOrWhiteSpace))
|
||
return "names: give at least one, and no blank ones";
|
||
if (p.Kits == null || p.Kits.Count == 0)
|
||
return "kits: give at least one; Kits is how an NPC is equipped (D217)";
|
||
foreach (string kit in p.Kits)
|
||
if (!KitExists(kit))
|
||
return $"kits: this server has no kit '{kit}'";
|
||
if (ResolvePrefab(p.Prefab) == null)
|
||
return $"prefab: '{p.Prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)";
|
||
if (p.Role != "roamer" && p.Role != "sentry" && p.Role != "guard")
|
||
return $"role: '{p.Role}' is not roamer, sentry or guard";
|
||
string movement = ValidateMovement(p.Movement);
|
||
if (movement != null) return movement;
|
||
if (!(p.Health > 0f)) return "health: must be above 0";
|
||
if (p.DamageDealt < 0f) return "damageDealt: must not be negative";
|
||
if (p.DamageTaken == null || p.DamageTaken.Head < 0f || p.DamageTaken.Body < 0f || p.DamageTaken.Legs < 0f)
|
||
return "damageTaken: head, body and legs must not be negative";
|
||
if (p.AimCone < 0f) return "aimCone: must not be negative";
|
||
if (p.Ranges == null || !(p.Ranges.Sense > 0f) || !(p.Ranges.Attack > 0f) || p.Ranges.LoseTarget < p.Ranges.Sense || p.Ranges.Chase < 0f)
|
||
return "ranges: sense and attack above 0, loseTarget at least sense, chase not negative";
|
||
if (p.VisionCone < -1f || p.VisionCone > 1f) return "visionCone: between -1 and 1";
|
||
if (p.SleepDistance < 0f) return "sleepDistance: 0 (never sleeps) or more";
|
||
if (p.HealthThresholds == null || p.HealthThresholds.Any(t => !(t > 0f && t < 1f)))
|
||
return "healthThresholds: fractions between 0 and 1";
|
||
if (!string.IsNullOrEmpty(p.Faction) && (!NameRule.IsMatch(p.Faction) || BuiltInFactions.Contains(p.Faction)))
|
||
return $"faction: '{p.Faction}' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)";
|
||
if (p.Relations == null)
|
||
return "relations: an object, even an empty one";
|
||
foreach (KeyValuePair<string, string> kv in p.Relations)
|
||
{
|
||
if (!IsFactionKey(kv.Key, true))
|
||
return $"relations: '{kv.Key}' is not a faction, scientists, animals or profile:<name>";
|
||
if (!Relations.Contains(kv.Value))
|
||
return $"relations.{kv.Key}: '{kv.Value}' is not hostile, neutral or allied";
|
||
}
|
||
|
||
if (!(p.AlertRadius >= 0f)) return "alertRadius: 0 (off) or more";
|
||
if (p.Turrets != "default" && p.Turrets != "ignore" && p.Turrets != "always")
|
||
return $"turrets: '{p.Turrets}' is not default, ignore or always";
|
||
if (p.KitUse == null) return "kitUse: an object, even an empty one";
|
||
return null;
|
||
}
|
||
|
||
// ---- the faction table (D254, D268) ----
|
||
|
||
/// <summary>Rust's own two factions (D254). Neither can be a profile's faction.</summary>
|
||
private static readonly HashSet<string> BuiltInFactions = new HashSet<string> { "scientists", "animals" };
|
||
|
||
private static readonly HashSet<string> Relations = new HashSet<string> { "hostile", "neutral", "allied" };
|
||
|
||
/// <summary>A faction name, one of Rust's two, or (where a profile names it) <c>profile:<name></c>.</summary>
|
||
private static bool IsFactionKey(string key, bool profileAllowed)
|
||
{
|
||
if (string.IsNullOrEmpty(key)) return false;
|
||
if (BuiltInFactions.Contains(key) || NameRule.IsMatch(key)) return true;
|
||
return profileAllowed && key.StartsWith("profile:") && NameRule.IsMatch(key.Substring(8));
|
||
}
|
||
|
||
/// <summary>Null when the table can be used; otherwise the first problem.</summary>
|
||
private static string ValidateFactions(List<FactionPair> pairs)
|
||
{
|
||
if (pairs == null) return "it is missing";
|
||
var seen = new HashSet<string>();
|
||
for (int i = 0; i < pairs.Count; i++)
|
||
{
|
||
FactionPair f = pairs[i];
|
||
if (f == null || !IsFactionKey(f.A, false) || !IsFactionKey(f.B, false))
|
||
return $"factions[{i}]: a and b are faction names (1–40 of a-z, 0-9, _ and -), scientists or animals";
|
||
if (f.A == f.B)
|
||
return $"factions[{i}]: a faction is always allied to itself";
|
||
if (BuiltInFactions.Contains(f.A) && BuiltInFactions.Contains(f.B))
|
||
return $"factions[{i}]: both are Rust's own, and RunicNPC does not change how Rust's NPCs treat each other";
|
||
if (!Relations.Contains(f.Relation))
|
||
return $"factions[{i}]: '{f.Relation}' is not hostile, neutral or allied";
|
||
if (!seen.Add(PairKey(f.A, f.B)))
|
||
return $"factions[{i}]: {f.A} and {f.B} are given twice (one row is both ways, D268)";
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>The faction table, by pair, either way round (D268).</summary>
|
||
private readonly Dictionary<string, string> _factionTable = new Dictionary<string, string>();
|
||
|
||
private static string PairKey(string a, string b) => string.CompareOrdinal(a, b) < 0 ? a + "|" + b : b + "|" + a;
|
||
|
||
private void BuildFactionTable(List<FactionPair> pairs)
|
||
{
|
||
_factionTable.Clear();
|
||
foreach (FactionPair f in pairs)
|
||
_factionTable[PairKey(f.A, f.B)] = f.Relation;
|
||
}
|
||
|
||
private static string ValidateMovement(Movement m)
|
||
{
|
||
if (m == null) return "movement: missing";
|
||
string kind, route;
|
||
if (!ParseMode(m.Mode, out kind, out route))
|
||
return $"movement.mode: '{m.Mode}' is not wander, monument or route:<name>";
|
||
if (kind == "wander" && !(m.Radius > 0f))
|
||
return "movement.radius: a wanderer's radius must be above 0";
|
||
return null;
|
||
}
|
||
|
||
private static bool ParseMode(string mode, out string kind, out string route)
|
||
{
|
||
kind = null;
|
||
route = null;
|
||
if (mode == "wander" || mode == "monument")
|
||
{
|
||
kind = mode;
|
||
return true;
|
||
}
|
||
|
||
if (mode != null && mode.StartsWith("route:") && NameRule.IsMatch(mode.Substring(6)))
|
||
{
|
||
kind = "route";
|
||
route = mode.Substring(6);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private string ValidatePlacement(Placement p)
|
||
{
|
||
if (p == null) return "it is empty";
|
||
if (p.Profile == null || !NameRule.IsMatch(p.Profile)) return "profile: a profile name";
|
||
if (p.Position == null || float.IsNaN(p.Position.X) || float.IsNaN(p.Position.Y) || float.IsNaN(p.Position.Z))
|
||
return "position: x, y and z";
|
||
if (p.Count < 1) return "count: at least 1";
|
||
if (!(p.Respawn >= 1f)) return "respawn: at least 1 second";
|
||
if (p.RespawnMode != "each" && p.RespawnMode != "group") return "respawnMode: each or group";
|
||
if (p.Movement != null)
|
||
{
|
||
string movement = ValidateMovement(p.Movement);
|
||
if (movement != null) return movement;
|
||
}
|
||
|
||
if (p.Tether != null && !TetherNameRule.IsMatch(p.Tether))
|
||
return $"tether: '{p.Tether}' is not a ZoneManager zone id";
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>ZoneManager's zone ids are free text; RunicNPC asks only that they be one plain word.</summary>
|
||
private static readonly Regex TetherNameRule = new Regex(@"^[A-Za-z0-9_.:-]{1,64}$");
|
||
|
||
private static string ValidateRoute(Route r)
|
||
{
|
||
if (r == null || r.Points == null || r.Points.Count < 2) return "points: at least two";
|
||
for (int i = 0; i < r.Points.Count; i++)
|
||
{
|
||
NavMeshHit hit;
|
||
if (r.Points[i] == null || !SampleNavmesh(r.Points[i].V, 3f, out hit))
|
||
return $"points[{i}] is not on Rust's navmesh";
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Rust's facts: kits, prefabs, the navmesh
|
||
|
||
private bool KitExists(string kit) => Kits != null && !string.IsNullOrEmpty(kit) && Kits.Call("IsKit", kit) is bool b && b;
|
||
|
||
private static Dictionary<string, string> _prefabPaths;
|
||
|
||
/// <summary>A scientist prefab's full path from its short name, read from the game's manifest.</summary>
|
||
private static string ResolvePrefab(string shortName)
|
||
{
|
||
if (string.IsNullOrEmpty(shortName) || !shortName.StartsWith("scientistnpc_"))
|
||
return null;
|
||
if (_prefabPaths == null)
|
||
{
|
||
_prefabPaths = new Dictionary<string, string>();
|
||
// prefabProperties lists every prefab; the manifest's `entities` does not include the NPCs.
|
||
foreach (GameManifest.PrefabProperties prefab in GameManifest.Current.prefabProperties)
|
||
{
|
||
string path = prefab.name;
|
||
if (!path.StartsWith("assets/rust.ai/agents/npcplayer/humannpc/") || !path.EndsWith(".prefab"))
|
||
continue;
|
||
string name = path.Substring(path.LastIndexOf('/') + 1);
|
||
_prefabPaths[name.Substring(0, name.Length - ".prefab".Length)] = path;
|
||
}
|
||
}
|
||
|
||
string full;
|
||
return _prefabPaths.TryGetValue(shortName, out full) ? full : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Rust's own navmesh, the one a scientist walks (stage 1, Q4): its navigator is a Gen2
|
||
/// <c>RustNavMeshAgent</c>, and Unity's NavMesh queries see none of the open world.
|
||
/// </summary>
|
||
private static bool SampleNavmesh(Vector3 p, float radius, out NavMeshHit hit) =>
|
||
Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, radius, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas);
|
||
|
||
/// <summary>False until the map's navmesh is built: 8–10 minutes on a map's first boot (stage 1, Q4).</summary>
|
||
private static bool NavmeshReady() =>
|
||
Rust.Ai.Gen2.Nav.RustNavigation.Instance != null && Rust.Ai.Gen2.Nav.RustNavigation.Instance.IsDefaultNavmeshBuilt();
|
||
|
||
private static string Framework() =>
|
||
AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide";
|
||
|
||
#endregion
|
||
|
||
#region The swap (D223, D232)
|
||
|
||
// <fieldlist>
|
||
// Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 2573b149-ba5d-458a-9b83-7f5f32c76afc.
|
||
// Do not edit by hand: regenerate after a Rust update (D232).
|
||
private static readonly string[] SwapNpcFields =
|
||
{
|
||
"ScientistNPC.RadioChatterEffects",
|
||
"ScientistNPC.DeathEffects",
|
||
"ScientistNPC.deathStatName",
|
||
"ScientistNPC.IdleChatterRepeatRange",
|
||
"ScientistNPC.radioChatterType",
|
||
"HumanNPC.AdditionalLosBlockingLayer",
|
||
"HumanNPC.LootSpawnSlots",
|
||
"HumanNPC.aimConeScale",
|
||
"HumanNPC.lastDismountTime",
|
||
"NPCPlayer.MovementTickStartDelay",
|
||
"NPCPlayer.VirtualInfoZone",
|
||
"NPCPlayer.finalDestination",
|
||
"NPCPlayer.loadouts",
|
||
"NPCPlayer.movementMask",
|
||
"NPCPlayer.LegacyNavigation",
|
||
"NPCPlayer.NavAgent",
|
||
"NPCPlayer.damageScale",
|
||
"NPCPlayer.shortRange",
|
||
"NPCPlayer.attackLengthMaxShortRangeScale",
|
||
"BasePlayer.GestureViewModel",
|
||
"BasePlayer.currentTeam",
|
||
"BasePlayer.PetEntity",
|
||
"BasePlayer.lastPlayerVisibility",
|
||
"BasePlayer.eyeHistory",
|
||
"BasePlayer.tickHistory",
|
||
"BasePlayer.fallDamageEffect",
|
||
"BasePlayer.drownEffect",
|
||
"BasePlayer.playerFlags",
|
||
"BasePlayer.Belt",
|
||
"BasePlayer.weaponMoveSpeedScale",
|
||
"BasePlayer.clothingBlocksAiming",
|
||
"BasePlayer.clothingMoveSpeedReduction",
|
||
"BasePlayer.clothingWaterSpeedBonus",
|
||
"BasePlayer.clothingAccuracyBonus",
|
||
"BasePlayer.equippingBlocked",
|
||
"BasePlayer.eggVision",
|
||
"BasePlayer.designingAIEntity",
|
||
"BaseCombatEntity.spawnDeployableCorpseOnDeath",
|
||
"BaseCombatEntity.deployableCorpsePrefab",
|
||
"BaseCombatEntity.skeletonProperties",
|
||
"BaseCombatEntity.baseProtection",
|
||
"BaseCombatEntity.startHealth",
|
||
"BaseCombatEntity.pickup",
|
||
"BaseCombatEntity.repair",
|
||
"BaseCombatEntity.ShowHealthInfo",
|
||
"BaseCombatEntity.showHealthInfoThreshold",
|
||
"BaseCombatEntity.lifestate",
|
||
"BaseCombatEntity.sendsHitNotification",
|
||
"BaseCombatEntity.sendsMeleeHitNotification",
|
||
"BaseCombatEntity.markAttackerHostile",
|
||
"BaseCombatEntity.faction",
|
||
"BaseEntity.bounds",
|
||
"BaseEntity.impactEffect",
|
||
"BaseEntity.enableSaving",
|
||
"BaseEntity.syncPosition",
|
||
"BaseEntity.model",
|
||
"BaseEntity.flags",
|
||
"BaseEntity.HasBrain",
|
||
"BaseNetworkable.postNetworkUpdateComponents",
|
||
"BaseNetworkable.prefabID",
|
||
"BaseNetworkable.globalBroadcast",
|
||
"BaseNetworkable.globalNetworkBehavior",
|
||
"BaseNetworkable.globalBuildingBlock",
|
||
"BaseNetworkable.networkRange",
|
||
};
|
||
private static readonly string[] SwapBrainFields =
|
||
{
|
||
"BaseAIBrain.SendClientCurrentState",
|
||
"BaseAIBrain.UseQueuedMovementUpdates",
|
||
"BaseAIBrain.AllowedToSleep",
|
||
"BaseAIBrain.DefaultDesignSO",
|
||
"BaseAIBrain.Designs",
|
||
"BaseAIBrain.InstanceSpecificDesign",
|
||
"BaseAIBrain.SenseRange",
|
||
"BaseAIBrain.AttackRangeMultiplier",
|
||
"BaseAIBrain.TargetLostRange",
|
||
"BaseAIBrain.VisionCone",
|
||
"BaseAIBrain.CheckVisionCone",
|
||
"BaseAIBrain.CheckLOS",
|
||
"BaseAIBrain.IgnoreNonVisionSneakers",
|
||
"BaseAIBrain.IgnoreSneakersMaxDistance",
|
||
"BaseAIBrain.IgnoreNonVisionMaxDistance",
|
||
"BaseAIBrain.ListenRange",
|
||
"BaseAIBrain.SenseTypes",
|
||
"BaseAIBrain.HostileTargetsOnly",
|
||
"BaseAIBrain.IgnoreSafeZonePlayers",
|
||
"BaseAIBrain.MaxGroupSize",
|
||
"BaseAIBrain.MemoryDuration",
|
||
"BaseAIBrain.RefreshKnownLOS",
|
||
"BaseAIBrain.CanBeBlinded",
|
||
"BaseAIBrain.BlindDurationMultiplier",
|
||
"BaseAIBrain.MovementTickStartDelay",
|
||
"BaseAIBrain.ClientCurrentState",
|
||
"BaseAIBrain.mainInterestPoint",
|
||
"BaseAIBrain.UseAIDesign",
|
||
"BaseAIBrain.Pet",
|
||
"BaseAIBrain.CanUseHealingItems",
|
||
"BaseAIBrain.HealChance",
|
||
"BaseAIBrain.HealBelowHealthFraction",
|
||
};
|
||
// </fieldlist>
|
||
|
||
private FieldInfo[] _npcFields;
|
||
private FieldInfo[] _brainFields;
|
||
private readonly List<string> _fieldsMissing = new List<string>();
|
||
private readonly List<string> _fieldsAdded = new List<string>();
|
||
|
||
/// <summary>
|
||
/// Resolves the generated field list against the running game. A name Rust has since
|
||
/// removed is reported, not fatal. On Carbon, where Rust's assembly is unmodified, the
|
||
/// list is also checked against the rule it was generated with, so a field Rust has
|
||
/// ADDED is named at boot (D232). On Oxide the rule cannot be applied: its patcher makes
|
||
/// private fields public.
|
||
/// </summary>
|
||
private void ResolveFields()
|
||
{
|
||
_fieldsMissing.Clear();
|
||
_fieldsAdded.Clear();
|
||
_npcFields = ResolveFields(typeof(ScientistNPC), SwapNpcFields, _fieldsMissing);
|
||
_brainFields = ResolveFields(typeof(ScientistBrain), SwapBrainFields, _fieldsMissing);
|
||
|
||
if (Framework() == "carbon")
|
||
{
|
||
_fieldsAdded.AddRange(RuleFields(typeof(ScientistNPC)).Except(SwapNpcFields));
|
||
_fieldsAdded.AddRange(RuleFields(typeof(ScientistBrain)).Except(SwapBrainFields));
|
||
}
|
||
|
||
if (_fieldsMissing.Count > 0)
|
||
PrintWarning($"The swap's field list names fields this Rust no longer has: {string.Join(", ", _fieldsMissing)}. Regenerate it (tools/fieldlist).");
|
||
if (_fieldsAdded.Count > 0)
|
||
PrintWarning($"Rust has fields the swap's list does not copy: {string.Join(", ", _fieldsAdded)}. Regenerate it (tools/fieldlist).");
|
||
}
|
||
|
||
private static FieldInfo[] ResolveFields(Type type, string[] names, List<string> missing)
|
||
{
|
||
var chain = new List<Type>();
|
||
for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType)
|
||
chain.Add(t);
|
||
|
||
var found = new List<FieldInfo>();
|
||
foreach (string name in names)
|
||
{
|
||
int dot = name.IndexOf('.');
|
||
string typeName = name.Substring(0, dot);
|
||
Type owner = chain.FirstOrDefault(t => t.Name == typeName);
|
||
FieldInfo f = owner?.GetField(name.Substring(dot + 1),
|
||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
|
||
if (f == null)
|
||
missing.Add(name);
|
||
else
|
||
found.Add(f);
|
||
}
|
||
|
||
return found.ToArray();
|
||
}
|
||
|
||
/// <summary>tools/fieldlist's rule, applied at run time: only meaningful on an unmodified assembly.</summary>
|
||
private static List<string> RuleFields(Type type)
|
||
{
|
||
var names = new List<string>();
|
||
for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType)
|
||
{
|
||
foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
||
{
|
||
if (f.IsInitOnly || f.IsLiteral)
|
||
continue;
|
||
bool serialised = (f.IsPublic && (f.Attributes & FieldAttributes.NotSerialized) == 0) || f.IsDefined(typeof(SerializeField), false);
|
||
if (serialised && !typeof(Delegate).IsAssignableFrom(f.FieldType))
|
||
names.Add($"{t.Name}.{f.Name}");
|
||
}
|
||
}
|
||
|
||
return names;
|
||
}
|
||
|
||
private static void CopyFields(FieldInfo[] fields, Component from, Component to)
|
||
{
|
||
foreach (FieldInfo f in fields)
|
||
f.SetValue(to, f.GetValue(from));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Makes one of our NPCs, not yet spawned: the prefab's scientist with its two components
|
||
/// swapped for ours. Everything the brain reads once, as it starts, is set here (stage 1,
|
||
/// Q3: <c>Senses.Init</c> copies the sense values a single time).
|
||
/// </summary>
|
||
private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, out string error)
|
||
{
|
||
error = null;
|
||
BaseEntity made = GameManager.server.CreateEntity(ResolvePrefab(p.Prefab), at, Quaternion.Euler(0f, yaw, 0f), false);
|
||
ScientistNPC stock = made as ScientistNPC;
|
||
ScientistBrain stockBrain = stock != null ? stock.GetComponent<ScientistBrain>() : null;
|
||
if (stock == null || stock.GetType() != typeof(ScientistNPC) || stockBrain == null || stockBrain.GetType() != typeof(ScientistBrain))
|
||
{
|
||
error = $"prefab '{p.Prefab}' is not a plain scientist (ScientistNPC with ScientistBrain)";
|
||
if (made != null) UnityEngine.Object.Destroy(made.gameObject);
|
||
return null;
|
||
}
|
||
|
||
GameObject go = stock.gameObject;
|
||
RunicNpcPlayer npc = go.AddComponent<RunicNpcPlayer>();
|
||
RunicNpcBrain brain = go.AddComponent<RunicNpcBrain>();
|
||
CopyFields(_npcFields, stock, npc);
|
||
CopyFields(_brainFields, stockBrain, brain);
|
||
UnityEngine.Object.DestroyImmediate(stock, true);
|
||
UnityEngine.Object.DestroyImmediate(stockBrain, true);
|
||
|
||
npc.enableSaving = false;
|
||
npc.startHealth = p.Health;
|
||
npc.damageScale = p.DamageDealt;
|
||
npc.aimConeScale = p.AimCone;
|
||
|
||
brain.SenseRange = p.Ranges.Sense;
|
||
brain.TargetLostRange = p.Ranges.LoseTarget;
|
||
brain.VisionCone = p.VisionCone;
|
||
|
||
// Our own sleep replaces an AI zone's (D235): a zone must never put ours to sleep.
|
||
brain.AllowedToSleep = false;
|
||
brain.Sentry = p.IsSentry;
|
||
return npc;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region The NPC
|
||
|
||
/// <summary>
|
||
/// Our NPC. What only a subclass can do (PLAN.md §2): its own name, its name on the
|
||
/// victim's death screen, its attack range, and damage taken by body part.
|
||
/// </summary>
|
||
public class RunicNpcPlayer : ScientistNPC, IAIAttack, IAISenses
|
||
{
|
||
public string RnName = "Scientist";
|
||
public string ProfileName;
|
||
public Profile Profile;
|
||
public string Owner;
|
||
public string PlacementId;
|
||
public int Slot = -1;
|
||
public Vector3 Home;
|
||
public float HomeYaw;
|
||
public string MoveKind = "wander";
|
||
public float MoveRadius = 20f;
|
||
public string RouteName;
|
||
public string Kit;
|
||
public bool Died;
|
||
public readonly Dictionary<ulong, float> Contributors = new Dictionary<ulong, float>();
|
||
public readonly HashSet<float> ThresholdsFired = new HashSet<float>();
|
||
|
||
public ulong NetId => net != null ? net.ID.Value : 0UL;
|
||
|
||
public RunicNpcBrain RnBrain => Brain as RunicNpcBrain;
|
||
|
||
public override string displayName => RnName;
|
||
|
||
/// <summary>
|
||
/// HumanNPC.AttackerInfo writes the prefab's short name as the killer on the victim's
|
||
/// death screen, after BasePlayer's display name (stage 1, Q6). Ours puts the name back.
|
||
/// </summary>
|
||
public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info)
|
||
{
|
||
base.AttackerInfo(info);
|
||
info.attackerName = displayName;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The profile's attack range replaces the weapon's. HumanNPC.TickAttack asks through
|
||
/// IAIAttack, which this class re-implements, so the call lands here.
|
||
/// </summary>
|
||
public new bool IsTargetInRange(BaseEntity entity, out float dist)
|
||
{
|
||
dist = Vector3.Distance(entity.transform.position, transform.position);
|
||
return dist <= EngagementRange();
|
||
}
|
||
|
||
public new float EngagementRange() => Profile != null ? Profile.Ranges.Attack : base.EngagementRange();
|
||
|
||
// ---- stage 5: whom it fights, beside Rust's own design (D231, D254–D272) ----
|
||
|
||
/// <summary>
|
||
/// Its own target: an NPC, an animal or a turret. Rust's design still owns every fight with a
|
||
/// player; this one is fought by <see cref="RunicNPC.Fight"/> when the design has none.
|
||
/// </summary>
|
||
public BaseCombatEntity NpcTarget;
|
||
|
||
/// <summary>True while it fights <see cref="NpcTarget"/>: its roam state stands aside (D262).</summary>
|
||
public bool Engaged;
|
||
|
||
/// <summary>Whom it escorts (D258, D269), or null. Its leash and home follow it.</summary>
|
||
public BaseEntity Escort;
|
||
|
||
/// <summary>The clan, team or lone player it is allied to (D257, D270); 0 for none.</summary>
|
||
public long AllyClan;
|
||
public ulong AllyTeam;
|
||
public ulong AllyPlayer;
|
||
|
||
/// <summary>The ZoneManager zone it never leaves (D272), or null.</summary>
|
||
public string Tether;
|
||
|
||
public int HuntsVersion = -1;
|
||
public HashSet<string> Hunts;
|
||
public float NextSense, NextLos, NextMove, NextAlert, NextDefend;
|
||
public bool TargetLos;
|
||
public float LastHurtAt = float.NegativeInfinity;
|
||
public float NextHeal, NextThrow, NextRocket, KitBusyUntil;
|
||
public int Heals, Throws, Rockets, MeleeHits, ShotsMarked;
|
||
|
||
public bool HasAlly => AllyClan != 0L || AllyTeam != 0UL || AllyPlayer != 0UL;
|
||
|
||
/// <summary>Where its leash is measured from: the escorted entity while it has one, else its spot.</summary>
|
||
public Vector3 Anchor => Escort != null && !Escort.IsDestroyed ? Escort.transform.position : Home;
|
||
|
||
/// <summary>
|
||
/// Rust's <c>HumanNPC.IsTarget</c> (players, pets, scarecrows), less anyone it is allied to or
|
||
/// escorting (D257, D269). <c>IAISenses</c> is re-implemented so Rust's senses ask here.
|
||
/// </summary>
|
||
public new bool IsTarget(BaseEntity entity) =>
|
||
base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity));
|
||
|
||
public new bool IsThreat(BaseEntity entity) => IsTarget(entity);
|
||
|
||
public new bool IsFriendly(BaseEntity entity) => base.IsFriendly(entity);
|
||
|
||
private BaseCombatEntity _marked;
|
||
private BaseCombatEntity.Faction _markedWas;
|
||
|
||
/// <summary>
|
||
/// Rust drops every hit an NPC's bullet makes on another NPC unless the victim's faction is
|
||
/// Horror (<c>BaseProjectile.ServerUse</c>, stage 5 spike). So its own NPC target alone is
|
||
/// made Horror for the length of its own shot, then put back. ShotTest calls TriggerDown
|
||
/// inside itself: a mark is made only when none is in place, or the inner call would save
|
||
/// Horror as the faction to restore and leave the target Horror for everyone's bullets.
|
||
/// </summary>
|
||
private bool Mark()
|
||
{
|
||
if (_marked != null)
|
||
return false;
|
||
BaseCombatEntity t = NpcTarget;
|
||
if (!Engaged || t == null || t.IsDestroyed || !t.IsNpc)
|
||
return false;
|
||
_marked = t;
|
||
_markedWas = t.faction;
|
||
t.faction = BaseCombatEntity.Faction.Horror;
|
||
ShotsMarked++;
|
||
return true;
|
||
}
|
||
|
||
private void Unmark(bool marked)
|
||
{
|
||
if (!marked)
|
||
return;
|
||
if (_marked != null && !_marked.IsDestroyed)
|
||
_marked.faction = _markedWas;
|
||
_marked = null;
|
||
}
|
||
|
||
/// <summary>A single-shot weapon fires inside ShotTest, a burst weapon from TriggerDown.</summary>
|
||
public override bool ShotTest(float targetDist)
|
||
{
|
||
if (_self != null && !_self.FuelExtra(this))
|
||
return false;
|
||
bool marked = Mark();
|
||
try { return base.ShotTest(targetDist); }
|
||
finally { Unmark(marked); }
|
||
}
|
||
|
||
public override void TriggerDown()
|
||
{
|
||
bool marked = Mark();
|
||
try { base.TriggerDown(); }
|
||
finally { Unmark(marked); }
|
||
}
|
||
|
||
public override void Hurt(HitInfo info)
|
||
{
|
||
if (Profile != null && info != null)
|
||
{
|
||
// A hit with no bone (fire, an explosion, a fall) reports every area at once,
|
||
// (HitArea)(-1), so only an exact area counts as head or legs; the rest is body.
|
||
HitArea area = info.boneArea;
|
||
float scale = area == HitArea.Head ? Profile.DamageTaken.Head
|
||
: area == HitArea.Leg || area == HitArea.Foot ? Profile.DamageTaken.Legs
|
||
: Profile.DamageTaken.Body;
|
||
if (!Mathf.Approximately(scale, 1f))
|
||
info.damageTypes.ScaleAll(scale);
|
||
|
||
// D271: "players can hurt it" off. RunicNPC's own rule, so it holds with a PVE plugin
|
||
// or without one; NextGenPVE ignores a "deny" answer (stage 5 spike).
|
||
BasePlayer by = info.InitiatorPlayer;
|
||
if (!Profile.HurtByPlayers && by != null && !by.IsNpc)
|
||
info.damageTypes.ScaleAll(0f);
|
||
}
|
||
|
||
// A contributor is credited with the health it actually took, after Rust's own
|
||
// protection and never past zero. A killing blow is credited in OnDied, which
|
||
// base.Hurt reaches before it returns, so OnRunicNpcDied carries it.
|
||
BasePlayer attacker = info?.InitiatorPlayer;
|
||
ulong attackerId = attacker != null && !attacker.IsNpc ? (ulong)attacker.userID : 0UL;
|
||
_pendingAttacker = attackerId;
|
||
_healthBefore = health;
|
||
base.Hurt(info);
|
||
_pendingAttacker = 0UL;
|
||
if (attackerId != 0UL && !Died)
|
||
Credit(attackerId, _healthBefore - health);
|
||
if (Profile != null && !IsDead())
|
||
{
|
||
_self?.CheckThresholds(this);
|
||
// An unkillable NPC (D271) takes nothing and still answers the attack.
|
||
if (info != null && info.Initiator != null)
|
||
{
|
||
LastHurtAt = Time.time;
|
||
_self?.NpcHurt(this, info);
|
||
}
|
||
}
|
||
}
|
||
|
||
private ulong _pendingAttacker;
|
||
private float _healthBefore;
|
||
|
||
private void Credit(ulong id, float damage)
|
||
{
|
||
if (damage <= 0f)
|
||
return;
|
||
float sum;
|
||
Contributors.TryGetValue(id, out sum);
|
||
Contributors[id] = sum + damage;
|
||
}
|
||
|
||
public override void OnDied(HitInfo info)
|
||
{
|
||
if (_pendingAttacker != 0UL)
|
||
Credit(_pendingAttacker, _healthBefore);
|
||
Died = true;
|
||
_self?.HandleDied(this, info);
|
||
base.OnDied(info);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Our brain: Rust's scientist brain, whose AI design still decides when to chase, fight
|
||
/// and take cover, with three things of ours (D233, D235):
|
||
///
|
||
/// <list type="bullet">
|
||
/// <item>the roam state replaced by our wander or route follower (a monument
|
||
/// roamer keeps Rust's own);</item>
|
||
/// <item>a sentry's moving states replaced by one that holds its spot and turns;</item>
|
||
/// <item>sleep: past the profile's distance from every player it walks home, then
|
||
/// stops thinking; a player in range wakes it.</item>
|
||
/// </list>
|
||
///
|
||
/// The design references states by type, so a replaced state keeps its events: the
|
||
/// shooting still happens where Rust's design puts it.
|
||
/// </summary>
|
||
public class RunicNpcBrain : ScientistBrain
|
||
{
|
||
public bool Sentry;
|
||
|
||
public enum RestState
|
||
{
|
||
Awake,
|
||
GoingHome,
|
||
Asleep
|
||
}
|
||
|
||
public RestState Rest = RestState.Awake;
|
||
public int Leashes;
|
||
|
||
private float _nextCheck;
|
||
private bool _playerNear = true;
|
||
private float _homeBy;
|
||
|
||
private static readonly BasePlayer[] NearBuffer = new BasePlayer[4];
|
||
|
||
private static readonly AIState[] SentryHolds =
|
||
{
|
||
AIState.Roam, AIState.Chase, AIState.Combat, AIState.TakeCover, AIState.Cover, AIState.MoveTowards,
|
||
AIState.MoveToVector3, AIState.FollowPath, AIState.NavigateHome, AIState.Flee, AIState.Blinded
|
||
};
|
||
|
||
private static readonly HashSet<AIState> Aggressive = new HashSet<AIState>
|
||
{
|
||
AIState.Chase, AIState.Combat, AIState.TakeCover, AIState.Cover
|
||
};
|
||
|
||
public override void AddStates()
|
||
{
|
||
base.AddStates();
|
||
RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer;
|
||
if (npc == null)
|
||
return;
|
||
|
||
if (Sentry)
|
||
{
|
||
foreach (AIState type in SentryHolds)
|
||
if (states.ContainsKey(type))
|
||
Replace(new HoldState(type, Aggressive.Contains(type)));
|
||
return;
|
||
}
|
||
|
||
// Every other NPC's roam is ours (stage 5): it stands aside while ours fights an NPC
|
||
// (D262), follows an escort (D269), and otherwise walks as its role and movement say.
|
||
// Rust's own roam and chase both need an AI zone's move points, so outside a monument
|
||
// they do nothing (stage 1, Q3; stage 2 found the chase's Error branch): a guard, a
|
||
// wanderer and a route walker get ours, and a monument roamer keeps both of Rust's.
|
||
BasicAIState inner;
|
||
if (npc.Profile != null && npc.Profile.IsGuard)
|
||
inner = new RnGuardState();
|
||
else if (npc.MoveKind == "wander")
|
||
inner = new WanderState();
|
||
else if (npc.MoveKind == "route")
|
||
inner = new RouteState();
|
||
else
|
||
{
|
||
BasicAIState rust;
|
||
inner = states.TryGetValue(AIState.Roam, out rust) ? rust : null;
|
||
}
|
||
|
||
if (inner != null)
|
||
Replace(new RnRoamState(inner));
|
||
if (npc.MoveKind != "monument" || (npc.Profile != null && npc.Profile.IsGuard))
|
||
Replace(new PursueState());
|
||
}
|
||
|
||
/// <summary>Whether Rust's design is fighting a player now; ours then leaves the fight to it.</summary>
|
||
public bool FightingAPlayer()
|
||
{
|
||
if (CurrentState == null || !CurrentState.AgrresiveState || Senses?.Memory == null)
|
||
return false;
|
||
foreach (BaseEntity e in Senses.Memory.Targets)
|
||
{
|
||
BasePlayer p = e as BasePlayer;
|
||
if (p != null && !p.IsNpc && p.IsAlive())
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>Wakes a sleeping NPC at once, for an ally's alarm (D256) or a defence (D257).</summary>
|
||
public void Rouse()
|
||
{
|
||
if (Rest == RestState.Awake)
|
||
return;
|
||
bool asleep = Rest == RestState.Asleep;
|
||
Rest = RestState.Awake;
|
||
_nextCheck = Time.time + 10f;
|
||
_playerNear = true;
|
||
if (asleep)
|
||
((IAISleepable)this).WakeAI();
|
||
}
|
||
|
||
private void Replace(BasicAIState state)
|
||
{
|
||
states.Remove(state.StateType);
|
||
AddState(state);
|
||
}
|
||
|
||
public override void Think(float delta)
|
||
{
|
||
RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer;
|
||
|
||
// Rust's think manager can reach a brain before Unity has started it, and until then
|
||
// it has no navigator and no state: leave that think to Rust.
|
||
if (npc == null || npc.Profile == null || npc.IsDead() || Navigator == null)
|
||
{
|
||
base.Think(delta);
|
||
return;
|
||
}
|
||
|
||
float now = Time.time;
|
||
if (now >= _nextCheck)
|
||
{
|
||
_nextCheck = now + 2f;
|
||
_playerNear = npc.Profile.SleepDistance <= 0f || PlayerWithin(npc.transform.position, npc.Profile.SleepDistance);
|
||
if (!Sentry && !Navigator.IsOnNavMeshLink)
|
||
Reground(npc);
|
||
_self?.CheckEscort(npc);
|
||
|
||
// D272: outside its zone (pushed, or the zone moved), it forgets the fight and walks home.
|
||
if (!Sentry && npc.Tether != null && _self != null && !_self.InTether(npc, npc.transform.position))
|
||
{
|
||
Tethered++;
|
||
Leash(npc);
|
||
}
|
||
}
|
||
|
||
switch (Rest)
|
||
{
|
||
case RestState.Asleep:
|
||
if (!_playerNear)
|
||
{
|
||
lastThinkTime = now;
|
||
return;
|
||
}
|
||
|
||
Rest = RestState.Awake;
|
||
((IAISleepable)this).WakeAI();
|
||
break;
|
||
|
||
case RestState.GoingHome:
|
||
if (_playerNear)
|
||
{
|
||
Rest = RestState.Awake;
|
||
break;
|
||
}
|
||
|
||
lastThinkTime = now;
|
||
if (Flat(npc.transform.position - npc.Anchor) <= 2f || now >= _homeBy)
|
||
Sleep();
|
||
else if (!Navigator.Moving && !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal))
|
||
Sleep();
|
||
return;
|
||
|
||
default:
|
||
if (!_playerNear && !npc.Engaged && (CurrentState == null || !CurrentState.AgrresiveState))
|
||
{
|
||
// D235: walk home first, then sleep. Nothing thinks on the way: no player
|
||
// is within the sleep distance, so there is nothing to sense or fight.
|
||
Rest = RestState.GoingHome;
|
||
_homeBy = now + 120f;
|
||
lastThinkTime = now;
|
||
if (Sentry || !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal))
|
||
Sleep();
|
||
return;
|
||
}
|
||
|
||
break;
|
||
}
|
||
|
||
if (!Sentry && npc.Profile.Ranges.Chase > 0f && CurrentState != null && CurrentState.AgrresiveState &&
|
||
Flat(npc.transform.position - npc.Anchor) > npc.Profile.Ranges.Chase)
|
||
Leash(npc);
|
||
|
||
base.Think(delta);
|
||
|
||
// Stage 5: our own fight, after Rust's, so Rust's design still owns every fight with a
|
||
// player. It costs nothing for an NPC with no faction settings and no target (D255).
|
||
if (_self != null && !npc.IsDead())
|
||
{
|
||
long t0 = Stopwatch.GetTimestamp();
|
||
_self.Fight(npc, this, delta);
|
||
FightTicks++;
|
||
FightMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency;
|
||
}
|
||
}
|
||
|
||
public int Tethered;
|
||
|
||
/// <summary>What our own fight layer has cost, for <c>rnpc.status</c> and the cost warning.</summary>
|
||
public static double FightMs;
|
||
public static long FightTicks;
|
||
|
||
/// <summary>
|
||
/// D239: the floor under a roamer was destroyed. Rust leaves the NPC standing in the air
|
||
/// where the floor was (stage 3 measured 15 s and counting), so it is put on the nearest
|
||
/// navmesh, as Rust's own navigator warps, and that spot is its home until it respawns.
|
||
/// </summary>
|
||
private void Reground(RunicNpcPlayer npc)
|
||
{
|
||
Vector3 at = npc.transform.position;
|
||
if (OffMeshReason(at) == null)
|
||
return;
|
||
NavMeshHit hit;
|
||
if (Navigator.Agent == null || !SampleNavmesh(at, Fallback, out hit))
|
||
return;
|
||
|
||
Navigator.Stop();
|
||
Navigator.Agent.Warp(hit.position);
|
||
npc.transform.position = hit.position;
|
||
npc.Home = hit.position;
|
||
Regrounded++;
|
||
_self?.NoteRegrounded(npc, at, hit.position);
|
||
}
|
||
|
||
public int Regrounded;
|
||
|
||
/// <summary>Past its chase range from home: forget the target and head back.</summary>
|
||
public void Leash(RunicNpcPlayer npc)
|
||
{
|
||
Leashes++;
|
||
npc.NpcTarget = null;
|
||
npc.Engaged = false;
|
||
Rust.Ai.SimpleAIMemory memory = Senses.Memory;
|
||
memory.All.Clear();
|
||
memory.Players.Clear();
|
||
memory.Targets.Clear();
|
||
memory.Threats.Clear();
|
||
memory.LOS.Clear();
|
||
Events.Memory.Entity.Clear();
|
||
Senses.DelaySenseUpdate(5f);
|
||
npc.NextSense = Time.time + 5f;
|
||
Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Fast);
|
||
}
|
||
|
||
private void Sleep()
|
||
{
|
||
Rest = RestState.Asleep;
|
||
Navigator.Stop();
|
||
((IAISleepable)this).SleepAI();
|
||
}
|
||
|
||
private static bool PlayerWithin(Vector3 at, float distance) =>
|
||
BaseEntity.Query.Server.GetPlayersInSphere(at, distance, NearBuffer, p => !p.IsNpc && p.IsAlive() && !p.IsSleeping()) > 0;
|
||
|
||
private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude;
|
||
}
|
||
|
||
/// <summary>D233 <c>wander</c>: a walkable point within the radius of home, walk there, finish.</summary>
|
||
public class WanderState : BaseAIBrain.BasicAIState
|
||
{
|
||
private StateStatus _status = StateStatus.Error;
|
||
|
||
public WanderState() : base(AIState.Roam)
|
||
{
|
||
}
|
||
|
||
public override void StateEnter(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateEnter(brain, entity);
|
||
_status = StateStatus.Error;
|
||
RunicNpcPlayer npc = entity as RunicNpcPlayer;
|
||
if (npc == null)
|
||
return;
|
||
|
||
for (int i = 0; i < 6; i++)
|
||
{
|
||
Vector2 offset = UnityEngine.Random.insideUnitCircle * npc.MoveRadius;
|
||
NavMeshHit hit;
|
||
if (SampleNavmesh(npc.Home + new Vector3(offset.x, 0f, offset.y), 3f, out hit) &&
|
||
(npc.Tether == null || _self == null || _self.InTether(npc, hit.position)) &&
|
||
brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Slow))
|
||
{
|
||
_status = StateStatus.Running;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateThink(delta, brain, entity);
|
||
if (_status == StateStatus.Error)
|
||
return _status;
|
||
return brain.Navigator.Moving ? StateStatus.Running : StateStatus.Finished;
|
||
}
|
||
|
||
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateLeave(brain, entity);
|
||
brain.Navigator.Stop();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The roam state of every NPC that is not a sentry (stage 5). It hands the NPC to one of
|
||
/// three things, in order:
|
||
///
|
||
/// <list type="bullet">
|
||
/// <item>an escort (D269): it keeps within a few metres of whom it escorts;</item>
|
||
/// <item>its own fight with an NPC (D231): it stands aside while <see cref="RunicNPC.Fight"/>
|
||
/// moves the NPC, and a route walker resumes at the point it was heading to (D262);</item>
|
||
/// <item>otherwise its role's own roam: a guard's hold, our wander or route, or Rust's own
|
||
/// monument roam.</item>
|
||
/// </list>
|
||
/// </summary>
|
||
public class RnRoamState : BaseAIBrain.BasicAIState
|
||
{
|
||
private readonly BaseAIBrain.BasicAIState _inner;
|
||
private bool _innerActive;
|
||
private float _nextFollow;
|
||
|
||
/// <summary>How close an escort keeps, and how far it lets its charge get before it runs.</summary>
|
||
private const float Keep = 4f;
|
||
private const float Run = 12f;
|
||
|
||
public RnRoamState(BaseAIBrain.BasicAIState inner) : base(AIState.Roam)
|
||
{
|
||
_inner = inner;
|
||
}
|
||
|
||
public override void StateEnter(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateEnter(brain, entity);
|
||
_innerActive = false;
|
||
_nextFollow = 0f;
|
||
RunicNpcPlayer npc = entity as RunicNpcPlayer;
|
||
if (npc != null && !Elsewhere(npc))
|
||
EnterInner(brain, entity);
|
||
}
|
||
|
||
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateThink(delta, brain, entity);
|
||
RunicNpcPlayer npc = entity as RunicNpcPlayer;
|
||
if (npc == null)
|
||
return StateStatus.Error;
|
||
|
||
if (Elsewhere(npc))
|
||
{
|
||
LeaveInner(brain, entity);
|
||
if (npc.Escort != null && !npc.Engaged)
|
||
Follow(brain, npc);
|
||
return StateStatus.Running;
|
||
}
|
||
|
||
if (!_innerActive)
|
||
EnterInner(brain, entity);
|
||
return _inner.StateThink(delta, brain, entity);
|
||
}
|
||
|
||
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateLeave(brain, entity);
|
||
LeaveInner(brain, entity);
|
||
brain.Navigator.ClearFacingDirectionOverride();
|
||
}
|
||
|
||
private static bool Elsewhere(RunicNpcPlayer npc) => npc.Engaged || (npc.Escort != null && !npc.Escort.IsDestroyed);
|
||
|
||
private void EnterInner(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
_inner.StateEnter(brain, entity);
|
||
_innerActive = true;
|
||
}
|
||
|
||
private void LeaveInner(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
if (!_innerActive)
|
||
return;
|
||
_inner.StateLeave(brain, entity);
|
||
_innerActive = false;
|
||
}
|
||
|
||
/// <summary>D269: keep close to whom it escorts, running when they get away.</summary>
|
||
private void Follow(BaseAIBrain brain, RunicNpcPlayer npc)
|
||
{
|
||
if (Time.time < _nextFollow)
|
||
return;
|
||
_nextFollow = Time.time + 0.5f;
|
||
Vector3 there = npc.Escort.transform.position;
|
||
float distance = Vector3.Distance(npc.transform.position, there);
|
||
if (distance <= Keep + 1f)
|
||
{
|
||
if (brain.Navigator.Moving)
|
||
brain.Navigator.Stop();
|
||
return;
|
||
}
|
||
|
||
// A spot a little short of the escorted entity, on the side ours comes from, so a squad
|
||
// gathers round rather than on top of it.
|
||
Vector3 from = (npc.transform.position - there);
|
||
from.y = 0f;
|
||
Vector3 goal = there + (from.sqrMagnitude > 0.01f ? from.normalized : Vector3.forward) * Keep;
|
||
NavMeshHit hit;
|
||
if (SampleNavmesh(goal, 3f, out hit) || SampleNavmesh(there, 3f, out hit))
|
||
brain.Navigator.SetDestination(hit.position, distance > Run ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// A guard's roam (stage 5): it holds its spot facing the way it was placed, and walks back
|
||
/// to it after a chase. Its chase range is its leash, as for any roamer.
|
||
/// </summary>
|
||
public class RnGuardState : BaseAIBrain.BasicAIState
|
||
{
|
||
private float _next;
|
||
|
||
public RnGuardState() : base(AIState.Roam)
|
||
{
|
||
}
|
||
|
||
public override void StateEnter(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateEnter(brain, entity);
|
||
_next = 0f;
|
||
}
|
||
|
||
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateThink(delta, brain, entity);
|
||
RunicNpcPlayer npc = entity as RunicNpcPlayer;
|
||
if (npc == null)
|
||
return StateStatus.Error;
|
||
if (Time.time < _next)
|
||
return StateStatus.Running;
|
||
_next = Time.time + 1f;
|
||
|
||
if (Flat(npc.transform.position - npc.Home) > 2f)
|
||
{
|
||
if (!brain.Navigator.Moving)
|
||
brain.Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal);
|
||
return StateStatus.Running;
|
||
}
|
||
|
||
if (brain.Navigator.Moving)
|
||
brain.Navigator.Stop();
|
||
brain.Navigator.SetFacingDirectionOverride(Quaternion.Euler(0f, npc.HomeYaw, 0f) * Vector3.forward);
|
||
return StateStatus.Running;
|
||
}
|
||
|
||
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateLeave(brain, entity);
|
||
brain.Navigator.Stop();
|
||
brain.Navigator.ClearFacingDirectionOverride();
|
||
}
|
||
|
||
private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude;
|
||
}
|
||
|
||
/// <summary>D233 <c>route:<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 anchor = npc.Anchor;
|
||
Vector3 fromHome = goal - anchor;
|
||
fromHome.y = 0f;
|
||
bool clamped = chase > 0f && fromHome.magnitude > chase;
|
||
if (clamped)
|
||
goal = anchor + fromHome.normalized * chase;
|
||
|
||
// D272: the zone's edge is a leash too. Past it, ours stands and waits, as at the chase range.
|
||
if (npc.Tether != null && _self != null && !_self.InTether(npc, goal))
|
||
{
|
||
goal = here;
|
||
clamped = true;
|
||
}
|
||
|
||
if (clamped && distance > npc.Profile.Ranges.Attack)
|
||
{
|
||
if (_edgeSince < 0f)
|
||
_edgeSince = Time.time;
|
||
if (Time.time - _edgeSince >= 5f)
|
||
{
|
||
(brain as RunicNpcBrain)?.Leash(npc);
|
||
return StateStatus.Error;
|
||
}
|
||
}
|
||
else
|
||
_edgeSince = -1f;
|
||
|
||
NavMeshHit hit;
|
||
if (!SampleNavmesh(goal, 3f, out hit))
|
||
return StateStatus.Error;
|
||
brain.Navigator.SetDestination(hit.position, distance > 10f ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal);
|
||
return StateStatus.Running;
|
||
}
|
||
|
||
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateLeave(brain, entity);
|
||
brain.Navigator.Stop();
|
||
brain.Navigator.ClearFacingDirectionOverride();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// A sentry's stand-in for every state that would move it: it stops, and turns to face the
|
||
/// design's current target. The design's attack events still run in it.
|
||
/// </summary>
|
||
public class HoldState : BaseAIBrain.BasicAIState
|
||
{
|
||
public HoldState(AIState type, bool aggressive) : base(type)
|
||
{
|
||
AgrresiveState = aggressive;
|
||
}
|
||
|
||
public override void StateEnter(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateEnter(brain, entity);
|
||
brain.Navigator.Stop();
|
||
}
|
||
|
||
public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateThink(delta, brain, entity);
|
||
BaseEntity target = brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot);
|
||
if (target != null)
|
||
brain.Navigator.SetFacingDirectionEntity(target);
|
||
else
|
||
brain.Navigator.ClearFacingDirectionOverride();
|
||
return StateStatus.Running;
|
||
}
|
||
|
||
public override void StateLeave(BaseAIBrain brain, BaseEntity entity)
|
||
{
|
||
base.StateLeave(brain, entity);
|
||
brain.Navigator.ClearFacingDirectionOverride();
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Factions, sensing and the fight (stage 5)
|
||
|
||
// Rust's own design still owns every fight with a player. What is ours: whom else an NPC fights
|
||
// (D231, D254, D255), its allies' alarm (D256), the clan or team it serves (D257), escorts (D258,
|
||
// D269), turrets (D259, D264, D267), the kit's extras (D260), PVE (D261, D271) and the tether (D272).
|
||
// An NPC with no faction settings, no ally and no escort costs one dictionary look-up a think.
|
||
|
||
/// <summary>Rust's scientists (not ours), for the profiles hostile to <c>scientists</c>.</summary>
|
||
private readonly HashSet<BaseCombatEntity> _scientists = new HashSet<BaseCombatEntity>();
|
||
|
||
/// <summary>Rust's animals, old and new, for the profiles hostile to <c>animals</c>.</summary>
|
||
private readonly HashSet<BaseCombatEntity> _animals = new HashSet<BaseCombatEntity>();
|
||
|
||
/// <summary>Our live NPCs by profile, for the profiles hostile to another profile.</summary>
|
||
private readonly Dictionary<string, HashSet<RunicNpcPlayer>> _byProfile = new Dictionary<string, HashSet<RunicNpcPlayer>>();
|
||
|
||
/// <summary>Our NPCs with an ally or an escort: the only ones a damage event is checked for (D257, D269).</summary>
|
||
private readonly HashSet<RunicNpcPlayer> _defenders = new HashSet<RunicNpcPlayer>();
|
||
|
||
/// <summary>Moves whenever profiles or the faction table change, so each NPC recomputes whom it hunts.</summary>
|
||
private int _huntsVersion;
|
||
|
||
private static bool IsScientist(BaseEntity e) => e is ScientistNPC && !(e is RunicNpcPlayer);
|
||
|
||
private static bool IsAnimal(BaseEntity e) => e is BaseNpc || e is Rust.Ai.Gen2.BaseNPC2;
|
||
|
||
private void Track(BaseNetworkable entity)
|
||
{
|
||
BaseCombatEntity e = entity as BaseCombatEntity;
|
||
if (e == null || e is RunicNpcPlayer)
|
||
return;
|
||
if (IsScientist(e))
|
||
_scientists.Add(e);
|
||
else if (IsAnimal(e))
|
||
_animals.Add(e);
|
||
}
|
||
|
||
private void Untrack(BaseNetworkable entity)
|
||
{
|
||
BaseCombatEntity e = entity as BaseCombatEntity;
|
||
if (e == null)
|
||
return;
|
||
_scientists.Remove(e);
|
||
_animals.Remove(e);
|
||
}
|
||
|
||
private void TrackWorld()
|
||
{
|
||
_scientists.Clear();
|
||
_animals.Clear();
|
||
foreach (BaseNetworkable e in BaseNetworkable.serverEntities)
|
||
Track(e);
|
||
}
|
||
|
||
private void RebuildHunts() => _huntsVersion++;
|
||
|
||
/// <summary>A profile's faction, or <c>profile:<name></c> when it has none: its allies are then its own (D256).</summary>
|
||
private static string FactionOf(string profileName, Profile p) => string.IsNullOrEmpty(p.Faction) ? "profile:" + profileName : p.Faction;
|
||
|
||
private static string FactionOf(RunicNpcPlayer npc) => FactionOf(npc.ProfileName, npc.Profile);
|
||
|
||
/// <summary>
|
||
/// How a profile treats a faction: its own exceptions first (D254), then its own faction
|
||
/// (allied), then the table, both ways (D268); neutral when nothing says otherwise.
|
||
/// </summary>
|
||
private string RelationOf(string profileName, Profile p, string otherFaction, string otherProfile)
|
||
{
|
||
string r;
|
||
if (otherProfile != null && p.Relations.TryGetValue("profile:" + otherProfile, out r))
|
||
return r;
|
||
if (p.Relations.TryGetValue(otherFaction, out r))
|
||
return r;
|
||
string mine = FactionOf(profileName, p);
|
||
if (mine == otherFaction)
|
||
return "allied";
|
||
return _factionTable.TryGetValue(PairKey(mine, otherFaction), out r) ? r : "neutral";
|
||
}
|
||
|
||
/// <summary>How one of ours treats an entity: a relation for NPCs and animals, null for anything else.</summary>
|
||
private string RelationTo(RunicNpcPlayer npc, BaseEntity e)
|
||
{
|
||
RunicNpcPlayer other = e as RunicNpcPlayer;
|
||
if (other != null)
|
||
return other.Profile == null ? null : RelationOf(npc.ProfileName, npc.Profile, FactionOf(other), other.ProfileName);
|
||
if (IsScientist(e))
|
||
return RelationOf(npc.ProfileName, npc.Profile, "scientists", null);
|
||
if (IsAnimal(e))
|
||
return RelationOf(npc.ProfileName, npc.Profile, "animals", null);
|
||
return null;
|
||
}
|
||
|
||
/// <summary>D256: an ally is the same faction, or the same profile when there is no faction.</summary>
|
||
private static bool SameSide(RunicNpcPlayer a, RunicNpcPlayer b) =>
|
||
a.Profile != null && b.Profile != null && FactionOf(a) == FactionOf(b);
|
||
|
||
/// <summary>The registry keys an NPC hunts: <c>scientists</c>, <c>animals</c> and <c>ours:<profile></c>. Null for none (D255).</summary>
|
||
private HashSet<string> HuntsOf(RunicNpcPlayer npc)
|
||
{
|
||
if (npc.HuntsVersion == _huntsVersion)
|
||
return npc.Hunts;
|
||
npc.HuntsVersion = _huntsVersion;
|
||
var hunts = new HashSet<string>();
|
||
if (RelationOf(npc.ProfileName, npc.Profile, "scientists", null) == "hostile")
|
||
hunts.Add("scientists");
|
||
if (RelationOf(npc.ProfileName, npc.Profile, "animals", null) == "hostile")
|
||
hunts.Add("animals");
|
||
foreach (KeyValuePair<string, Profile> kv in _profiles)
|
||
if (RelationOf(npc.ProfileName, npc.Profile, FactionOf(kv.Key, kv.Value), kv.Key) == "hostile")
|
||
hunts.Add("ours:" + kv.Key);
|
||
npc.Hunts = hunts.Count > 0 ? hunts : null;
|
||
return npc.Hunts;
|
||
}
|
||
|
||
private IEnumerable<BaseCombatEntity> Registry(string key)
|
||
{
|
||
if (key == "scientists")
|
||
return _scientists;
|
||
if (key == "animals")
|
||
return _animals;
|
||
HashSet<RunicNpcPlayer> ours;
|
||
return key.StartsWith("ours:") && _byProfile.TryGetValue(key.Substring(5), out ours) ? ours.Cast<BaseCombatEntity>() : null;
|
||
}
|
||
|
||
private void Index(RunicNpcPlayer npc, bool add)
|
||
{
|
||
HashSet<RunicNpcPlayer> set;
|
||
if (!_byProfile.TryGetValue(npc.ProfileName, out set))
|
||
{
|
||
if (!add)
|
||
return;
|
||
_byProfile[npc.ProfileName] = set = new HashSet<RunicNpcPlayer>();
|
||
}
|
||
|
||
if (add)
|
||
set.Add(npc);
|
||
else
|
||
{
|
||
set.Remove(npc);
|
||
_defenders.Remove(npc);
|
||
}
|
||
}
|
||
|
||
private void NoteDefender(RunicNpcPlayer npc)
|
||
{
|
||
if (npc.HasAlly || npc.Escort != null)
|
||
_defenders.Add(npc);
|
||
else
|
||
_defenders.Remove(npc);
|
||
}
|
||
|
||
/// <summary>How far from its anchor ours may fight an NPC (D263): its chase range, or for a sentry its attack range.</summary>
|
||
private static float LeashOf(RunicNpcPlayer npc) =>
|
||
npc.Profile.IsSentry ? npc.Profile.Ranges.Attack : npc.Profile.Ranges.Chase > 0f ? npc.Profile.Ranges.Chase : npc.Profile.Ranges.Sense;
|
||
|
||
private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude;
|
||
|
||
// ---- the fight ----
|
||
|
||
/// <summary>LOS tests a sense may spend: the nearest few candidates only (stage 5 spike).</summary>
|
||
private const int LosBudget = 3;
|
||
|
||
/// <summary>Rust's own line-of-sight mask for an NPC's shots.</summary>
|
||
private const int LosMask = 1218519041;
|
||
|
||
private static readonly BaseCombatEntity[] Best = new BaseCombatEntity[LosBudget];
|
||
private static readonly float[] BestD2 = new float[LosBudget];
|
||
|
||
/// <summary>
|
||
/// Our own fight, after Rust's think (stage 5). With no player to fight and a target of ours —
|
||
/// sensed (D231, D263), learned from an ally (D256), or whoever hurt it or its charge (D257,
|
||
/// D268) — it faces it, closes in within its leash and its zone, and hands Rust's own attack
|
||
/// routine the target, so every shot is Rust's.
|
||
/// </summary>
|
||
private void Fight(RunicNpcPlayer npc, RunicNpcBrain brain, float delta)
|
||
{
|
||
float now = Time.time;
|
||
HashSet<string> hunts = HuntsOf(npc);
|
||
BaseCombatEntity t = npc.NpcTarget;
|
||
Vector3 anchor = npc.Anchor;
|
||
float leash = LeashOf(npc);
|
||
|
||
if (t != null && (t.IsDestroyed || t.IsDead() || Spares(npc, t) ||
|
||
Vector3.Distance(npc.transform.position, t.transform.position) > Mathf.Max(npc.Profile.Ranges.LoseTarget, leash) + 10f ||
|
||
Flat(t.transform.position - anchor) > leash + Mathf.Max(npc.Profile.Ranges.Attack, 10f)))
|
||
t = npc.NpcTarget = null;
|
||
|
||
if (hunts != null && now >= npc.NextSense && TurretOf(t) == null)
|
||
{
|
||
npc.NextSense = now + 1f;
|
||
long s0 = Stopwatch.GetTimestamp();
|
||
BaseCombatEntity found = Sense(npc, hunts, anchor, leash);
|
||
SenseMs += (Stopwatch.GetTimestamp() - s0) * 1000.0 / Stopwatch.Frequency;
|
||
SenseRuns++;
|
||
if (found != null)
|
||
t = npc.NpcTarget = found;
|
||
}
|
||
|
||
bool player = brain.FightingAPlayer();
|
||
npc.Engaged = t != null && !player;
|
||
|
||
if (npc.Profile.KitUse.Any)
|
||
KitTick(npc, brain, npc.Engaged ? t : PlayerTarget(brain), now);
|
||
|
||
if (!npc.Engaged)
|
||
return;
|
||
|
||
if (now >= npc.NextLos)
|
||
{
|
||
npc.NextLos = now + 0.5f;
|
||
npc.TargetLos = Los(npc, t);
|
||
}
|
||
|
||
brain.Navigator.SetFacingDirectionEntity(t);
|
||
float distance = Vector3.Distance(npc.transform.position, t.transform.position);
|
||
if (!npc.Profile.IsSentry)
|
||
{
|
||
if (!npc.TargetLos || distance > npc.EngagementRange() * 0.9f)
|
||
{
|
||
if (now >= npc.NextMove)
|
||
{
|
||
npc.NextMove = now + 1f;
|
||
Vector3 goal = t.transform.position;
|
||
Vector3 fromAnchor = goal - anchor;
|
||
fromAnchor.y = 0f;
|
||
if (fromAnchor.magnitude > leash)
|
||
goal = anchor + fromAnchor.normalized * leash;
|
||
NavMeshHit hit;
|
||
if ((npc.Tether == null || InTether(npc, goal)) && SampleNavmesh(goal, 3f, out hit))
|
||
brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Fast);
|
||
else if (brain.Navigator.Moving)
|
||
brain.Navigator.Stop();
|
||
}
|
||
}
|
||
else if (brain.Navigator.Moving)
|
||
brain.Navigator.Stop();
|
||
}
|
||
|
||
if (now >= npc.KitBusyUntil)
|
||
npc.AttackTick(delta, t, npc.TargetLos);
|
||
}
|
||
|
||
/// <summary>The player Rust's design is fighting, for the kit's extras (D260).</summary>
|
||
private static BaseEntity PlayerTarget(RunicNpcBrain brain)
|
||
{
|
||
if (brain.CurrentState == null || !brain.CurrentState.AgrresiveState)
|
||
return null;
|
||
return brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot);
|
||
}
|
||
|
||
public static double SenseMs;
|
||
public static long SenseRuns;
|
||
|
||
/// <summary>
|
||
/// The nearest hostile within its sense range AND its leash around its anchor (D263), from the
|
||
/// registry rather than a physics query: distances first, then line of sight on the nearest
|
||
/// few only. Stage 5's spike measured this at 1.5–2.5 ms a think in a 100-against-100 fight.
|
||
/// </summary>
|
||
private BaseCombatEntity Sense(RunicNpcPlayer npc, HashSet<string> hunts, Vector3 anchor, float leash)
|
||
{
|
||
for (int i = 0; i < LosBudget; i++)
|
||
{
|
||
Best[i] = null;
|
||
BestD2[i] = float.MaxValue;
|
||
}
|
||
|
||
Vector3 at = npc.transform.position;
|
||
float sense = npc.Profile.IsSentry ? Mathf.Min(npc.Profile.Ranges.Sense, npc.Profile.Ranges.Attack) : npc.Profile.Ranges.Sense;
|
||
float r2 = sense * sense;
|
||
float leash2 = leash * leash;
|
||
foreach (string key in hunts)
|
||
{
|
||
IEnumerable<BaseCombatEntity> set = Registry(key);
|
||
if (set == null)
|
||
continue;
|
||
foreach (BaseCombatEntity e in set)
|
||
{
|
||
if (e == null || e.IsDestroyed || e == npc || e.IsDead())
|
||
continue;
|
||
Vector3 p = e.transform.position;
|
||
float d2 = (p - at).sqrMagnitude;
|
||
if (d2 > r2 || d2 >= BestD2[LosBudget - 1])
|
||
continue;
|
||
Vector3 fromAnchor = p - anchor;
|
||
if (fromAnchor.x * fromAnchor.x + fromAnchor.z * fromAnchor.z > leash2)
|
||
continue;
|
||
int k = LosBudget - 1;
|
||
while (k > 0 && BestD2[k - 1] > d2)
|
||
{
|
||
Best[k] = Best[k - 1];
|
||
BestD2[k] = BestD2[k - 1];
|
||
k--;
|
||
}
|
||
|
||
Best[k] = e;
|
||
BestD2[k] = d2;
|
||
}
|
||
}
|
||
|
||
for (int i = 0; i < LosBudget && Best[i] != null; i++)
|
||
if ((npc.Tether == null || InTether(npc, Best[i].transform.position)) && Los(npc, Best[i]))
|
||
return Best[i];
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A line from its eyes to the target's centre that counts the target's own collider as seen.
|
||
/// Not Rust's <c>CanSeeTarget</c>, even for a scientist or one of ours: its rays are unbounded,
|
||
/// so on open ground they run past the target into the terrain behind it, and an NPC 25 m off
|
||
/// read as unseen until ours had walked right up to it (stage 5 build). Players are Rust's
|
||
/// design's to see; this is only for what our own fight takes on.
|
||
/// </summary>
|
||
private static bool Los(RunicNpcPlayer npc, BaseCombatEntity t)
|
||
{
|
||
RaycastHit hit;
|
||
if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore))
|
||
return true;
|
||
BaseEntity e = hit.GetEntity();
|
||
return e != null && (e == t || e.EqualNetID(t) || (e.HasParent() && e.GetParentEntity() == t));
|
||
}
|
||
|
||
/// <summary>
|
||
/// The turret behind a hit. A flame turret hurts through its fireballs, whose creator is the
|
||
/// turret or the fireball that spawned them, so the chain is followed (stage 5 spike).
|
||
/// </summary>
|
||
private static BaseCombatEntity TurretOf(BaseEntity e)
|
||
{
|
||
for (int i = 0; i < 6 && e is FireBall; i++)
|
||
e = e.creatorEntity == e ? null : e.creatorEntity;
|
||
return e is AutoTurret || e is FlameTurret || e is GunTrap ? (BaseCombatEntity)e : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whom one of ours never targets: whom it escorts (D269), its allied clan's, team's or player's
|
||
/// people (D257), and NPCs it is allied to (D254).
|
||
/// </summary>
|
||
private bool Spares(RunicNpcPlayer npc, BaseEntity e)
|
||
{
|
||
if (e == null)
|
||
return false;
|
||
if (npc.Escort != null && (e == npc.Escort || e.EqualNetID(npc.Escort)))
|
||
return true;
|
||
BasePlayer p = e as BasePlayer;
|
||
if (p != null && !p.IsNpc)
|
||
return npc.HasAlly && IsAllyMember(npc, p);
|
||
return npc.Profile != null && (e is RunicNpcPlayer || IsScientist(e) || IsAnimal(e)) && RelationTo(npc, e) == "allied";
|
||
}
|
||
|
||
// ---- hurt: shoot back (D259, D268) and raise the alarm (D256) ----
|
||
|
||
/// <summary>Called from the NPC's own Hurt, after Rust's.</summary>
|
||
private void NpcHurt(RunicNpcPlayer npc, HitInfo info)
|
||
{
|
||
BaseEntity source = info.Initiator;
|
||
BaseCombatEntity attacker = TurretOf(source) ?? TurretOf(info.Weapon != null ? info.Weapon.GetParentEntity() : null) ?? source as BaseCombatEntity;
|
||
if (attacker == null || attacker == npc || attacker.IsDestroyed || attacker.IsDead())
|
||
return;
|
||
|
||
RunicNpcPlayer ours = attacker as RunicNpcPlayer;
|
||
if (ours != null && SameSide(npc, ours))
|
||
return; // an ally's fire or grenade (D266): no fight among friends.
|
||
|
||
BasePlayer player = attacker as BasePlayer;
|
||
bool real = player != null && !player.IsNpc;
|
||
if (real && npc.HasAlly && IsAllyMember(npc, player))
|
||
return;
|
||
|
||
// D268: whoever shot it is fought back, whatever the table says. Rust's design already learns
|
||
// a player (HumanNPC.Hurt); an NPC, an animal or a turret is ours to fight.
|
||
if (!real && (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed))
|
||
npc.NpcTarget = attacker;
|
||
|
||
Alarm(npc, attacker, real);
|
||
}
|
||
|
||
/// <summary>D256: every ally within the profile's alert radius learns the attacker, seen or not.</summary>
|
||
private void Alarm(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real)
|
||
{
|
||
float radius = npc.Profile.AlertRadius;
|
||
float now = Time.time;
|
||
if (radius <= 0f || now < npc.NextAlert)
|
||
return;
|
||
npc.NextAlert = now + 1f;
|
||
float r2 = radius * radius;
|
||
Vector3 at = npc.transform.position;
|
||
foreach (RunicNpcPlayer other in _npcs.Values)
|
||
{
|
||
if (other == npc || other.IsDestroyed || other.IsDead() || other.Profile == null || !SameSide(npc, other))
|
||
continue;
|
||
if ((other.transform.position - at).sqrMagnitude > r2)
|
||
continue;
|
||
Learn(other, attacker, real);
|
||
Alarms++;
|
||
}
|
||
}
|
||
|
||
public static long Alarms, Defences;
|
||
|
||
/// <summary>Tells one of ours about an attacker: Rust's memory for a player, our own target for anything else.</summary>
|
||
private void Learn(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real)
|
||
{
|
||
if (Spares(npc, attacker))
|
||
return;
|
||
npc.RnBrain?.Rouse();
|
||
if (real)
|
||
{
|
||
BaseAIBrain brain = npc.Brain;
|
||
if (brain?.Senses?.Memory != null)
|
||
brain.Senses.Memory.SetKnown(attacker, npc, brain.Senses);
|
||
return;
|
||
}
|
||
|
||
if (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed)
|
||
npc.NpcTarget = attacker;
|
||
}
|
||
|
||
// ---- allies: a clan, a team or a player (D257, D270) ----
|
||
|
||
private sealed class Members
|
||
{
|
||
public HashSet<ulong> Ids = new HashSet<ulong>();
|
||
public float ReadAt = float.NegativeInfinity;
|
||
}
|
||
|
||
private readonly Dictionary<string, Members> _members = new Dictionary<string, Members>();
|
||
|
||
/// <summary>Whether a player belongs to whom one of ours is allied to.</summary>
|
||
private bool IsAllyMember(RunicNpcPlayer npc, BasePlayer p)
|
||
{
|
||
if (npc.AllyPlayer != 0UL && (ulong)p.userID == npc.AllyPlayer)
|
||
return true;
|
||
if (npc.AllyClan != 0L && p.clanId == npc.AllyClan)
|
||
return true;
|
||
return npc.AllyTeam != 0UL && p.currentTeam == npc.AllyTeam;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether a Steam id belongs to whom one of ours is allied to: for what they own (D257), when
|
||
/// the owner may be offline. Clan and team rosters are re-read every five seconds at most.
|
||
/// </summary>
|
||
private bool IsAllyMember(RunicNpcPlayer npc, ulong id)
|
||
{
|
||
if (id == 0UL)
|
||
return false;
|
||
if (npc.AllyPlayer == id)
|
||
return true;
|
||
if (npc.AllyClan != 0L && Roster("clan:" + npc.AllyClan).Contains(id))
|
||
return true;
|
||
return npc.AllyTeam != 0UL && Roster("team:" + npc.AllyTeam).Contains(id);
|
||
}
|
||
|
||
private HashSet<ulong> Roster(string key)
|
||
{
|
||
Members m;
|
||
if (!_members.TryGetValue(key, out m))
|
||
_members[key] = m = new Members();
|
||
float now = Time.realtimeSinceStartup;
|
||
if (now - m.ReadAt < 5f)
|
||
return m.Ids;
|
||
m.ReadAt = now;
|
||
m.Ids.Clear();
|
||
if (key.StartsWith("team:"))
|
||
{
|
||
RelationshipManager.PlayerTeam team = RelationshipManager.ServerInstance != null
|
||
? RelationshipManager.ServerInstance.FindTeam(ulong.Parse(key.Substring(5)))
|
||
: null;
|
||
if (team?.members != null)
|
||
foreach (ulong id in team.members)
|
||
m.Ids.Add(id);
|
||
}
|
||
else
|
||
{
|
||
IClan clan = ReadClan(long.Parse(key.Substring(5)));
|
||
if (clan?.Members != null)
|
||
foreach (ClanMember member in clan.Members)
|
||
m.Ids.Add(member.SteamId);
|
||
}
|
||
|
||
return m.Ids;
|
||
}
|
||
|
||
/// <summary>A clan by id from the game's own clans, or null. Never waits: a clan not cached is read only if the read is instant.</summary>
|
||
private static IClan ReadClan(long clanId)
|
||
{
|
||
IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null;
|
||
if (backend == null)
|
||
return null;
|
||
IClan clan;
|
||
if (backend.TryGet(clanId, out clan) && clan != null)
|
||
return clan;
|
||
System.Threading.Tasks.ValueTask<ClanValueResult<IClan>> read = backend.Get(clanId);
|
||
return read.IsCompleted && read.Result.IsSuccess ? read.Result.Value : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// D257 and D269: a damage event against whom ours escort or serve. The attacker is fought by
|
||
/// every defender within its leash of the victim; a player, by Rust's own design.
|
||
/// </summary>
|
||
private void Defend(BaseCombatEntity victim, HitInfo info)
|
||
{
|
||
BaseEntity source = info.Initiator;
|
||
BaseCombatEntity attacker = TurretOf(source) ?? source as BaseCombatEntity;
|
||
if (attacker == null || attacker == victim || attacker.IsDead())
|
||
return;
|
||
BasePlayer victimPlayer = victim as BasePlayer;
|
||
BasePlayer attackerPlayer = attacker as BasePlayer;
|
||
bool real = attackerPlayer != null && !attackerPlayer.IsNpc;
|
||
float now = Time.time;
|
||
|
||
foreach (RunicNpcPlayer d in _defenders)
|
||
{
|
||
if (d == null || d.IsDestroyed || d.IsDead() || d.Profile == null || d == attacker || now < d.NextDefend)
|
||
continue;
|
||
|
||
bool charge = d.Escort != null && (victim == d.Escort || victim.EqualNetID(d.Escort));
|
||
if (!charge && d.HasAlly)
|
||
{
|
||
charge = victimPlayer != null && !victimPlayer.IsNpc
|
||
? IsAllyMember(d, victimPlayer)
|
||
: victim.OwnerID != 0UL && IsAllyMember(d, victim.OwnerID);
|
||
}
|
||
|
||
if (!charge)
|
||
continue;
|
||
|
||
// The attacker is not fought if it is one of the side being defended.
|
||
if (real && d.HasAlly && IsAllyMember(d, attackerPlayer))
|
||
continue;
|
||
RunicNpcPlayer ours = attacker as RunicNpcPlayer;
|
||
if (ours != null && SameSide(d, ours))
|
||
continue;
|
||
|
||
float reach = Mathf.Max(LeashOf(d), d.Profile.AlertRadius);
|
||
if (Flat(victim.transform.position - d.Anchor) > reach)
|
||
continue;
|
||
|
||
d.NextDefend = now + 0.5f;
|
||
Learn(d, attacker, real);
|
||
Defences++;
|
||
}
|
||
}
|
||
|
||
// ---- escort (D258, D269) ----
|
||
|
||
/// <summary>
|
||
/// Sets whom one of ours escorts, or clears it with null. Null on success, else why not: a
|
||
/// sentry never moves, and a dead or disconnected player cannot be escorted.
|
||
/// </summary>
|
||
private string SetEscort(RunicNpcPlayer npc, BaseEntity target)
|
||
{
|
||
if (target == null)
|
||
{
|
||
npc.Escort = null;
|
||
NoteDefender(npc);
|
||
return null;
|
||
}
|
||
|
||
if (npc.Profile.IsSentry)
|
||
return $"{npc.RnName} is a sentry and never moves, so it cannot escort";
|
||
if (target == npc || target.IsDestroyed)
|
||
return "there is nothing to escort";
|
||
BasePlayer p = target as BasePlayer;
|
||
if (p != null && (!p.IsConnected || p.IsDead()) && !p.IsNpc)
|
||
return $"{p.displayName} is not on the server";
|
||
BaseCombatEntity c = target as BaseCombatEntity;
|
||
if (c != null && c.IsDead())
|
||
return "it is dead";
|
||
|
||
npc.Escort = target;
|
||
npc.NpcTarget = null;
|
||
NoteDefender(npc);
|
||
npc.RnBrain?.Rouse();
|
||
return null;
|
||
}
|
||
|
||
/// <summary>D269: whom it escorts died or left the server; it walks back to its spot.</summary>
|
||
private void CheckEscort(RunicNpcPlayer npc)
|
||
{
|
||
// Unity's == says a destroyed entity is null, which is exactly the case to catch here.
|
||
BaseEntity e = npc.Escort;
|
||
if (ReferenceEquals(e, null))
|
||
return;
|
||
bool destroyed = e == null || e.IsDestroyed;
|
||
BasePlayer p = destroyed ? null : e as BasePlayer;
|
||
BaseCombatEntity c = destroyed ? null : e as BaseCombatEntity;
|
||
bool gone = destroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected);
|
||
if (!gone)
|
||
return;
|
||
string who = p != null ? p.displayName : destroyed ? "its charge" : e.ShortPrefabName;
|
||
npc.Escort = null;
|
||
NoteDefender(npc);
|
||
npc.RnBrain?.Navigator?.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal);
|
||
Interface.CallHook("OnRunicNpcEscortEnded", npc, npc.ProfileName, npc.Owner);
|
||
Puts($"{npc.RnName} ({npc.Owner}) no longer escorts {who}, who is gone; it walks back to its spot.");
|
||
}
|
||
|
||
// ---- the tether (D272) ----
|
||
|
||
[PluginReference] private Plugin ZoneManager;
|
||
|
||
private bool _tetherWarned;
|
||
|
||
/// <summary>
|
||
/// Whether a point is inside one of ours' zone. With ZoneManager gone the NPC is not held,
|
||
/// and the log says so once, rather than every NPC freezing where it stands.
|
||
/// </summary>
|
||
private bool InTether(RunicNpcPlayer npc, Vector3 at)
|
||
{
|
||
if (npc.Tether == null)
|
||
return true;
|
||
if (ZoneManager == null)
|
||
{
|
||
if (!_tetherWarned)
|
||
PrintWarning("ZoneManager is not loaded, so no NPC is held inside its zone.");
|
||
_tetherWarned = true;
|
||
return true;
|
||
}
|
||
|
||
object inside = ZoneManager.Call("IsPositionInZone", npc.Tether, at);
|
||
return !(inside is bool) || (bool)inside;
|
||
}
|
||
|
||
/// <summary>Null when a zone can hold NPCs at a point, else why not.</summary>
|
||
private string TetherRefusal(string zone, Vector3 at)
|
||
{
|
||
if (zone == null)
|
||
return null;
|
||
if (ZoneManager == null)
|
||
return "tether: ZoneManager is not loaded on this server";
|
||
if (ZoneManager.Call("CheckZoneID", zone) == null)
|
||
return $"tether: this server has no zone '{zone}'";
|
||
object inside = ZoneManager.Call("IsPositionInZone", zone, at);
|
||
if (inside is bool && !(bool)inside)
|
||
return $"tether: the spot is outside the zone '{zone}'";
|
||
return null;
|
||
}
|
||
|
||
// ---- the kit's extras (D260, D265) ----
|
||
|
||
/// <summary>The first item in its inventory whose held entity is a <typeparamref name="T"/>, or null.</summary>
|
||
private static Item FindHeld<T>(RunicNpcPlayer npc) where T : HeldEntity
|
||
{
|
||
foreach (ItemContainer container in new[] { npc.inventory.containerBelt, npc.inventory.containerMain })
|
||
{
|
||
if (container == null)
|
||
continue;
|
||
foreach (Item item in container.itemList)
|
||
if (item != null && item.GetHeldEntity() is T)
|
||
return item;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>Puts an item in its hands; only the belt can be held, so it is moved there first.</summary>
|
||
private static bool Hold(RunicNpcPlayer npc, Item item)
|
||
{
|
||
if (item == null)
|
||
return false;
|
||
if (npc.GetActiveItem() == item)
|
||
return true;
|
||
if (item.parent != npc.inventory.containerBelt && !item.MoveToContainer(npc.inventory.containerBelt))
|
||
return false;
|
||
npc.UpdateActiveItem(item.uid);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Back to the main weapon (the belt's first slot, Rust's own rule).</summary>
|
||
private static void MainWeapon(RunicNpcPlayer npc)
|
||
{
|
||
Item main = npc.inventory.containerBelt?.GetSlot(0);
|
||
if (main != null && npc.GetActiveItem() != main)
|
||
npc.EquipWeapon(true);
|
||
}
|
||
|
||
private static bool IsMain(RunicNpcPlayer npc, Item item) => item != null && npc.inventory.containerBelt?.GetSlot(0) == item;
|
||
|
||
/// <summary>
|
||
/// D260: each extra the profile opts into, when the moment calls for it. Rust's AI uses none of
|
||
/// them in open ground (stage 5 spike), so these are ours: heal in a lull, melee close in,
|
||
/// the flamethrower within its reach, a grenade at 5–20 m, a rocket at 15–80 m.
|
||
/// </summary>
|
||
private void KitTick(RunicNpcPlayer npc, RunicNpcBrain brain, BaseEntity target, float now)
|
||
{
|
||
KitUse k = npc.Profile.KitUse;
|
||
if (now < npc.KitBusyUntil)
|
||
return;
|
||
|
||
if (k.Heal && npc.health < npc.MaxHealth() * 0.5f && now - npc.LastHurtAt > 3f && now >= npc.NextHeal)
|
||
{
|
||
npc.NextHeal = now + (Heal(npc) ? 6f : 15f);
|
||
if (now < npc.KitBusyUntil)
|
||
return;
|
||
}
|
||
|
||
if (target == null || target.IsDestroyed)
|
||
{
|
||
MainWeapon(npc);
|
||
return;
|
||
}
|
||
|
||
float distance = Vector3.Distance(npc.transform.position, target.transform.position);
|
||
|
||
if (k.Melee && distance <= 2.5f)
|
||
{
|
||
Item melee = FindHeld<BaseMelee>(npc);
|
||
if (melee != null && !IsMain(npc, melee) && Hold(npc, melee))
|
||
{
|
||
brain.Navigator.SetFacingDirectionEntity(target);
|
||
if (npc.MeleeAttack())
|
||
npc.MeleeHits++;
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (k.Flamethrower && distance <= 7f)
|
||
{
|
||
Item flame = FindHeld<FlameThrower>(npc);
|
||
if (flame != null && !IsMain(npc, flame) && HasFuel(npc, flame) && Hold(npc, flame))
|
||
return; // Rust's own attack tick fires what it holds.
|
||
}
|
||
|
||
if (k.Grenades && distance > 5f && distance < 20f && now >= npc.NextThrow)
|
||
{
|
||
npc.NextThrow = now + 10f;
|
||
Item thrown = FindHeld<ThrownWeapon>(npc);
|
||
if (thrown != null && (thrown.parent == npc.inventory.containerBelt || thrown.MoveToContainer(npc.inventory.containerBelt)) &&
|
||
npc.TryUseThrownWeapon(thrown, target, 10f))
|
||
{
|
||
// Rust's own throw: aims for 1.5 s, throws, uses one up, and re-equips the main weapon.
|
||
npc.Throws++;
|
||
npc.KitBusyUntil = now + 2.5f;
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket)
|
||
{
|
||
npc.NextRocket = now + 2f;
|
||
// A real player's line of sight is the one Rust's design shoots on; an NPC (a scientist, one of
|
||
// ours) or an animal is never in Rust's player memory, so it is ours.
|
||
BaseCombatEntity aim = target as BaseCombatEntity;
|
||
BasePlayer human = aim as BasePlayer;
|
||
bool seen = human != null && !human.IsNpc
|
||
? brain.Senses?.Memory != null && brain.Senses.Memory.IsLOS(aim)
|
||
: aim != null && Los(npc, aim);
|
||
if (seen && FireRocket(npc, target))
|
||
{
|
||
npc.KitBusyUntil = now + 1.5f;
|
||
return;
|
||
}
|
||
}
|
||
|
||
Item held = npc.GetActiveItem();
|
||
if (held != null && !IsMain(npc, held))
|
||
{
|
||
HeldEntity h = held.GetHeldEntity() as HeldEntity;
|
||
bool keep = (h is BaseMelee && distance <= 4f) || (h is FlameThrower && distance <= 9f);
|
||
if (!keep)
|
||
MainWeapon(npc);
|
||
}
|
||
}
|
||
|
||
/// <summary>D260: one syringe, medkit or bandage, used up, its health given at once.</summary>
|
||
private bool Heal(RunicNpcPlayer npc)
|
||
{
|
||
Item item = FindHeld<MedicalTool>(npc);
|
||
ItemModConsumable consumable = item?.info.GetComponent<ItemModConsumable>();
|
||
if (consumable == null)
|
||
return false;
|
||
float amount = 0f;
|
||
foreach (ItemModConsumable.ConsumableEffect effect in consumable.effects)
|
||
if (effect.type == MetabolismAttribute.Type.Health || effect.type == MetabolismAttribute.Type.HealthOverTime)
|
||
amount += effect.amount;
|
||
if (amount <= 0f)
|
||
return false;
|
||
|
||
Hold(npc, item);
|
||
npc.SignalBroadcast(BaseEntity.Signal.Attack);
|
||
item.UseItem(1);
|
||
npc.Heal(amount);
|
||
npc.Heals++;
|
||
npc.KitBusyUntil = Time.time + 1.5f;
|
||
timer.Once(1.2f, () =>
|
||
{
|
||
if (npc != null && !npc.IsDestroyed && !npc.IsDead())
|
||
MainWeapon(npc);
|
||
});
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// D260: a rocket from the kit's launcher, one rocket used up. Rust's AI never fires a launcher
|
||
/// (stage 5 spike), so the rocket is launched here, from its eyes, on an arc that lands on the
|
||
/// target, and credited to the NPC.
|
||
/// </summary>
|
||
private bool FireRocket(RunicNpcPlayer npc, BaseEntity target)
|
||
{
|
||
Item launcher = FindHeld<BaseLauncher>(npc);
|
||
if (launcher == null)
|
||
return false;
|
||
Item ammo = null;
|
||
ItemModProjectile projectile = null;
|
||
foreach (ItemContainer container in new[] { npc.inventory.containerMain, npc.inventory.containerBelt })
|
||
{
|
||
if (container == null)
|
||
continue;
|
||
foreach (Item item in container.itemList)
|
||
{
|
||
ItemModProjectile p = item?.info.GetComponent<ItemModProjectile>();
|
||
if (p != null && p.projectileObject != null && p.projectileObject.isValid && item.info.shortname.StartsWith("ammo.rocket"))
|
||
{
|
||
ammo = item;
|
||
projectile = p;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (ammo != null)
|
||
break;
|
||
}
|
||
|
||
if (ammo == null || !Hold(npc, launcher))
|
||
return false;
|
||
|
||
Vector3 from = npc.eyes.position + npc.eyes.BodyForward() * 1f;
|
||
Vector3 to = target.CenterPoint();
|
||
BaseEntity rocket = GameManager.server.CreateEntity(projectile.projectileObject.resourcePath, from);
|
||
ServerProjectile mover = rocket != null ? rocket.GetComponent<ServerProjectile>() : null;
|
||
if (mover == null)
|
||
{
|
||
if (rocket != null)
|
||
UnityEngine.Object.Destroy(rocket.gameObject);
|
||
return false;
|
||
}
|
||
|
||
Vector3 direction = Arc(from, to, mover.speed, -Physics.gravity.y * mover.gravityModifier);
|
||
npc.SetAimDirection(direction);
|
||
rocket.SetCreatorEntity(npc);
|
||
rocket.OwnerID = 0UL;
|
||
mover.InitializeVelocity(direction * mover.speed);
|
||
rocket.SendMessage("SetDamageScale", npc.damageScale, SendMessageOptions.DontRequireReceiver);
|
||
rocket.Spawn();
|
||
ammo.UseItem(1);
|
||
launcher.GetHeldEntity()?.SignalBroadcast(BaseEntity.Signal.Attack, string.Empty);
|
||
npc.Rockets++;
|
||
npc.NextRocket = Time.time + 10f;
|
||
timer.Once(1.2f, () =>
|
||
{
|
||
if (npc != null && !npc.IsDestroyed && !npc.IsDead())
|
||
MainWeapon(npc);
|
||
});
|
||
return true;
|
||
}
|
||
|
||
/// <summary>The direction that lands a projectile of this speed and gravity on the point; straight at it when out of reach.</summary>
|
||
private static Vector3 Arc(Vector3 from, Vector3 to, float speed, float gravity)
|
||
{
|
||
Vector3 delta = to - from;
|
||
Vector3 flat = new Vector3(delta.x, 0f, delta.z);
|
||
float x = flat.magnitude;
|
||
if (gravity <= 0f || x < 0.5f)
|
||
return delta.normalized;
|
||
float y = delta.y;
|
||
float v2 = speed * speed;
|
||
float root = v2 * v2 - gravity * (gravity * x * x + 2f * y * v2);
|
||
if (root < 0f)
|
||
return delta.normalized;
|
||
float angle = Mathf.Atan((v2 - Mathf.Sqrt(root)) / (gravity * x));
|
||
return (flat.normalized * Mathf.Cos(angle) + Vector3.up * Mathf.Sin(angle)).normalized;
|
||
}
|
||
|
||
private static bool HasFuel(RunicNpcPlayer npc, Item flameItem)
|
||
{
|
||
FlameThrower f = flameItem.GetHeldEntity() as FlameThrower;
|
||
return f != null && (f.ammo > 0 || (f.fuelType != null && npc.inventory.FindItemByItemName(f.fuelType.shortname) != null));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Called from ShotTest. D265: the main weapon is endless, as in Rust. A flamethrower held as an
|
||
/// EXTRA burns the kit's fuel: an empty tank is refilled from the inventory, and with none left
|
||
/// the NPC goes back to its main weapon. False means "no shot now".
|
||
/// </summary>
|
||
private bool FuelExtra(RunicNpcPlayer npc)
|
||
{
|
||
Item held = npc.GetActiveItem();
|
||
FlameThrower f = held?.GetHeldEntity() as FlameThrower;
|
||
if (f == null || IsMain(npc, held) || f.ammo > 0)
|
||
return true;
|
||
Item fuel = f.fuelType != null ? npc.inventory.FindItemByItemName(f.fuelType.shortname) : null;
|
||
if (fuel == null)
|
||
{
|
||
MainWeapon(npc);
|
||
return false;
|
||
}
|
||
|
||
int take = Mathf.Min(fuel.amount, f.maxAmmo);
|
||
fuel.UseItem(take);
|
||
f.ammo = take;
|
||
f.SendNetworkUpdateImmediate();
|
||
return true;
|
||
}
|
||
|
||
// ---- turrets (D259, D264, D267) ----
|
||
|
||
/// <summary>
|
||
/// <c>turrets: ignore</c>: no auto turret, flame turret or shotgun trap targets it. Answers only
|
||
/// for our own NPCs, and null for everything else.
|
||
/// </summary>
|
||
private object CanBeTargeted(BaseCombatEntity entity, MonoBehaviour turret)
|
||
{
|
||
MarkHook("CanBeTargeted");
|
||
RunicNpcPlayer npc = entity as RunicNpcPlayer;
|
||
if (npc?.Profile == null || npc.Profile.Turrets != "ignore")
|
||
return null;
|
||
return false;
|
||
}
|
||
|
||
private HarmonyLib.Harmony _harmony;
|
||
private const string HarmonyId = "runicnpc.sentries";
|
||
private string _sentryPatch = "not tried";
|
||
|
||
/// <summary>
|
||
/// D267: Outpost's and Bandit Camp's sentries refuse every scientist in <c>Ignore</c> and
|
||
/// <c>IsEntityHostile</c>, before any Oxide or Carbon hook runs, so <c>turrets: always</c> is a
|
||
/// Harmony patch on those two. Each answers "hostile" for our <c>always</c> NPCs alone and runs
|
||
/// Rust's own method for everything else, Rust's scientists and bandit guards included.
|
||
/// </summary>
|
||
private void PatchSentries()
|
||
{
|
||
try
|
||
{
|
||
const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||
MethodInfo ignore = typeof(NPCAutoTurret).GetMethod("Ignore", any, null, new[] { typeof(BasePlayer) }, null);
|
||
MethodInfo hostile = typeof(NPCAutoTurret).GetMethod("IsEntityHostile", any, null, new[] { typeof(BaseCombatEntity) }, null);
|
||
if (ignore == null || hostile == null)
|
||
{
|
||
_sentryPatch = $"not patched: this Rust has no NPCAutoTurret.{(ignore == null ? "Ignore" : "IsEntityHostile")}";
|
||
PrintWarning($"turrets: always cannot reach the safe-zone sentries ({_sentryPatch}).");
|
||
return;
|
||
}
|
||
|
||
_harmony = new HarmonyLib.Harmony(HarmonyId);
|
||
_harmony.Patch(ignore, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.IgnorePrefix))));
|
||
_harmony.Patch(hostile, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.HostilePrefix))));
|
||
_sentryPatch = "patched";
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
_sentryPatch = "failed: " + e.Message;
|
||
PrintWarning($"turrets: always cannot reach the safe-zone sentries: Harmony could not patch them ({e.Message}).");
|
||
}
|
||
}
|
||
|
||
private void UnpatchSentries()
|
||
{
|
||
try
|
||
{
|
||
_harmony?.UnpatchAll(HarmonyId);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
PrintWarning($"Harmony could not unpatch the sentries: {e.Message}");
|
||
}
|
||
|
||
_harmony = null;
|
||
}
|
||
|
||
/// <summary>The two prefixes. Each answers for our <c>always</c> NPCs only; Rust's method runs for anything else.</summary>
|
||
public static class SentryPatch
|
||
{
|
||
public static long Answered;
|
||
|
||
private static bool Always(BaseEntity e)
|
||
{
|
||
RunicNpcPlayer npc = e as RunicNpcPlayer;
|
||
return npc != null && npc.Profile != null && npc.Profile.Turrets == "always" && !npc.IsDead();
|
||
}
|
||
|
||
public static bool IgnorePrefix(BasePlayer __0, ref bool __result)
|
||
{
|
||
if (!Always(__0))
|
||
return true;
|
||
__result = false;
|
||
Answered++;
|
||
return false;
|
||
}
|
||
|
||
public static bool HostilePrefix(BaseCombatEntity __0, ref bool __result)
|
||
{
|
||
if (!Always(__0))
|
||
return true;
|
||
__result = true;
|
||
Answered++;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ---- PVE (D261, D271) ----
|
||
|
||
/// <summary>
|
||
/// TruePVE's and NextGenPVE's question. Ours are "allowed" both ways with players, as Rust's
|
||
/// scientists are (D261), unless the profile turns a direction off (D271). Null for anything
|
||
/// that is not one of ours facing a player.
|
||
/// </summary>
|
||
private object CanEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
|
||
{
|
||
MarkHook("CanEntityTakeDamage");
|
||
if (entity == null || info == null)
|
||
return null;
|
||
RunicNpcPlayer victim = entity as RunicNpcPlayer;
|
||
BasePlayer attacker = info.InitiatorPlayer;
|
||
if (victim?.Profile != null && attacker != null && !attacker.IsNpc)
|
||
return victim.Profile.HurtByPlayers;
|
||
RunicNpcPlayer from = info.Initiator as RunicNpcPlayer;
|
||
BasePlayer player = entity as BasePlayer;
|
||
if (from?.Profile != null && player != null && !player.IsNpc)
|
||
return from.Profile.HurtsPlayers;
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Every damage event. Two jobs only: a harmless profile's NPC never hurts a player (D271),
|
||
/// and whom ours escort or serve are defended (D257, D269). Null otherwise.
|
||
/// </summary>
|
||
private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
|
||
{
|
||
MarkHook("OnEntityTakeDamage");
|
||
if (entity == null || info == null)
|
||
return null;
|
||
RunicNpcPlayer from = info.Initiator as RunicNpcPlayer;
|
||
BasePlayer player = entity as BasePlayer;
|
||
if (from?.Profile != null && !from.Profile.HurtsPlayers && player != null && !player.IsNpc)
|
||
return true;
|
||
if (_defenders.Count > 0)
|
||
Defend(entity, info);
|
||
return null;
|
||
}
|
||
|
||
private void OnEntitySpawned(BaseNetworkable entity)
|
||
{
|
||
MarkHook("OnEntitySpawned");
|
||
if (_ready)
|
||
Track(entity);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Registry, owners, spawning
|
||
|
||
/// <summary>Every live NPC of ours, by net id.</summary>
|
||
private readonly Dictionary<ulong, RunicNpcPlayer> _npcs = new Dictionary<ulong, RunicNpcPlayer>();
|
||
|
||
private sealed class Slot
|
||
{
|
||
public RunicNpcPlayer Npc;
|
||
public float DiedAt = float.NegativeInfinity;
|
||
public bool Queued;
|
||
}
|
||
|
||
private sealed class PlacementState
|
||
{
|
||
public string Id;
|
||
public Placement Data;
|
||
public Slot[] Slots;
|
||
public string Waiting;
|
||
public string LastError;
|
||
|
||
/// <summary>Set while its spot is off the navmesh and its NPCs stand on the nearest (D239).</summary>
|
||
public string Note;
|
||
}
|
||
|
||
private readonly Dictionary<string, PlacementState> _placements = new Dictionary<string, PlacementState>();
|
||
|
||
private sealed class SpawnRequest
|
||
{
|
||
public PlacementState Placement;
|
||
public int Slot;
|
||
}
|
||
|
||
private readonly Queue<SpawnRequest> _queue = new Queue<SpawnRequest>();
|
||
private bool _pumpScheduled;
|
||
private bool _navmeshWaitLogged;
|
||
private float _tokens;
|
||
private float _tokensAt;
|
||
|
||
private int CountOwner(string owner) => _npcs.Values.Count(n => n.Owner == owner);
|
||
|
||
private int CountProfile(string profile) => _npcs.Values.Count(n => n.ProfileName == profile);
|
||
|
||
/// <summary>Null when a cap allows one more; otherwise which cap, for the caller.</summary>
|
||
private string CapRefusal(string owner, string profile)
|
||
{
|
||
CapsConfig caps = _config.Caps;
|
||
if (caps.Total > 0 && _npcs.Count >= caps.Total)
|
||
return $"the server's cap of {caps.Total} RunicNPC NPCs is reached";
|
||
if (caps.PerOwner > 0 && CountOwner(owner) >= caps.PerOwner)
|
||
return $"the cap of {caps.PerOwner} per owner is reached for {owner}";
|
||
if (caps.PerProfile > 0 && CountProfile(profile) >= caps.PerProfile)
|
||
return $"the cap of {caps.PerProfile} per profile is reached for '{profile}'";
|
||
return null;
|
||
}
|
||
|
||
/// <summary>The spawn-rate cap, a token bucket; always true when no rate is set.</summary>
|
||
private bool TakeSpawnToken()
|
||
{
|
||
float rate = _config.Caps.SpawnsPerSecond;
|
||
if (rate <= 0f)
|
||
return true;
|
||
float now = Time.realtimeSinceStartup;
|
||
_tokens = Mathf.Min(Mathf.Max(1f, rate), _tokens + (now - _tokensAt) * rate);
|
||
_tokensAt = now;
|
||
if (_tokens < 1f)
|
||
return false;
|
||
_tokens -= 1f;
|
||
return true;
|
||
}
|
||
|
||
private static Profile Effective(Profile profile, JObject overrides)
|
||
{
|
||
JObject merged = JObject.FromObject(profile);
|
||
if (overrides != null)
|
||
merged.Merge(overrides, new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Replace });
|
||
return merged.ToObject<Profile>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Spawns one NPC now, or returns null with the reason. Every path into the world comes
|
||
/// through here: the API, and the placement queue.
|
||
/// </summary>
|
||
private RunicNpcPlayer Spawn(string profileName, Profile p, Vector3 at, float yaw, string owner, Movement movementOverride,
|
||
string placementId, int slot, out string error)
|
||
{
|
||
if (!_ready || _unloading) { error = "RunicNPC is not ready"; return null; }
|
||
if (!NavmeshReady()) { error = "the map's navmesh is not built yet"; return null; }
|
||
|
||
error = CapRefusal(owner, profileName);
|
||
if (error != null) return null;
|
||
|
||
Movement movement = movementOverride ?? p.Movement;
|
||
string kind, route;
|
||
ParseMode(movement.Mode, out kind, out route);
|
||
if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route))
|
||
{
|
||
error = $"route '{route}' does not exist";
|
||
return null;
|
||
}
|
||
|
||
Vector3 position = at;
|
||
if (!p.IsSentry)
|
||
{
|
||
// Rust's sampler reaches further down than across: without the height check, a spot
|
||
// on a roof finds the ground below it (stage 2).
|
||
NavMeshHit hit;
|
||
if (!SampleNavmesh(at, Scatter, out hit) || Mathf.Abs(hit.position.y - at.y) > MaxDrop)
|
||
{
|
||
error = "the spot is not on Rust's navmesh, and only a sentry may stand off it (D219)";
|
||
return null;
|
||
}
|
||
|
||
position = hit.position;
|
||
}
|
||
|
||
if (!TakeSpawnToken())
|
||
{
|
||
error = $"the spawn-rate cap of {_config.Caps.SpawnsPerSecond}/s is reached";
|
||
return null;
|
||
}
|
||
|
||
RunicNpcPlayer npc = CreateNpc(p, position, yaw, out error);
|
||
if (npc == null)
|
||
return null;
|
||
|
||
npc.RnName = p.Names[UnityEngine.Random.Range(0, p.Names.Count)];
|
||
npc.ProfileName = profileName;
|
||
npc.Profile = p;
|
||
npc.Owner = owner;
|
||
npc.PlacementId = placementId;
|
||
npc.Slot = slot;
|
||
npc.Home = position;
|
||
npc.HomeYaw = yaw;
|
||
npc.MoveKind = kind;
|
||
npc.MoveRadius = movement.Radius;
|
||
npc.RouteName = route;
|
||
|
||
// CreateEntity(..., startActive: false) leaves the GameObject inactive and skips exactly this
|
||
// call, which GameManager.CreatePrefab makes when active is true. Without it the entity still
|
||
// spawns and thinks, but Unity never starts the brain: no design, no navigator, no state
|
||
// (stage 1, Q1).
|
||
npc.gameObject.AwakeFromInstantiate();
|
||
npc.Spawn();
|
||
npc.InitializeHealth(p.Health, p.Health);
|
||
|
||
npc.Kit = p.Kits[UnityEngine.Random.Range(0, p.Kits.Count)];
|
||
npc.inventory.Strip();
|
||
object given = Kits?.Call("GiveKit", npc, npc.Kit);
|
||
if (given is string)
|
||
PrintWarning($"Kits did not give '{npc.Kit}' to {npc.RnName} ({profileName}): {given}");
|
||
npc.EquipWeapon();
|
||
|
||
_npcs[npc.NetId] = npc;
|
||
Index(npc, true);
|
||
Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner);
|
||
return npc;
|
||
}
|
||
|
||
/// <summary>Removes one NPC from the registry, however it went: death, despawn or unload.</summary>
|
||
private void Unregister(RunicNpcPlayer npc)
|
||
{
|
||
if (!_npcs.Remove(npc.NetId))
|
||
return;
|
||
Index(npc, false);
|
||
|
||
if (npc.PlacementId != null)
|
||
{
|
||
PlacementState ps;
|
||
if (_placements.TryGetValue(npc.PlacementId, out ps) && npc.Slot >= 0 && npc.Slot < ps.Slots.Length && ps.Slots[npc.Slot].Npc == npc)
|
||
{
|
||
ps.Slots[npc.Slot].Npc = null;
|
||
ps.Slots[npc.Slot].DiedAt = Time.realtimeSinceStartup;
|
||
}
|
||
}
|
||
|
||
if (!npc.Died && !_unloading)
|
||
Interface.CallHook("OnRunicNpcDespawned", npc, npc.Owner);
|
||
}
|
||
|
||
private int Despawn(IEnumerable<RunicNpcPlayer> npcs)
|
||
{
|
||
int n = 0;
|
||
foreach (RunicNpcPlayer npc in npcs.ToList())
|
||
{
|
||
if (npc == null || npc.IsDestroyed)
|
||
continue;
|
||
Unregister(npc);
|
||
npc.Kill();
|
||
n++;
|
||
}
|
||
|
||
return n;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A death. It is unregistered at once, so a placement's respawn delay counts from the death,
|
||
/// not from whenever the body's entity is finally removed.
|
||
/// </summary>
|
||
private void HandleDied(RunicNpcPlayer npc, HitInfo info)
|
||
{
|
||
var contributors = new Dictionary<ulong, float>(npc.Contributors);
|
||
Unregister(npc);
|
||
Interface.CallHook("OnRunicNpcDied", npc, npc.ProfileName, npc.Owner, info, contributors);
|
||
}
|
||
|
||
private void NoteRegrounded(RunicNpcPlayer npc, Vector3 from, Vector3 to)
|
||
{
|
||
string text = $"its ground went from under {npc.RnName} ({from.y - to.y:0.0} m), who now stands on the nearest navmesh until it respawns";
|
||
PlacementState ps;
|
||
if (npc.PlacementId != null && _placements.TryGetValue(npc.PlacementId, out ps))
|
||
ps.Note = text;
|
||
Puts($"{npc.Owner}: {text}.");
|
||
}
|
||
|
||
private void CheckThresholds(RunicNpcPlayer npc)
|
||
{
|
||
float fraction = npc.health / npc.MaxHealth();
|
||
foreach (float t in npc.Profile.HealthThresholds.OrderByDescending(x => x))
|
||
if (fraction <= t && npc.ThresholdsFired.Add(t))
|
||
Interface.CallHook("OnRunicNpcHealth", npc, npc.ProfileName, t);
|
||
}
|
||
|
||
private Route RouteFor(RunicNpcPlayer npc)
|
||
{
|
||
Route route;
|
||
return npc.RouteName != null && _routeFile.Routes.TryGetValue(npc.RouteName, out route) && route.Points.Count > 0 ? route : null;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Placements
|
||
|
||
private void RebuildPlacements()
|
||
{
|
||
foreach (PlacementState ps in _placements.Values)
|
||
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
|
||
_placements.Clear();
|
||
_queue.Clear();
|
||
|
||
foreach (KeyValuePair<string, Placement> kv in _placementFile.Placements)
|
||
{
|
||
string error = ValidatePlacement(kv.Value);
|
||
if (error != null)
|
||
{
|
||
PrintWarning($"Placement '{kv.Key}' is ignored: {error}");
|
||
continue;
|
||
}
|
||
|
||
_placements[kv.Key] = NewState(kv.Key, kv.Value);
|
||
}
|
||
}
|
||
|
||
private static PlacementState NewState(string id, Placement data)
|
||
{
|
||
var ps = new PlacementState { Id = id, Data = data, Slots = new Slot[data.Count] };
|
||
for (int i = 0; i < ps.Slots.Length; i++)
|
||
ps.Slots[i] = new Slot();
|
||
return ps;
|
||
}
|
||
|
||
/// <summary>Why a placement cannot spawn right now, or null (D237: it waits, it is not removed).</summary>
|
||
private string WaitReason(Placement data)
|
||
{
|
||
Profile p;
|
||
if (!_profiles.TryGetValue(data.Profile, out p))
|
||
return _refused.ContainsKey(data.Profile) ? $"profile '{data.Profile}' is refused: {_refused[data.Profile]}" : $"profile '{data.Profile}' is missing";
|
||
Movement movement = data.Movement ?? p.Movement;
|
||
string kind, route;
|
||
ParseMode(movement.Mode, out kind, out route);
|
||
if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route))
|
||
return $"route '{route}' is missing";
|
||
|
||
// D272: a zone that has gone holds its placement back, as a missing route does, rather than
|
||
// letting NPCs that were meant to stay inside it roam the map.
|
||
if (data.Tether != null && (ZoneManager == null || ZoneManager.Call("CheckZoneID", data.Tether) == null))
|
||
return ZoneManager == null ? $"its zone '{data.Tether}' needs ZoneManager, which is not loaded" : $"its zone '{data.Tether}' is missing";
|
||
return null;
|
||
}
|
||
|
||
/// <summary>Once a second: what each placement is waiting for, and which slots are due.</summary>
|
||
private void TickPlacements()
|
||
{
|
||
if (_placementsDirty)
|
||
SavePlacements();
|
||
if (!_ready || _unloading)
|
||
return;
|
||
|
||
float now = Time.realtimeSinceStartup;
|
||
bool queued = false;
|
||
foreach (PlacementState ps in _placements.Values)
|
||
{
|
||
string waiting = WaitReason(ps.Data);
|
||
if (waiting != null)
|
||
{
|
||
if (ps.Waiting != waiting)
|
||
PrintWarning($"Placement '{ps.Id}' waits: {waiting}");
|
||
ps.Waiting = waiting;
|
||
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
|
||
foreach (Slot s in ps.Slots)
|
||
{
|
||
s.DiedAt = float.NegativeInfinity;
|
||
s.Queued = false;
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (ps.Waiting != null)
|
||
Puts($"Placement '{ps.Id}' no longer waits.");
|
||
ps.Waiting = null;
|
||
|
||
if (ps.Data.RespawnMode == "group")
|
||
{
|
||
if (ps.Slots.All(s => s.Npc == null && !s.Queued) && now >= ps.Slots.Max(s => s.DiedAt) + ps.Data.Respawn)
|
||
for (int i = 0; i < ps.Slots.Length; i++)
|
||
queued |= Enqueue(ps, i);
|
||
}
|
||
else
|
||
{
|
||
for (int i = 0; i < ps.Slots.Length; i++)
|
||
if (ps.Slots[i].Npc == null && !ps.Slots[i].Queued && now >= ps.Slots[i].DiedAt + ps.Data.Respawn)
|
||
queued |= Enqueue(ps, i);
|
||
}
|
||
}
|
||
|
||
if (queued)
|
||
SchedulePump();
|
||
}
|
||
|
||
private bool Enqueue(PlacementState ps, int slot)
|
||
{
|
||
ps.Slots[slot].Queued = true;
|
||
_queue.Enqueue(new SpawnRequest { Placement = ps, Slot = slot });
|
||
return true;
|
||
}
|
||
|
||
private void SchedulePump()
|
||
{
|
||
if (_pumpScheduled || _unloading)
|
||
return;
|
||
_pumpScheduled = true;
|
||
NextFrame(Pump);
|
||
}
|
||
|
||
private void PumpLater(float seconds)
|
||
{
|
||
_pumpScheduled = true;
|
||
timer.Once(seconds, () =>
|
||
{
|
||
_pumpScheduled = false;
|
||
SchedulePump();
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Spawns queued placement slots within the frame budget, and carries the rest to the next
|
||
/// frame (stage 1, Q5). Nothing spawns until the map's navmesh is built (Q4).
|
||
/// </summary>
|
||
private void Pump()
|
||
{
|
||
_pumpScheduled = false;
|
||
if (_unloading)
|
||
return;
|
||
|
||
if (!NavmeshReady())
|
||
{
|
||
if (!_navmeshWaitLogged)
|
||
Puts($"Waiting for the map's navmesh before spawning {_queue.Count} placement NPC(s); on a map's first boot this takes minutes.");
|
||
_navmeshWaitLogged = true;
|
||
PumpLater(5f);
|
||
return;
|
||
}
|
||
|
||
Stopwatch clock = Stopwatch.StartNew();
|
||
while (_queue.Count > 0 && clock.Elapsed.TotalMilliseconds < _config.SpawnBudgetMs)
|
||
{
|
||
SpawnRequest r = _queue.Peek();
|
||
if (_config.Caps.SpawnsPerSecond > 0f && _tokens < 1f && !WouldHaveToken())
|
||
{
|
||
PumpLater(0.25f);
|
||
return;
|
||
}
|
||
|
||
_queue.Dequeue();
|
||
Slot slot = r.Placement.Slots.Length > r.Slot ? r.Placement.Slots[r.Slot] : null;
|
||
if (slot == null || !_placements.ContainsKey(r.Placement.Id) || _placements[r.Placement.Id] != r.Placement)
|
||
continue;
|
||
slot.Queued = false;
|
||
if (slot.Npc != null || r.Placement.Waiting != null)
|
||
continue;
|
||
SpawnSlot(r.Placement, r.Slot);
|
||
}
|
||
|
||
if (_queue.Count > 0)
|
||
SchedulePump();
|
||
}
|
||
|
||
private bool WouldHaveToken()
|
||
{
|
||
float rate = _config.Caps.SpawnsPerSecond;
|
||
return _tokens + (Time.realtimeSinceStartup - _tokensAt) * rate >= 1f;
|
||
}
|
||
|
||
private void SpawnSlot(PlacementState ps, int slot)
|
||
{
|
||
Placement data = ps.Data;
|
||
Profile p = _profiles[data.Profile];
|
||
Vector3 spot = data.Position.V;
|
||
Vector3 at = spot;
|
||
if (slot > 0)
|
||
{
|
||
// A scattered spot that leaves a small floor takes the placement's own spot instead.
|
||
Vector2 offset = UnityEngine.Random.insideUnitCircle * Scatter;
|
||
at = spot + new Vector3(offset.x, 0f, offset.y);
|
||
if (!p.IsSentry && OffMeshReason(at) != null)
|
||
at = spot;
|
||
}
|
||
|
||
// D239: the spot was on the navmesh when it was placed. If the world has changed under it (a
|
||
// player-built floor destroyed), the NPC stands on the nearest navmesh until the spot returns.
|
||
string off = p.IsSentry ? null : OffMeshReason(at);
|
||
NavMeshHit nearest;
|
||
if (off != null && SampleNavmesh(at, Fallback, out nearest))
|
||
{
|
||
string note = $"its spot is off the navmesh now ({off}), so its NPCs stand on the nearest, {Meters(at, nearest.position)} away";
|
||
if (ps.Note != note)
|
||
PrintWarning($"Placement '{ps.Id}': {note}.");
|
||
ps.Note = note;
|
||
at = nearest.position;
|
||
}
|
||
else if (off == null && slot == 0)
|
||
ps.Note = null;
|
||
|
||
string error;
|
||
RunicNpcPlayer npc = Spawn(data.Profile, p, at, data.Yaw, "placement:" + ps.Id, data.Movement, ps.Id, slot, out error);
|
||
if (npc == null)
|
||
{
|
||
if (ps.LastError != error)
|
||
PrintWarning($"Placement '{ps.Id}' could not spawn: {error}. It retries after its respawn delay.");
|
||
ps.LastError = error;
|
||
ps.Slots[slot].DiedAt = Time.realtimeSinceStartup;
|
||
return;
|
||
}
|
||
|
||
ps.LastError = null;
|
||
ps.Slots[slot].Npc = npc;
|
||
npc.Tether = data.Tether;
|
||
}
|
||
|
||
/// <summary>The count a placement plans, whether or not its NPCs are alive now.</summary>
|
||
private int PlannedTotal() =>
|
||
_placements.Values.Sum(ps => ps.Slots.Length) + _npcs.Values.Count(n => n.PlacementId == null);
|
||
|
||
#endregion
|
||
|
||
#region The cost warning (D227)
|
||
|
||
/// <summary>
|
||
/// What <paramref name="total"/> NPCs cost the server, from stage 1's measurements on both
|
||
/// frameworks (PLAN.md §9, stage 1, Q5). The rig ran about 50 fps empty, so the warning
|
||
/// says what NPCs ADD to a frame, not an fps.
|
||
/// </summary>
|
||
private static string CostWarning(int total)
|
||
{
|
||
if (total <= 0)
|
||
return "RunicNPC: no NPCs on this server.";
|
||
float hundreds = total / 100f;
|
||
string estimate = total > 100 ? " (measured up to 100; beyond that this is an estimate)" : "";
|
||
return $"RunicNPC: {total} NPC(s) on this server{estimate}. Measured on a test server, they add about " +
|
||
$"{Mathf.Max(0.1f, hundreds * 1.5f):0.#} ms to every server frame while idle. If all fight at once they add about " +
|
||
$"{hundreds * 5f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " +
|
||
$"{Mathf.Max(0.3f, hundreds * 2.5f):0.#} s. Fighting other NPCs (a faction setting) was measured to cost about " +
|
||
$"what fighting players does; on a map whose own AI is busy, each then reacts only every " +
|
||
$"{Mathf.Max(0.5f, hundreds * 7f):0.#} s.";
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Lifecycle and hooks
|
||
|
||
private void Init()
|
||
{
|
||
_self = this;
|
||
foreach (string hook in ExpectedHooks)
|
||
_hookCounts[hook] = 0L;
|
||
RegisterVerbs();
|
||
}
|
||
|
||
private void OnServerInitialized()
|
||
{
|
||
MarkHook("OnServerInitialized");
|
||
ResolveFields();
|
||
LoadData();
|
||
RevalidateProfiles();
|
||
RebuildPlacements();
|
||
TrackWorld();
|
||
PatchSentries();
|
||
_ready = true;
|
||
timer.Every(1f, TickPlacements);
|
||
Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " +
|
||
$"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}");
|
||
TickPlacements();
|
||
}
|
||
|
||
private void OnServerSave()
|
||
{
|
||
MarkHook("OnServerSave");
|
||
if (_placementsDirty)
|
||
SavePlacements();
|
||
}
|
||
|
||
private void OnEntityKill(BaseNetworkable entity)
|
||
{
|
||
MarkHook("OnEntityKill");
|
||
RunicNpcPlayer npc = entity as RunicNpcPlayer;
|
||
if (npc != null)
|
||
Unregister(npc);
|
||
else
|
||
Untrack(entity);
|
||
}
|
||
|
||
/// <summary>A plugin's NPCs die with it (§2: <c>plugin:<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;
|
||
UnpatchSentries();
|
||
Despawn(_npcs.Values);
|
||
_npcs.Clear();
|
||
if (_placementsDirty)
|
||
SavePlacements();
|
||
_self = null;
|
||
}
|
||
|
||
private void MarkHook(string name)
|
||
{
|
||
long count;
|
||
_hookCounts.TryGetValue(name, out count);
|
||
_hookCounts[name] = count + 1;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region The API (PLAN.md §4, docs/runicnpc/API.md)
|
||
|
||
// Every call is private: Oxide's Call reaches a non-public method by name, and a public one only
|
||
// with [HookMethod] (the PR check holds this). Each returns a value or null and says why in the log.
|
||
|
||
private int RunicNpc_ApiVersion() => ApiVersion;
|
||
|
||
private BasePlayer RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)
|
||
{
|
||
string error = null;
|
||
RunicNpcPlayer npc = null;
|
||
Profile p;
|
||
if (owner == null || !OwnerRule.IsMatch(owner))
|
||
error = "owner must be run:<id> or plugin:<name> (placement: owners are RunicNPC's own)";
|
||
else if (profile == null || !_profiles.TryGetValue(profile, out p))
|
||
error = profile != null && _refused.ContainsKey(profile) ? $"profile '{profile}' is refused: {_refused[profile]}" : $"no profile '{profile}'";
|
||
else
|
||
{
|
||
Profile effective = p;
|
||
if (overrides != null)
|
||
{
|
||
try
|
||
{
|
||
effective = Effective(p, overrides);
|
||
error = ValidateProfile(effective);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
error = "overrides could not be applied: " + e.Message;
|
||
}
|
||
}
|
||
|
||
if (error == null)
|
||
npc = Spawn(profile, effective, at, 0f, owner, null, null, -1, out error);
|
||
}
|
||
|
||
if (npc == null)
|
||
PrintWarning($"RunicNpc_Spawn('{profile}', {owner}) refused: {error}");
|
||
return npc;
|
||
}
|
||
|
||
private int RunicNpc_Despawn(ulong netId)
|
||
{
|
||
RunicNpcPlayer npc;
|
||
return _npcs.TryGetValue(netId, out npc) ? Despawn(new[] { npc }) : 0;
|
||
}
|
||
|
||
private int RunicNpc_DespawnOwner(string owner) => owner == null ? 0 : Despawn(_npcs.Values.Where(n => n.Owner == owner));
|
||
|
||
private List<Dictionary<string, object>> RunicNpc_List(string owner) =>
|
||
_npcs.Values.Where(n => owner == null || n.Owner == owner).Select(Describe).ToList();
|
||
|
||
private static Dictionary<string, object> Describe(RunicNpcPlayer n)
|
||
{
|
||
Vector3 p = n.transform.position;
|
||
RunicNpcBrain brain = n.RnBrain;
|
||
return new Dictionary<string, object>
|
||
{
|
||
["netId"] = n.NetId,
|
||
["profile"] = n.ProfileName,
|
||
["name"] = n.RnName,
|
||
["owner"] = n.Owner,
|
||
["placement"] = n.PlacementId,
|
||
["kit"] = n.Kit,
|
||
["position"] = new Dictionary<string, object> { ["x"] = p.x, ["y"] = p.y, ["z"] = p.z },
|
||
["home"] = new Dictionary<string, object> { ["x"] = n.Home.x, ["y"] = n.Home.y, ["z"] = n.Home.z },
|
||
["health"] = n.health,
|
||
["maxHealth"] = n.MaxHealth(),
|
||
["movement"] = n.MoveKind == "route" ? "route:" + n.RouteName : n.MoveKind,
|
||
["rest"] = brain != null ? brain.Rest.ToString() : "?",
|
||
["state"] = brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none",
|
||
["leashes"] = brain != null ? brain.Leashes : 0,
|
||
["regrounded"] = brain != null ? brain.Regrounded : 0,
|
||
["faction"] = n.Profile != null ? FactionOf(n) : null,
|
||
["target"] = n.NpcTarget != null && !n.NpcTarget.IsDestroyed ? n.NpcTarget.ShortPrefabName : null,
|
||
["engaged"] = n.Engaged,
|
||
["escort"] = n.Escort != null && !n.Escort.IsDestroyed ? (n.Escort is BasePlayer ? ((BasePlayer)n.Escort).UserIDString : n.Escort.ShortPrefabName) : null,
|
||
["ally"] = n.AllyClan != 0L ? "clan:" + n.AllyClan : n.AllyTeam != 0UL ? "team:" + n.AllyTeam : n.AllyPlayer != 0UL ? "player:" + n.AllyPlayer : null,
|
||
["tether"] = n.Tether,
|
||
["shotsMarked"] = n.ShotsMarked,
|
||
["kitUsed"] = new Dictionary<string, object> { ["heals"] = n.Heals, ["throws"] = n.Throws, ["rockets"] = n.Rockets, ["meleeHits"] = n.MeleeHits }
|
||
};
|
||
}
|
||
|
||
private JObject RunicNpc_Profiles()
|
||
{
|
||
JObject all = JObject.FromObject(_profileFile);
|
||
all["refused"] = JObject.FromObject(_refused);
|
||
return all;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Replaces the whole profile set, as a site does on every push (D221), and marks the
|
||
/// server managed. Returns the profiles refused and why; the rest are in use at once.
|
||
/// </summary>
|
||
private Dictionary<string, string> RunicNpc_SetProfiles(JObject all)
|
||
{
|
||
Dictionary<string, Profile> profiles;
|
||
try
|
||
{
|
||
profiles = all != null ? all.ToObject<Dictionary<string, Profile>>() : new Dictionary<string, Profile>();
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
return new Dictionary<string, string> { ["*"] = "the profile set could not be read: " + e.Message };
|
||
}
|
||
|
||
_profileFile.Profiles = profiles;
|
||
_profileFile.Managed = true;
|
||
SaveProfiles();
|
||
RevalidateProfiles();
|
||
TickPlacements();
|
||
return new Dictionary<string, string>(_refused);
|
||
}
|
||
|
||
private List<Dictionary<string, object>> RunicNpc_Placements() => _placements.Values.Select(ps => new Dictionary<string, object>
|
||
{
|
||
["id"] = ps.Id,
|
||
["placement"] = JObject.FromObject(ps.Data),
|
||
["alive"] = ps.Slots.Count(s => s.Npc != null),
|
||
["waiting"] = ps.Waiting,
|
||
["lastError"] = ps.LastError,
|
||
["note"] = ps.Note
|
||
}).ToList();
|
||
|
||
/// <summary>Adds or replaces a placement (D222). Null on success; otherwise why not.</summary>
|
||
private string RunicNpc_SetPlacement(string id, JObject placement)
|
||
{
|
||
if (id == null || !NameRule.IsMatch(id))
|
||
return "id: 1–40 of a-z, 0-9, _ and -";
|
||
Placement data;
|
||
try
|
||
{
|
||
data = placement?.ToObject<Placement>();
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
return "the placement could not be read: " + e.Message;
|
||
}
|
||
|
||
return SetPlacement(id, data);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Adds or replaces a placement, for the API and <c>rnpc place</c> alike. A roamer's spot must
|
||
/// be on Rust's navmesh when it is set (D219); if the world changes under it later, its NPCs
|
||
/// stand on the nearest navmesh instead (D239, <see cref="SpawnSlot"/>).
|
||
/// </summary>
|
||
private string SetPlacement(string id, Placement data)
|
||
{
|
||
string error = ValidatePlacement(data);
|
||
if (error != null)
|
||
return error;
|
||
|
||
Profile profile;
|
||
if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !profile.IsSentry)
|
||
{
|
||
string off = OffMeshReason(data.Position.V);
|
||
if (off != null)
|
||
return $"position: {off}, and only a sentry may stand off Rust's navmesh (D219)";
|
||
}
|
||
|
||
error = TetherRefusal(data.Tether, data.Position.V);
|
||
if (error != null)
|
||
return error;
|
||
|
||
PlacementState old;
|
||
if (_placements.TryGetValue(id, out old))
|
||
Despawn(old.Slots.Select(s => s.Npc).Where(n => n != null));
|
||
|
||
_placementFile.Placements[id] = data;
|
||
_placements[id] = NewState(id, data);
|
||
_placementsDirty = true;
|
||
Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}");
|
||
TickPlacements();
|
||
PlacementChanged(id, "set");
|
||
return null;
|
||
}
|
||
|
||
private bool RunicNpc_RemovePlacement(string id)
|
||
{
|
||
PlacementState ps;
|
||
if (id == null || !_placements.TryGetValue(id, out ps))
|
||
return false;
|
||
_placements.Remove(id);
|
||
_placementFile.Placements.Remove(id);
|
||
_placementsDirty = true;
|
||
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
|
||
PlacementChanged(id, "removed");
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// API 3 (D249): creates a placement and names it as <c>rnpc place</c> does (D241, D246). A
|
||
/// position without <c>y</c> is a point on a map: it is put on the ground there (terrain and
|
||
/// rock, never a building, D245) and then checked against the navmesh like any other.
|
||
/// Answers <c>{id, position, built, cost}</c>, or <c>{error}</c>.
|
||
/// </summary>
|
||
private Dictionary<string, object> RunicNpc_AddPlacement(JObject placement)
|
||
{
|
||
Placement data;
|
||
try
|
||
{
|
||
data = placement?.ToObject<Placement>();
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
return new Dictionary<string, object> { ["error"] = "the placement could not be read: " + e.Message };
|
||
}
|
||
|
||
if (data != null && data.Position != null && placement["position"]?["y"] == null)
|
||
{
|
||
Vector3 ground;
|
||
string off = GroundAt(data.Position.X, data.Position.Z, out ground);
|
||
if (off != null)
|
||
return new Dictionary<string, object> { ["error"] = off };
|
||
data.Position = Pos.Of(ground);
|
||
}
|
||
|
||
string id;
|
||
bool built;
|
||
string error = CreatePlacement(data, out id, out built);
|
||
if (error != null)
|
||
{
|
||
PrintWarning($"RunicNpc_AddPlacement('{data?.Profile}') refused: {error}");
|
||
return new Dictionary<string, object> { ["error"] = error };
|
||
}
|
||
|
||
return new Dictionary<string, object>
|
||
{
|
||
["id"] = id,
|
||
["position"] = new Dictionary<string, object> { ["x"] = data.Position.X, ["y"] = data.Position.Y, ["z"] = data.Position.Z },
|
||
["built"] = built,
|
||
["cost"] = CostWarning(PlannedTotal())
|
||
};
|
||
}
|
||
|
||
/// <summary>API 3 (D249): renames a placement, as <c>rnpc rename</c> does. Null on success.</summary>
|
||
private string RunicNpc_RenamePlacement(string from, string to) => from == null || to == null ? "Name both placements." : RenamePlacement(from, to);
|
||
|
||
/// <summary>API 3 (D249): respawns a placement's NPCs now, as <c>rnpc respawn</c>. How many, or -1 if there is no such placement.</summary>
|
||
private int RunicNpc_RespawnPlacement(string id)
|
||
{
|
||
PlacementState ps;
|
||
if (id == null || !_placements.TryGetValue(id, out ps))
|
||
return -1;
|
||
int n = RespawnNow(ps);
|
||
TickPlacements();
|
||
return n;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The ground under a point of the map: terrain or rock, never anything built or a tree (D245:
|
||
/// a roof is an in-game placement). Null with the spot, or the reason there is none.
|
||
/// </summary>
|
||
private static string GroundAt(float x, float z, out Vector3 ground)
|
||
{
|
||
ground = Vector3.zero;
|
||
float half = World.Size / 2f;
|
||
if (float.IsNaN(x) || float.IsNaN(z) || Mathf.Abs(x) > half || Mathf.Abs(z) > half)
|
||
return $"({x:0}, {z:0}) is off the map";
|
||
RaycastHit hit;
|
||
if (Physics.Raycast(new Vector3(x, 1000f, z), Vector3.down, out hit, 2000f, MapGroundMask, QueryTriggerInteraction.Ignore))
|
||
ground = hit.point;
|
||
else
|
||
ground = new Vector3(x, TerrainMeta.HeightMap.GetHeight(new Vector3(x, 0f, z)), z);
|
||
if (TerrainMeta.WaterMap != null && TerrainMeta.WaterMap.GetHeight(ground) > ground.y + 0.5f)
|
||
return $"({x:0}, {z:0}) is under water";
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// API 3 (D249): raised whenever a placement is set, removed or renamed, from the API or in
|
||
/// game alike. <c>change</c> is <c>set</c>, <c>removed</c> or <c>renamed</c> (with <c>previous</c>).
|
||
/// </summary>
|
||
private static void PlacementChanged(string id, string change, string previous = null) =>
|
||
Interface.CallHook("OnRunicNpcPlacementChanged", id, change, previous);
|
||
|
||
private JObject RunicNpc_Routes() => JObject.FromObject(_routeFile.Routes);
|
||
|
||
/// <summary>Adds or replaces a route (D234). Every point must be on Rust's navmesh.</summary>
|
||
private string RunicNpc_SetRoute(string name, JObject route)
|
||
{
|
||
if (name == null || !NameRule.IsMatch(name))
|
||
return "name: 1–40 of a-z, 0-9, _ and -";
|
||
Route data;
|
||
try
|
||
{
|
||
data = route?.ToObject<Route>();
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
return "the route could not be read: " + e.Message;
|
||
}
|
||
|
||
return SetRoute(name, data);
|
||
}
|
||
|
||
private string SetRoute(string name, Route data)
|
||
{
|
||
string error = ValidateRoute(data);
|
||
if (error != null)
|
||
return error;
|
||
_routeFile.Routes[name] = data;
|
||
SaveRoutes();
|
||
TickPlacements();
|
||
return null;
|
||
}
|
||
|
||
private bool RunicNpc_RemoveRoute(string name)
|
||
{
|
||
if (name == null || !_routeFile.Routes.Remove(name))
|
||
return false;
|
||
SaveRoutes();
|
||
return true;
|
||
}
|
||
|
||
private bool RunicNpc_IsRunicNpc(BaseEntity entity) => entity is RunicNpcPlayer;
|
||
|
||
private string RunicNpc_ProfileOf(BaseEntity entity) => (entity as RunicNpcPlayer)?.ProfileName;
|
||
|
||
/// <summary>The cost warning (D227) for the NPCs this server plans, plus <paramref name="adding"/>.</summary>
|
||
private string RunicNpc_CostWarning(int adding) => CostWarning(PlannedTotal() + Math.Max(0, adding));
|
||
|
||
// ---- API 4 (stage 5) ----
|
||
|
||
/// <summary>API 4 (D254): the faction table, one row per pair, both ways (D268).</summary>
|
||
private JArray RunicNpc_Factions() => JArray.FromObject(_profileFile.Factions);
|
||
|
||
/// <summary>
|
||
/// API 4 (D254): replaces the faction table, as a site does with every profile push, and marks
|
||
/// the server managed (D221). Null on success; otherwise why not, and nothing changes.
|
||
/// </summary>
|
||
private string RunicNpc_SetFactions(JArray pairs)
|
||
{
|
||
List<FactionPair> table;
|
||
try
|
||
{
|
||
table = pairs != null ? pairs.ToObject<List<FactionPair>>() : new List<FactionPair>();
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
return "the faction table could not be read: " + e.Message;
|
||
}
|
||
|
||
string error = ValidateFactions(table);
|
||
if (error != null)
|
||
return error;
|
||
_profileFile.Factions = table;
|
||
_profileFile.Managed = true;
|
||
SaveProfiles();
|
||
RevalidateProfiles();
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// API 4 (D258, D269): one of ours escorts an entity, a player or anything else; null stops it.
|
||
/// Null on success, else why not.
|
||
/// </summary>
|
||
private string RunicNpc_Escort(ulong netId, BaseEntity target)
|
||
{
|
||
RunicNpcPlayer npc;
|
||
if (!_npcs.TryGetValue(netId, out npc))
|
||
return $"there is no RunicNPC NPC {netId}";
|
||
return SetEscort(npc, target);
|
||
}
|
||
|
||
/// <summary>
|
||
/// API 4 (D257, D270): one of ours is allied to a clan (<c>clan</c>, the game's clan id), a team
|
||
/// (<c>team</c>), or a player and that player's team (<c>player</c>, a Steam id), or to nobody
|
||
/// (<c>none</c>). Null on success, else why not.
|
||
/// </summary>
|
||
private string RunicNpc_Ally(ulong netId, string kind, string id)
|
||
{
|
||
RunicNpcPlayer npc;
|
||
if (!_npcs.TryGetValue(netId, out npc))
|
||
return $"there is no RunicNPC NPC {netId}";
|
||
return SetAlly(npc, kind, id);
|
||
}
|
||
|
||
/// <summary>API 4 (D272): holds one of ours inside a ZoneManager zone, or frees it with null.</summary>
|
||
private string RunicNpc_Tether(ulong netId, string zone)
|
||
{
|
||
RunicNpcPlayer npc;
|
||
if (!_npcs.TryGetValue(netId, out npc))
|
||
return $"there is no RunicNPC NPC {netId}";
|
||
if (zone != null && npc.Profile.IsSentry)
|
||
return $"{npc.RnName} is a sentry and never moves, so it needs no zone";
|
||
string error = TetherRefusal(zone, npc.transform.position);
|
||
if (error != null)
|
||
return error;
|
||
npc.Tether = zone;
|
||
return null;
|
||
}
|
||
|
||
private string SetAlly(RunicNpcPlayer npc, string kind, string id)
|
||
{
|
||
long clan = 0L;
|
||
ulong team = 0UL, player = 0UL;
|
||
switch (kind)
|
||
{
|
||
case "none":
|
||
case null:
|
||
break;
|
||
case "clan":
|
||
if (!long.TryParse(id, out clan) || clan == 0L)
|
||
return "a clan is the game's clan id";
|
||
if (ReadClan(clan) == null)
|
||
return $"this server has no clan {id}";
|
||
break;
|
||
case "team":
|
||
if (!ulong.TryParse(id, out team) || team == 0UL || RelationshipManager.ServerInstance?.FindTeam(team) == null)
|
||
return $"this server has no team {id}";
|
||
break;
|
||
case "player":
|
||
if (!ulong.TryParse(id, out player) || player == 0UL)
|
||
return "a player is a Steam id";
|
||
BasePlayer p = BasePlayer.FindAwakeOrSleepingByID(player);
|
||
if (p != null && p.currentTeam != 0UL)
|
||
team = p.currentTeam;
|
||
break;
|
||
default:
|
||
return $"'{kind}' is not clan, team, player or none";
|
||
}
|
||
|
||
npc.AllyClan = clan;
|
||
npc.AllyTeam = team;
|
||
npc.AllyPlayer = player;
|
||
NoteDefender(npc);
|
||
return null;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region In game: the commands (PLAN.md §5, stage 3)
|
||
|
||
// `/rnpc <verb>` in chat and `rnpc.<verb>` in a console run the same code. Each verb sits behind a
|
||
// permission; the server console has them all. `runicnpc.admin` includes `runicnpc.place`.
|
||
|
||
private const string PermPlace = "runicnpc.place";
|
||
private const string PermAdmin = "runicnpc.admin";
|
||
|
||
/// <summary>How far an admin's look reaches, for place, info and remove.</summary>
|
||
private const float LookReach = 100f;
|
||
|
||
/// <summary>How far below or around a spot the navmesh is looked for when saying why it is off it.</summary>
|
||
private const float Fallback = 20f;
|
||
|
||
private static readonly int GroundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default", "Tree");
|
||
private static readonly int BuiltMask = LayerMask.GetMask("Construction", "Deployed");
|
||
|
||
/// <summary>What a map point is put down on (D245): terrain and rock, never a building or a tree.</summary>
|
||
private static readonly int MapGroundMask = LayerMask.GetMask("Terrain", "World");
|
||
|
||
/// <summary>Who ran a command, and where the answer goes.</summary>
|
||
private sealed class Caller
|
||
{
|
||
/// <summary>Null for the server console.</summary>
|
||
public BasePlayer Player;
|
||
public bool Chat;
|
||
public readonly List<string> Lines = new List<string>();
|
||
|
||
public string Key => Player != null ? Player.UserIDString : "server";
|
||
|
||
public void Say(string line) => Lines.Add(line);
|
||
}
|
||
|
||
private sealed class Verb
|
||
{
|
||
public string Permission;
|
||
public string Usage;
|
||
public Action<Caller, string[]> Run;
|
||
public bool ConsoleOnly;
|
||
}
|
||
|
||
private Dictionary<string, Verb> _verbs;
|
||
|
||
/// <summary>A route being recorded, per admin (D240). Kept in memory only: a reload discards it.</summary>
|
||
private sealed class Recording
|
||
{
|
||
public string Name;
|
||
public readonly List<Vector3> Points = new List<Vector3>();
|
||
}
|
||
|
||
private readonly Dictionary<string, Recording> _recordings = new Dictionary<string, Recording>();
|
||
|
||
private void RegisterVerbs()
|
||
{
|
||
permission.RegisterPermission(PermPlace, this);
|
||
permission.RegisterPermission(PermAdmin, this);
|
||
_verbs = new Dictionary<string, Verb>
|
||
{
|
||
["place"] = new Verb { Permission = PermPlace, Usage = "place <profile> [count= respawn= mode=each|group move=wander|monument|route:<name> radius=]", Run = (c, a) => CmdPlace(c, a, false) },
|
||
["here"] = new Verb { Permission = PermPlace, Usage = "here <profile> [the same options]", Run = (c, a) => CmdPlace(c, a, true) },
|
||
["remove"] = new Verb { Permission = PermPlace, Usage = "remove [placement]", Run = CmdRemove },
|
||
["rename"] = new Verb { Permission = PermPlace, Usage = "rename <placement> <new name>", Run = CmdRename },
|
||
["near"] = new Verb { Permission = PermPlace, Usage = "near [radius]", Run = CmdNear },
|
||
["info"] = new Verb { Permission = PermPlace, Usage = "info", Run = CmdInfo },
|
||
["profiles"] = new Verb { Permission = PermPlace, Usage = "profiles", Run = CmdProfiles },
|
||
["path"] = new Verb { Permission = PermPlace, Usage = "path record <name> | point | undo | save [loop|back] | cancel | list | delete <name>", Run = CmdPath },
|
||
["tp"] = new Verb { Permission = PermAdmin, Usage = "tp <placement>", Run = CmdTp },
|
||
["respawn"] = new Verb { Permission = PermAdmin, Usage = "respawn <placement|all>", Run = CmdRespawn },
|
||
["clear"] = new Verb { Permission = PermAdmin, Usage = "clear <owner>", Run = CmdClear },
|
||
["profile"] = new Verb { Permission = PermAdmin, Usage = "profile list | show <name> | create <name> [from=<profile>] | set <name> <field> <value> | delete <name>", Run = CmdProfile, ConsoleOnly = true },
|
||
["follow"] = new Verb { Permission = PermPlace, Usage = "follow <placement> <player|me|off>", Run = CmdFollow },
|
||
["faction"] = new Verb { Permission = PermAdmin, Usage = "faction list | set <a> <b> hostile|neutral|allied | clear <a> <b>", Run = CmdFaction, ConsoleOnly = true }
|
||
};
|
||
}
|
||
|
||
private bool Allowed(Caller c, string perm) =>
|
||
c.Player == null || permission.UserHasPermission(c.Player.UserIDString, PermAdmin) ||
|
||
(perm == PermPlace && permission.UserHasPermission(c.Player.UserIDString, PermPlace));
|
||
|
||
private void Dispatch(Caller c, string verb, string[] args)
|
||
{
|
||
Verb v;
|
||
if (verb == null || verb == "help" || !_verbs.TryGetValue(verb, out v))
|
||
{
|
||
if (verb != null && verb != "help")
|
||
c.Say($"There is no '{verb}'.");
|
||
string prefix = c.Chat ? "/rnpc " : "rnpc.";
|
||
List<string> mine = _verbs.Where(kv => Allowed(c, kv.Value.Permission) && !(c.Chat && kv.Value.ConsoleOnly))
|
||
.Select(kv => prefix + kv.Value.Usage).ToList();
|
||
if (mine.Count == 0)
|
||
c.Say("You have no RunicNPC permissions.");
|
||
else
|
||
c.Lines.AddRange(mine);
|
||
return;
|
||
}
|
||
|
||
if (!Allowed(c, v.Permission))
|
||
{
|
||
c.Say($"You need the {v.Permission} permission.");
|
||
return;
|
||
}
|
||
|
||
if (c.Chat && v.ConsoleOnly)
|
||
{
|
||
c.Say($"'{verb}' is a console command: open the console (F1) and type rnpc.{verb}.");
|
||
return;
|
||
}
|
||
|
||
if (!_ready)
|
||
{
|
||
c.Say("RunicNPC is still starting.");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
v.Run(c, args);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
c.Say($"'{verb}' failed: {e.Message}");
|
||
PrintError($"rnpc {verb} {string.Join(" ", args)}: {e}");
|
||
}
|
||
}
|
||
|
||
[ChatCommand("rnpc")]
|
||
private void CmdChat(BasePlayer player, string command, string[] args)
|
||
{
|
||
var c = new Caller { Player = player, Chat = true };
|
||
Dispatch(c, args.Length > 0 ? args[0].ToLowerInvariant() : null, args.Skip(1).ToArray());
|
||
if (c.Lines.Count > 0)
|
||
player.ChatMessage(string.Join("\n", c.Lines.ToArray()));
|
||
}
|
||
|
||
private void RunConsole(ConsoleSystem.Arg arg, string verb)
|
||
{
|
||
BasePlayer player = arg.Player();
|
||
if (arg.Connection != null && player == null)
|
||
return;
|
||
var c = new Caller { Player = player };
|
||
// Rust's Args element type has changed between versions (string, then StringView), so
|
||
// read each through GetString, which both have.
|
||
int count = arg.Args?.Length ?? 0;
|
||
Dispatch(c, verb, Enumerable.Range(0, count).Select(i => arg.GetString(i)).ToArray());
|
||
arg.ReplyWith(string.Join(Environment.NewLine, c.Lines.ToArray()));
|
||
}
|
||
|
||
[ConsoleCommand("rnpc.place")] private void CcPlace(ConsoleSystem.Arg arg) => RunConsole(arg, "place");
|
||
[ConsoleCommand("rnpc.here")] private void CcHere(ConsoleSystem.Arg arg) => RunConsole(arg, "here");
|
||
[ConsoleCommand("rnpc.remove")] private void CcRemove(ConsoleSystem.Arg arg) => RunConsole(arg, "remove");
|
||
[ConsoleCommand("rnpc.rename")] private void CcRename(ConsoleSystem.Arg arg) => RunConsole(arg, "rename");
|
||
[ConsoleCommand("rnpc.near")] private void CcNear(ConsoleSystem.Arg arg) => RunConsole(arg, "near");
|
||
[ConsoleCommand("rnpc.info")] private void CcInfo(ConsoleSystem.Arg arg) => RunConsole(arg, "info");
|
||
[ConsoleCommand("rnpc.profiles")] private void CcProfiles(ConsoleSystem.Arg arg) => RunConsole(arg, "profiles");
|
||
[ConsoleCommand("rnpc.path")] private void CcPath(ConsoleSystem.Arg arg) => RunConsole(arg, "path");
|
||
[ConsoleCommand("rnpc.tp")] private void CcTp(ConsoleSystem.Arg arg) => RunConsole(arg, "tp");
|
||
[ConsoleCommand("rnpc.respawn")] private void CcRespawn(ConsoleSystem.Arg arg) => RunConsole(arg, "respawn");
|
||
[ConsoleCommand("rnpc.clear")] private void CcClear(ConsoleSystem.Arg arg) => RunConsole(arg, "clear");
|
||
[ConsoleCommand("rnpc.profile")] private void CcProfile(ConsoleSystem.Arg arg) => RunConsole(arg, "profile");
|
||
[ConsoleCommand("rnpc.help")] private void CcHelp(ConsoleSystem.Arg arg) => RunConsole(arg, "help");
|
||
[ConsoleCommand("rnpc.follow")] private void CcFollow(ConsoleSystem.Arg arg) => RunConsole(arg, "follow");
|
||
[ConsoleCommand("rnpc.faction")] private void CcFaction(ConsoleSystem.Arg arg) => RunConsole(arg, "faction");
|
||
|
||
// ---- where an admin stands and looks ----
|
||
|
||
/// <summary>The ground, a floor or a roof the admin is looking at, within <see cref="LookReach"/>.</summary>
|
||
private static bool LookSpot(BasePlayer player, out Vector3 spot)
|
||
{
|
||
RaycastHit hit;
|
||
if (Physics.Raycast(player.eyes.HeadRay(), out hit, LookReach, GroundMask, QueryTriggerInteraction.Ignore))
|
||
{
|
||
spot = hit.point;
|
||
return true;
|
||
}
|
||
|
||
spot = Vector3.zero;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Our NPC the admin is looking at: the nearest whose body is within a metre of the line of
|
||
/// sight. It reads positions, not colliders, so it finds an NPC behind a bush too.
|
||
/// </summary>
|
||
private RunicNpcPlayer LookNpc(BasePlayer player)
|
||
{
|
||
Ray ray = player.eyes.HeadRay();
|
||
RunicNpcPlayer best = null;
|
||
float bestAlong = float.MaxValue;
|
||
foreach (RunicNpcPlayer npc in _npcs.Values)
|
||
{
|
||
if (npc == null || npc.IsDestroyed)
|
||
continue;
|
||
Vector3 body = npc.transform.position + Vector3.up * 1f;
|
||
Vector3 to = body - ray.origin;
|
||
float along = Vector3.Dot(to, ray.direction);
|
||
if (along <= 0f || along > LookReach)
|
||
continue;
|
||
float off = (to - ray.direction * along).magnitude;
|
||
if (off <= 1.2f && along < bestAlong)
|
||
{
|
||
best = npc;
|
||
bestAlong = along;
|
||
}
|
||
}
|
||
|
||
return best;
|
||
}
|
||
|
||
/// <summary>Null when a roamer may stand here (on Rust's navmesh, within 2 m up or down); otherwise why not.</summary>
|
||
private static string OffMeshReason(Vector3 spot)
|
||
{
|
||
NavMeshHit hit;
|
||
if (SampleNavmesh(spot, Scatter, out hit) && Mathf.Abs(hit.position.y - spot.y) <= MaxDrop)
|
||
return null;
|
||
if (!SampleNavmesh(spot, Fallback, out hit))
|
||
{
|
||
float above = spot.y - TerrainMeta.HeightMap.GetHeight(spot);
|
||
return above > MaxDrop ? $"it is {above:0.0} m above the ground, with no navmesh within {Fallback:0} m" : $"there is no navmesh within {Fallback:0} m";
|
||
}
|
||
|
||
float dy = spot.y - hit.position.y;
|
||
if (dy > MaxDrop) return $"it is {dy:0.0} m above the navmesh";
|
||
if (dy < -MaxDrop) return $"it is {-dy:0.0} m below the navmesh";
|
||
return $"it is {new Vector2(hit.position.x - spot.x, hit.position.z - spot.z).magnitude:0.0} m from the navmesh";
|
||
}
|
||
|
||
private static Rust.Ai.Gen2.RustNavMeshPath _path;
|
||
|
||
/// <summary>
|
||
/// Whether an NPC can walk from one point to the other on Rust's navmesh. A route leg that
|
||
/// fails this leaves its NPC standing at the last point it reached (stage 3: a route recorded
|
||
/// up a hill a player walked, and the NPC never left its first point).
|
||
/// </summary>
|
||
private static bool Walkable(Vector3 from, Vector3 to)
|
||
{
|
||
if (_path == null)
|
||
_path = new Rust.Ai.Gen2.RustNavMeshPath();
|
||
return Rust.Ai.Gen2.RustNavMeshHelpers.CalculatePath(from, to, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas, _path) &&
|
||
_path.status == NavMeshPathStatus.PathComplete;
|
||
}
|
||
|
||
/// <summary>D239: a spot on something players built, which can be destroyed under the NPC.</summary>
|
||
private static bool IsPlayerBuilt(Vector3 spot)
|
||
{
|
||
RaycastHit hit;
|
||
if (!Physics.Raycast(spot + Vector3.up * 0.5f, Vector3.down, out hit, 2.5f, BuiltMask, QueryTriggerInteraction.Ignore))
|
||
return false;
|
||
BaseEntity entity = hit.GetEntity();
|
||
return entity != null && (entity is StabilityEntity || entity.OwnerID != 0UL);
|
||
}
|
||
|
||
private static string Meters(Vector3 a, Vector3 b) => $"{Vector3.Distance(a, b):0} m";
|
||
|
||
private static bool TryFloat(string s, out float f) =>
|
||
float.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out f) && !float.IsNaN(f) && !float.IsInfinity(f);
|
||
|
||
// ---- place and here (D239, D241, D242) ----
|
||
|
||
private sealed class PlaceOptions
|
||
{
|
||
public int? Count;
|
||
public float? Respawn;
|
||
public string Mode;
|
||
public string Move;
|
||
public float? Radius;
|
||
public Vector3? At;
|
||
public float? Yaw;
|
||
public string Tether;
|
||
}
|
||
|
||
/// <summary>
|
||
/// D242's <c>key=value</c> options, in any order. The server console has no position, so it
|
||
/// alone may also give <c>at=x,y,z</c> and <c>yaw=</c>.
|
||
/// </summary>
|
||
private static string ParseOptions(Caller c, string[] args, int from, out PlaceOptions o)
|
||
{
|
||
o = new PlaceOptions();
|
||
var seen = new HashSet<string>();
|
||
for (int i = from; i < args.Length; i++)
|
||
{
|
||
int eq = args[i].IndexOf('=');
|
||
if (eq <= 0)
|
||
return $"'{args[i]}' is not key=value. Options: count= respawn= mode= move= radius= tether=";
|
||
string key = args[i].Substring(0, eq).ToLowerInvariant();
|
||
string value = args[i].Substring(eq + 1);
|
||
if (!seen.Add(key))
|
||
return $"{key}= is given twice";
|
||
|
||
float f;
|
||
switch (key)
|
||
{
|
||
case "count":
|
||
int n;
|
||
if (!int.TryParse(value, out n) || n < 1) return "count= must be a whole number, at least 1";
|
||
o.Count = n;
|
||
break;
|
||
case "respawn":
|
||
if (!TryFloat(value, out f) || f < 1f) return "respawn= is in seconds, at least 1";
|
||
o.Respawn = f;
|
||
break;
|
||
case "mode":
|
||
if (value != "each" && value != "group") return "mode= is each or group";
|
||
o.Mode = value;
|
||
break;
|
||
case "move":
|
||
string kind, route;
|
||
if (!ParseMode(value, out kind, out route)) return "move= is wander, monument or route:<name>";
|
||
o.Move = value;
|
||
break;
|
||
case "radius":
|
||
if (!TryFloat(value, out f) || f <= 0f) return "radius= is in metres, above 0";
|
||
o.Radius = f;
|
||
break;
|
||
case "at":
|
||
string[] xyz = value.Split(',');
|
||
float x, y, z;
|
||
if (c.Player != null) return "at= is for the server console; in game, look at the spot or stand on it";
|
||
if (xyz.Length != 3 || !TryFloat(xyz[0], out x) || !TryFloat(xyz[1], out y) || !TryFloat(xyz[2], out z))
|
||
return "at= is x,y,z";
|
||
o.At = new Vector3(x, y, z);
|
||
break;
|
||
case "yaw":
|
||
if (c.Player != null) return "yaw= is for the server console";
|
||
if (!TryFloat(value, out f)) return "yaw= is in degrees";
|
||
o.Yaw = f;
|
||
break;
|
||
case "tether":
|
||
if (!TetherNameRule.IsMatch(value)) return "tether= is a ZoneManager zone id";
|
||
o.Tether = value;
|
||
break;
|
||
default:
|
||
return $"there is no option {key}=. Options: count= respawn= mode= move= radius= tether=";
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>D241: the profile's name and the first free number.</summary>
|
||
private string NextPlacementId(string profile)
|
||
{
|
||
string stem = profile.Length > 34 ? profile.Substring(0, 34) : profile;
|
||
for (int n = 1; ; n++)
|
||
{
|
||
string id = $"{stem}-{n}";
|
||
if (!_placementFile.Placements.ContainsKey(id) && !_placements.ContainsKey(id))
|
||
return id;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Checks, names and sets a NEW placement: <c>rnpc place</c>, <c>rnpc here</c> and
|
||
/// <c>RunicNpc_AddPlacement</c> alike, so a placement made from a website is named and refused
|
||
/// exactly as one made in game (D241, D246, D249). Null on success, else the sentence to show.
|
||
/// </summary>
|
||
private string CreatePlacement(Placement data, out string id, out bool built)
|
||
{
|
||
id = null;
|
||
built = false;
|
||
Profile p;
|
||
if (data == null || data.Profile == null || !_profiles.TryGetValue(data.Profile, out p))
|
||
{
|
||
string name = data?.Profile;
|
||
return name != null && _refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them.";
|
||
}
|
||
|
||
string error = ValidatePlacement(data);
|
||
if (error != null)
|
||
return "Not placed: " + error;
|
||
|
||
string kind, route;
|
||
ParseMode((data.Movement ?? p.Movement).Mode, out kind, out route);
|
||
if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route))
|
||
return $"There is no route '{route}'. Record it first: rnpc path record {route}";
|
||
|
||
if (!NavmeshReady())
|
||
return "The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly.";
|
||
|
||
if (!p.IsSentry)
|
||
{
|
||
string off = OffMeshReason(data.Position.V);
|
||
if (off != null)
|
||
return $"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219).";
|
||
built = IsPlayerBuilt(data.Position.V);
|
||
}
|
||
|
||
string next = NextPlacementId(data.Profile);
|
||
error = SetPlacement(next, data);
|
||
if (error != null)
|
||
return "Not placed: " + error;
|
||
id = next;
|
||
return null;
|
||
}
|
||
|
||
private void CmdPlace(Caller c, string[] args, bool here)
|
||
{
|
||
if (args.Length < 1)
|
||
{
|
||
c.Say($"Usage: {_verbs[here ? "here" : "place"].Usage}");
|
||
return;
|
||
}
|
||
|
||
string name = args[0];
|
||
Profile p;
|
||
if (!_profiles.TryGetValue(name, out p))
|
||
{
|
||
c.Say(_refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them.");
|
||
return;
|
||
}
|
||
|
||
PlaceOptions o;
|
||
string error = ParseOptions(c, args, 1, out o);
|
||
if (error != null)
|
||
{
|
||
c.Say(error);
|
||
return;
|
||
}
|
||
|
||
Vector3 spot;
|
||
float yaw;
|
||
if (o.At.HasValue)
|
||
{
|
||
spot = o.At.Value;
|
||
yaw = o.Yaw ?? 0f;
|
||
}
|
||
else if (c.Player == null)
|
||
{
|
||
c.Say("From the server console, give the spot as at=x,y,z.");
|
||
return;
|
||
}
|
||
else if (here)
|
||
{
|
||
spot = c.Player.transform.position;
|
||
yaw = c.Player.eyes.rotation.eulerAngles.y;
|
||
}
|
||
else
|
||
{
|
||
if (!LookSpot(c.Player, out spot))
|
||
{
|
||
c.Say($"Look at the ground, a floor or a roof within {LookReach:0} m.");
|
||
return;
|
||
}
|
||
|
||
// It faces the admin who placed it.
|
||
Vector3 back = c.Player.transform.position - spot;
|
||
yaw = back.sqrMagnitude > 0.01f ? Quaternion.LookRotation(new Vector3(back.x, 0f, back.z)).eulerAngles.y : 0f;
|
||
}
|
||
|
||
Movement movement = null;
|
||
if (o.Move != null || o.Radius.HasValue)
|
||
{
|
||
if (p.IsSentry)
|
||
{
|
||
c.Say($"'{name}' is a sentry and never moves: drop move= and radius=.");
|
||
return;
|
||
}
|
||
|
||
movement = new Movement { Mode = o.Move ?? p.Movement.Mode, Radius = o.Radius ?? p.Movement.Radius };
|
||
error = ValidateMovement(movement);
|
||
if (error != null)
|
||
{
|
||
c.Say(error);
|
||
return;
|
||
}
|
||
}
|
||
|
||
var data = new Placement
|
||
{
|
||
Profile = name,
|
||
Position = Pos.Of(spot),
|
||
Yaw = yaw,
|
||
Count = o.Count ?? 1,
|
||
Respawn = o.Respawn ?? 300f,
|
||
RespawnMode = o.Mode ?? "each",
|
||
Movement = movement,
|
||
Tether = o.Tether
|
||
};
|
||
|
||
if (o.Tether != null && p.IsSentry)
|
||
{
|
||
c.Say($"'{name}' is a sentry and never moves: drop tether=.");
|
||
return;
|
||
}
|
||
|
||
string id;
|
||
bool built;
|
||
error = CreatePlacement(data, out id, out built);
|
||
if (error != null)
|
||
{
|
||
c.Say(error);
|
||
return;
|
||
}
|
||
|
||
string kind, route;
|
||
ParseMode((movement ?? p.Movement).Mode, out kind, out route);
|
||
Movement m = movement ?? p.Movement;
|
||
c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " +
|
||
$"respawn {data.Respawn:0} s, {data.RespawnMode}{(data.Tether != null ? $", inside zone {data.Tether}" : "")}. Rename it with rnpc rename {id} <name>.");
|
||
if (built)
|
||
c.Say("This spot is on a player-built structure. If it is destroyed, the NPC falls back to the nearest navmesh.");
|
||
c.Say(CostWarning(PlannedTotal()));
|
||
}
|
||
|
||
// ---- remove, rename, near, info, profiles ----
|
||
|
||
/// <summary>A placement named in the command, or that of the NPC the admin is looking at.</summary>
|
||
private PlacementState NamedOrLooked(Caller c, string[] args, int at)
|
||
{
|
||
PlacementState ps;
|
||
if (args.Length > at)
|
||
{
|
||
if (_placements.TryGetValue(args[at], out ps))
|
||
return ps;
|
||
c.Say($"There is no placement '{args[at]}'. rnpc near lists those around you.");
|
||
return null;
|
||
}
|
||
|
||
if (c.Player == null)
|
||
{
|
||
c.Say("Name the placement.");
|
||
return null;
|
||
}
|
||
|
||
RunicNpcPlayer npc = LookNpc(c.Player);
|
||
if (npc == null)
|
||
{
|
||
c.Say("Look at one of RunicNPC's NPCs, or name the placement.");
|
||
return null;
|
||
}
|
||
|
||
if (npc.PlacementId == null || !_placements.TryGetValue(npc.PlacementId, out ps))
|
||
{
|
||
c.Say($"{npc.RnName} is not from a placement: it belongs to {npc.Owner}. rnpc clear {npc.Owner} removes that owner's NPCs.");
|
||
return null;
|
||
}
|
||
|
||
return ps;
|
||
}
|
||
|
||
private void CmdRemove(Caller c, string[] args)
|
||
{
|
||
PlacementState ps = NamedOrLooked(c, args, 0);
|
||
if (ps == null)
|
||
return;
|
||
int alive = ps.Slots.Count(s => s.Npc != null);
|
||
RunicNpc_RemovePlacement(ps.Id);
|
||
c.Say($"Removed {ps.Id} and its {alive} NPC(s). {CostWarning(PlannedTotal())}");
|
||
}
|
||
|
||
private void CmdRename(Caller c, string[] args)
|
||
{
|
||
if (args.Length != 2)
|
||
{
|
||
c.Say("Usage: rnpc rename <placement> <new name>");
|
||
return;
|
||
}
|
||
|
||
string error = RenamePlacement(args[0], args[1]);
|
||
c.Say(error ?? $"Renamed {args[0]} to {args[1]}.");
|
||
}
|
||
|
||
private string RenamePlacement(string from, string to)
|
||
{
|
||
PlacementState ps;
|
||
if (!_placements.TryGetValue(from, out ps))
|
||
return $"There is no placement '{from}'.";
|
||
if (!NameRule.IsMatch(to))
|
||
return "A name is 1–40 of a-z, 0-9, _ and -.";
|
||
if (_placements.ContainsKey(to) || _placementFile.Placements.ContainsKey(to))
|
||
return $"'{to}' is taken.";
|
||
|
||
_placements.Remove(from);
|
||
_placementFile.Placements.Remove(from);
|
||
ps.Id = to;
|
||
_placements[to] = ps;
|
||
_placementFile.Placements[to] = ps.Data;
|
||
foreach (Slot slot in ps.Slots)
|
||
{
|
||
if (slot.Npc == null)
|
||
continue;
|
||
slot.Npc.PlacementId = to;
|
||
slot.Npc.Owner = "placement:" + to;
|
||
}
|
||
|
||
_placementsDirty = true;
|
||
Puts($"Placement '{from}' renamed to '{to}'.");
|
||
PlacementChanged(to, "renamed", from);
|
||
return null;
|
||
}
|
||
|
||
private void CmdNear(Caller c, string[] args)
|
||
{
|
||
float radius = 50f;
|
||
if (args.Length > 0 && (!TryFloat(args[0], out radius) || radius <= 0f))
|
||
{
|
||
c.Say("Usage: rnpc near [radius in metres]");
|
||
return;
|
||
}
|
||
|
||
if (c.Player == null)
|
||
{
|
||
c.Say("From the server console there is no 'near': rnpc.status counts everything.");
|
||
return;
|
||
}
|
||
|
||
Vector3 here = c.Player.transform.position;
|
||
List<PlacementState> places = _placements.Values.Where(ps => Vector3.Distance(ps.Data.Position.V, here) <= radius)
|
||
.OrderBy(ps => Vector3.Distance(ps.Data.Position.V, here)).ToList();
|
||
List<RunicNpcPlayer> loose = _npcs.Values.Where(n => n.PlacementId == null && Vector3.Distance(n.transform.position, here) <= radius)
|
||
.OrderBy(n => Vector3.Distance(n.transform.position, here)).ToList();
|
||
if (places.Count == 0 && loose.Count == 0)
|
||
{
|
||
c.Say($"Nothing of RunicNPC's within {radius:0} m.");
|
||
return;
|
||
}
|
||
|
||
foreach (PlacementState ps in places)
|
||
{
|
||
Placement d = ps.Data;
|
||
string move = d.Movement != null ? $" · move {d.Movement.Mode}" : "";
|
||
string state = ps.Waiting != null ? $" · waiting: {ps.Waiting}" : ps.LastError != null ? $" · {ps.LastError}" : ps.Note != null ? $" · {ps.Note}" : "";
|
||
c.Say($"{ps.Id} · {d.Count} × {d.Profile} ({ps.Slots.Count(s => s.Npc != null)} alive) · {Meters(d.Position.V, here)} · respawn {d.Respawn:0} s {d.RespawnMode}{move}{state}");
|
||
}
|
||
|
||
foreach (RunicNpcPlayer n in loose)
|
||
c.Say($"{n.RnName} ({n.ProfileName}) · {n.Owner} · {Meters(n.transform.position, here)}");
|
||
}
|
||
|
||
private void CmdInfo(Caller c, string[] args)
|
||
{
|
||
if (c.Player == null)
|
||
{
|
||
c.Say("Info is for the NPC you look at, in game.");
|
||
return;
|
||
}
|
||
|
||
RunicNpcPlayer npc = LookNpc(c.Player);
|
||
if (npc == null)
|
||
{
|
||
c.Say("Look at one of RunicNPC's NPCs.");
|
||
return;
|
||
}
|
||
|
||
RunicNpcBrain brain = npc.RnBrain;
|
||
BaseEntity target = npc.GetBestTarget();
|
||
string targetText = target == null ? "none"
|
||
: $"{(target is BasePlayer ? ((BasePlayer)target).displayName : target.ShortPrefabName)} {Meters(target.transform.position, npc.transform.position)}";
|
||
string move = npc.Profile.IsSentry ? "holds its spot" : npc.MoveKind == "route" ? "route:" + npc.RouteName : npc.MoveKind == "wander" ? $"wander {npc.MoveRadius:0} m" : npc.MoveKind;
|
||
c.Say($"{npc.RnName} · profile {npc.ProfileName} ({npc.Profile.Role}) · {(npc.PlacementId != null ? "placement " + npc.PlacementId : npc.Owner)}");
|
||
c.Say($"health {npc.health:0}/{npc.MaxHealth():0} · state {(brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none")} · " +
|
||
$"{(brain != null ? brain.Rest.ToString().ToLowerInvariant() : "?")} · {move} · kit {npc.Kit}");
|
||
c.Say($"target {targetText} · {Meters(npc.transform.position, npc.Home)} from home");
|
||
|
||
BaseCombatEntity own = npc.NpcTarget;
|
||
HashSet<string> hunts = HuntsOf(npc);
|
||
c.Say($"faction {FactionOf(npc)} · hunts {(hunts != null ? string.Join(", ", hunts.ToArray()) : "players only")} · " +
|
||
$"fighting {(own != null && !own.IsDestroyed ? $"{own.ShortPrefabName} {Meters(own.transform.position, npc.transform.position)}{(npc.Engaged ? "" : " (waits)")}" : "no NPC")}");
|
||
var orders = new List<string>();
|
||
if (npc.Escort != null && !npc.Escort.IsDestroyed)
|
||
orders.Add("escorts " + (npc.Escort is BasePlayer ? ((BasePlayer)npc.Escort).displayName : npc.Escort.ShortPrefabName));
|
||
if (npc.HasAlly)
|
||
orders.Add("allied to " + (npc.AllyClan != 0L ? "clan " + npc.AllyClan : npc.AllyTeam != 0UL ? "team " + npc.AllyTeam : "player " + npc.AllyPlayer));
|
||
if (npc.Tether != null)
|
||
orders.Add("held in zone " + npc.Tether);
|
||
if (orders.Count > 0)
|
||
c.Say(string.Join(" · ", orders.ToArray()));
|
||
}
|
||
|
||
// ---- follow (D258, D269) ----
|
||
|
||
/// <summary>
|
||
/// <c>rnpc follow <placement> <player|me|off></c>: the placement's live NPCs escort a
|
||
/// player, for trying it out in game (D258). In memory only: a respawn or a reload ends it, and a
|
||
/// placement never escorts anyone of its own accord.
|
||
/// </summary>
|
||
private void CmdFollow(Caller c, string[] args)
|
||
{
|
||
if (args.Length < 2)
|
||
{
|
||
c.Say("Usage: rnpc " + _verbs["follow"].Usage);
|
||
return;
|
||
}
|
||
|
||
PlacementState ps;
|
||
if (!_placements.TryGetValue(args[0], out ps))
|
||
{
|
||
c.Say($"There is no placement '{args[0]}'. rnpc near lists those around you.");
|
||
return;
|
||
}
|
||
|
||
BasePlayer target = null;
|
||
string who = args[1];
|
||
if (who == "me")
|
||
{
|
||
if (c.Player == null)
|
||
{
|
||
c.Say("The server console cannot be followed; name a player.");
|
||
return;
|
||
}
|
||
|
||
target = c.Player;
|
||
}
|
||
else if (who != "off")
|
||
{
|
||
target = BasePlayer.Find(who);
|
||
if (target == null)
|
||
{
|
||
c.Say($"No player on the server matches '{who}'.");
|
||
return;
|
||
}
|
||
}
|
||
|
||
int n = 0;
|
||
foreach (Slot s in ps.Slots)
|
||
{
|
||
if (s.Npc == null || s.Npc.IsDestroyed)
|
||
continue;
|
||
string error = SetEscort(s.Npc, target);
|
||
if (error != null)
|
||
{
|
||
c.Say(error + ".");
|
||
return;
|
||
}
|
||
|
||
n++;
|
||
}
|
||
|
||
c.Say(n == 0 ? $"{ps.Id} has no live NPCs now." : target == null
|
||
? $"{ps.Id}: {n} NPC(s) stop escorting and walk back to their spot."
|
||
: $"{ps.Id}: {n} NPC(s) escort {target.displayName}. They walk back to their spot if {target.displayName} dies or leaves; rnpc follow {ps.Id} off ends it.");
|
||
}
|
||
|
||
// ---- faction (D254, D268) ----
|
||
|
||
/// <summary><c>rnpc.faction</c>: the faction table on a standalone server; refused when a site manages it (D221).</summary>
|
||
private void CmdFaction(Caller c, string[] args)
|
||
{
|
||
string sub = args.Length > 0 ? args[0].ToLowerInvariant() : "list";
|
||
if (sub == "list")
|
||
{
|
||
List<FactionPair> table = _profileFile.Factions;
|
||
var named = _profiles.Where(kv => !string.IsNullOrEmpty(kv.Value.Faction)).GroupBy(kv => kv.Value.Faction)
|
||
.Select(g => $"{g.Key} ({string.Join(", ", g.Select(kv => kv.Key).ToArray())})").ToList();
|
||
c.Say($"Factions in use: {(named.Count > 0 ? string.Join("; ", named.ToArray()) : "none")}. Rust's own: scientists, animals.");
|
||
if (table.Count == 0)
|
||
c.Say("The table is empty: every pair is neutral, and a profile with no faction settings fights players only (D255).");
|
||
foreach (FactionPair f in table.OrderBy(f => f.A).ThenBy(f => f.B))
|
||
c.Say($"{f.A} ↔ {f.B}: {f.Relation}");
|
||
if (_profileFile.Managed)
|
||
c.Say("Managed by this server's website (D221): edit the table there.");
|
||
return;
|
||
}
|
||
|
||
if (_profileFile.Managed)
|
||
{
|
||
c.Say("This server's faction table is managed by its website (D221): edit it there.");
|
||
return;
|
||
}
|
||
|
||
if ((sub == "set" && args.Length < 4) || (sub == "clear" && args.Length < 3) || (sub != "set" && sub != "clear"))
|
||
{
|
||
c.Say("Usage: rnpc." + _verbs["faction"].Usage);
|
||
return;
|
||
}
|
||
|
||
string a = args[1], b = args[2];
|
||
var next = _profileFile.Factions.Where(f => PairKey(f.A, f.B) != PairKey(a, b)).ToList();
|
||
if (sub == "set")
|
||
next.Add(new FactionPair { A = a, B = b, Relation = args[3].ToLowerInvariant() });
|
||
string error = ValidateFactions(next);
|
||
if (error != null)
|
||
{
|
||
c.Say(Regex.Replace(error, @"^factions\[\d+\]: ", ""));
|
||
return;
|
||
}
|
||
|
||
_profileFile.Factions = next;
|
||
SaveProfiles();
|
||
RevalidateProfiles();
|
||
c.Say(sub == "set" ? $"{a} ↔ {b}: {args[3].ToLowerInvariant()}, both ways (D268)." : $"{a} ↔ {b}: back to neutral.");
|
||
}
|
||
|
||
private void CmdProfiles(Caller c, string[] args)
|
||
{
|
||
if (_profiles.Count == 0 && _refused.Count == 0)
|
||
{
|
||
c.Say(_profileFile.Managed ? "This server has no profiles yet; its website manages them." : "This server has no profiles yet. rnpc.profile create <name> makes one.");
|
||
return;
|
||
}
|
||
|
||
c.Say(_profileFile.Managed ? "Profiles (managed by this server's website):" : "Profiles:");
|
||
foreach (KeyValuePair<string, Profile> kv in _profiles.OrderBy(kv => kv.Key))
|
||
{
|
||
Profile p = kv.Value;
|
||
c.Say($"{kv.Key} · {p.Role}{(p.IsSentry ? "" : " " + p.Movement.Mode)} · {p.Health:0} hp · kits {string.Join(", ", p.Kits.ToArray())}");
|
||
}
|
||
|
||
foreach (KeyValuePair<string, string> kv in _refused.OrderBy(kv => kv.Key))
|
||
c.Say($"{kv.Key} · refused: {kv.Value}");
|
||
}
|
||
|
||
// ---- tp, respawn, clear ----
|
||
|
||
private void CmdTp(Caller c, string[] args)
|
||
{
|
||
if (c.Player == null)
|
||
{
|
||
c.Say("Teleporting is for a player.");
|
||
return;
|
||
}
|
||
|
||
PlacementState ps;
|
||
if (args.Length != 1 || !_placements.TryGetValue(args[0], out ps))
|
||
{
|
||
c.Say(args.Length == 1 ? $"There is no placement '{args[0]}'." : "Usage: rnpc tp <placement>");
|
||
return;
|
||
}
|
||
|
||
c.Player.Teleport(ps.Data.Position.V + Vector3.up * 0.2f);
|
||
c.Say($"At {ps.Id}.");
|
||
}
|
||
|
||
/// <summary>A placement's NPCs, despawned and spawned fresh now, without waiting for the delay.</summary>
|
||
private int RespawnNow(PlacementState ps)
|
||
{
|
||
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
|
||
foreach (Slot s in ps.Slots)
|
||
s.DiedAt = float.NegativeInfinity;
|
||
ps.LastError = null;
|
||
return ps.Slots.Length;
|
||
}
|
||
|
||
private void CmdRespawn(Caller c, string[] args)
|
||
{
|
||
if (args.Length != 1)
|
||
{
|
||
c.Say("Usage: rnpc respawn <placement|all>");
|
||
return;
|
||
}
|
||
|
||
List<PlacementState> which;
|
||
if (args[0] == "all")
|
||
which = _placements.Values.ToList();
|
||
else
|
||
{
|
||
PlacementState ps;
|
||
if (!_placements.TryGetValue(args[0], out ps))
|
||
{
|
||
c.Say($"There is no placement '{args[0]}'.");
|
||
return;
|
||
}
|
||
|
||
which = new List<PlacementState> { ps };
|
||
}
|
||
|
||
int n = which.Sum(RespawnNow);
|
||
TickPlacements();
|
||
List<string> waiting = which.Where(ps => ps.Waiting != null).Select(ps => $"{ps.Id} waits: {ps.Waiting}").ToList();
|
||
c.Say($"Respawning {n} NPC(s) from {which.Count} placement(s).");
|
||
c.Lines.AddRange(waiting);
|
||
}
|
||
|
||
private void CmdClear(Caller c, string[] args)
|
||
{
|
||
if (args.Length != 1 || !(OwnerRule.IsMatch(args[0]) || args[0].StartsWith("placement:")))
|
||
{
|
||
c.Say("Usage: rnpc clear <owner>, e.g. run:<id>, plugin:<name> or placement:<name>");
|
||
return;
|
||
}
|
||
|
||
int n = Despawn(_npcs.Values.Where(x => x.Owner == args[0]));
|
||
c.Say(args[0].StartsWith("placement:")
|
||
? $"Removed {n} NPC(s) of {args[0]}; the placement stays and respawns them after its delay (rnpc remove deletes it)."
|
||
: $"Removed {n} NPC(s) of {args[0]}.");
|
||
}
|
||
|
||
// ---- path: recording a route point by point (D240) ----
|
||
|
||
private void CmdPath(Caller c, string[] args)
|
||
{
|
||
string sub = args.Length > 0 ? args[0].ToLowerInvariant() : null;
|
||
Recording rec;
|
||
_recordings.TryGetValue(c.Key, out rec);
|
||
|
||
switch (sub)
|
||
{
|
||
case "record":
|
||
if (args.Length != 2 || !NameRule.IsMatch(args[1]))
|
||
{
|
||
c.Say("Usage: rnpc path record <name>; a name is 1–40 of a-z, 0-9, _ and -.");
|
||
return;
|
||
}
|
||
|
||
_recordings[c.Key] = new Recording { Name = args[1] };
|
||
c.Say($"Recording '{args[1]}'{(_routeFile.Routes.ContainsKey(args[1]) ? " (saving replaces the route of that name)" : "")}. " +
|
||
"Walk, and at each point type rnpc path point.");
|
||
return;
|
||
|
||
case "point":
|
||
if (rec == null)
|
||
{
|
||
c.Say("Nothing is being recorded: rnpc path record <name> first.");
|
||
return;
|
||
}
|
||
|
||
Vector3 at;
|
||
if (c.Player != null && args.Length == 1)
|
||
at = c.Player.transform.position;
|
||
else
|
||
{
|
||
PlaceOptions o = null;
|
||
string error = args.Length == 2 ? ParseOptions(c, args, 1, out o) : "from the server console, give the point as at=x,y,z";
|
||
if (error == null && (o == null || !o.At.HasValue)) error = "give the point as at=x,y,z";
|
||
if (error != null)
|
||
{
|
||
c.Say(error);
|
||
return;
|
||
}
|
||
|
||
at = o.At.Value;
|
||
}
|
||
|
||
string off = OffMeshReason(at);
|
||
if (off != null)
|
||
{
|
||
c.Say($"Refused: {off}.");
|
||
return;
|
||
}
|
||
|
||
NavMeshHit hit;
|
||
SampleNavmesh(at, Scatter, out hit);
|
||
if (rec.Points.Count > 0 && !Walkable(rec.Points[rec.Points.Count - 1], hit.position))
|
||
{
|
||
c.Say($"Refused: an NPC cannot walk here from point {rec.Points.Count}; Rust's navmesh finds no path " +
|
||
"(a player can climb what an NPC cannot). Add a point between them, or choose another spot.");
|
||
return;
|
||
}
|
||
|
||
rec.Points.Add(hit.position);
|
||
string gap = rec.Points.Count > 1 ? $", {Meters(rec.Points[rec.Points.Count - 2], hit.position)} from the last" : "";
|
||
c.Say($"Point {rec.Points.Count} at ({hit.position.x:0}, {hit.position.y:0}, {hit.position.z:0}){gap}.");
|
||
return;
|
||
|
||
case "undo":
|
||
if (rec == null || rec.Points.Count == 0)
|
||
{
|
||
c.Say("There is no point to undo.");
|
||
return;
|
||
}
|
||
|
||
rec.Points.RemoveAt(rec.Points.Count - 1);
|
||
c.Say($"Dropped the last point; {rec.Points.Count} left.");
|
||
return;
|
||
|
||
case "save":
|
||
if (rec == null)
|
||
{
|
||
c.Say("Nothing is being recorded.");
|
||
return;
|
||
}
|
||
|
||
string how = args.Length > 1 ? args[1].ToLowerInvariant() : "loop";
|
||
if (how != "loop" && how != "back")
|
||
{
|
||
c.Say("Usage: rnpc path save [loop|back]: loop returns from the last point to the first; back walks it back and forth.");
|
||
return;
|
||
}
|
||
|
||
if (rec.Points.Count < 2)
|
||
{
|
||
c.Say($"A route needs at least two points; '{rec.Name}' has {rec.Points.Count}.");
|
||
return;
|
||
}
|
||
|
||
if (how == "loop" && !Walkable(rec.Points[rec.Points.Count - 1], rec.Points[0]))
|
||
{
|
||
c.Say($"An NPC cannot walk from the last point back to the first, so '{rec.Name}' cannot loop. " +
|
||
"Add points that close the loop, or save it as back (back and forth).");
|
||
return;
|
||
}
|
||
|
||
var route = new Route { Loop = how == "loop", Points = rec.Points.Select(Pos.Of).ToList() };
|
||
string refused = SetRoute(rec.Name, route);
|
||
if (refused != null)
|
||
{
|
||
c.Say("Not saved: " + refused);
|
||
return;
|
||
}
|
||
|
||
_recordings.Remove(c.Key);
|
||
int users = _placements.Values.Count(ps => ps.Data.Movement != null && ps.Data.Movement.Mode == "route:" + rec.Name);
|
||
c.Say($"Saved '{rec.Name}': {route.Points.Count} points, {(route.Loop ? "loops" : "back and forth")}. " +
|
||
$"Place a patrol on it: rnpc place <profile> move=route:{rec.Name}{(users > 0 ? $" ({users} placement(s) already use it)" : "")}");
|
||
return;
|
||
|
||
case "cancel":
|
||
c.Say(_recordings.Remove(c.Key) ? $"Discarded '{rec.Name}'." : "Nothing is being recorded.");
|
||
return;
|
||
|
||
case "list":
|
||
if (_routeFile.Routes.Count == 0)
|
||
c.Say("No routes yet: rnpc path record <name>.");
|
||
foreach (KeyValuePair<string, Route> kv in _routeFile.Routes.OrderBy(kv => kv.Key))
|
||
{
|
||
int n = _placements.Values.Count(ps => ps.Data.Movement != null && ps.Data.Movement.Mode == "route:" + kv.Key) +
|
||
_placements.Values.Count(ps => ps.Data.Movement == null && _profiles.ContainsKey(ps.Data.Profile) && _profiles[ps.Data.Profile].Movement.Mode == "route:" + kv.Key);
|
||
string where = c.Player != null ? $" · first point {Meters(kv.Value.Points[0].V, c.Player.transform.position)}" : "";
|
||
c.Say($"{kv.Key} · {kv.Value.Points.Count} points · {(kv.Value.Loop ? "loop" : "back and forth")} · {n} placement(s){where}");
|
||
}
|
||
|
||
if (rec != null)
|
||
c.Say($"Recording: '{rec.Name}', {rec.Points.Count} point(s), not saved.");
|
||
return;
|
||
|
||
case "delete":
|
||
if (args.Length != 2)
|
||
{
|
||
c.Say("Usage: rnpc path delete <name>");
|
||
return;
|
||
}
|
||
|
||
if (!RunicNpc_RemoveRoute(args[1]))
|
||
{
|
||
c.Say($"There is no route '{args[1]}'.");
|
||
return;
|
||
}
|
||
|
||
TickPlacements();
|
||
int waiting = _placements.Values.Count(ps => ps.Waiting != null && ps.Waiting.Contains($"route '{args[1]}'"));
|
||
c.Say($"Deleted '{args[1]}'.{(waiting > 0 ? $" {waiting} placement(s) on it now wait for a route of that name." : "")}");
|
||
return;
|
||
|
||
default:
|
||
c.Say("Usage: rnpc " + _verbs["path"].Usage);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ---- profile: standalone edits from a console (D221) ----
|
||
|
||
private void CmdProfile(Caller c, string[] args)
|
||
{
|
||
string sub = args.Length > 0 ? args[0].ToLowerInvariant() : null;
|
||
if (sub == "list")
|
||
{
|
||
CmdProfiles(c, args);
|
||
return;
|
||
}
|
||
|
||
if (sub == null || args.Length < 2)
|
||
{
|
||
c.Say("Usage: rnpc." + _verbs["profile"].Usage);
|
||
return;
|
||
}
|
||
|
||
string name = args[1];
|
||
Profile existing;
|
||
_profileFile.Profiles.TryGetValue(name, out existing);
|
||
if (sub == "show")
|
||
{
|
||
c.Say(existing == null ? $"There is no profile '{name}'." : JsonConvert.SerializeObject(existing, Formatting.Indented));
|
||
if (existing != null && _refused.ContainsKey(name))
|
||
c.Say($"It is refused: {_refused[name]}");
|
||
return;
|
||
}
|
||
|
||
if (_profileFile.Managed)
|
||
{
|
||
c.Say("This server's profiles are managed by its website (D221): edit them there.");
|
||
return;
|
||
}
|
||
|
||
switch (sub)
|
||
{
|
||
case "create":
|
||
if (!NameRule.IsMatch(name))
|
||
{
|
||
c.Say("A name is 1–40 of a-z, 0-9, _ and -.");
|
||
return;
|
||
}
|
||
|
||
if (existing != null)
|
||
{
|
||
c.Say($"'{name}' exists: rnpc.profile set {name} <field> <value> changes it.");
|
||
return;
|
||
}
|
||
|
||
Profile made = new Profile { Names = new List<string> { "Scientist" } };
|
||
if (args.Length > 2)
|
||
{
|
||
Profile from;
|
||
if (!args[2].StartsWith("from=") || !_profileFile.Profiles.TryGetValue(args[2].Substring(5), out from))
|
||
{
|
||
c.Say(args[2].StartsWith("from=") ? $"There is no profile '{args[2].Substring(5)}'." : "Usage: rnpc.profile create <name> [from=<profile>]");
|
||
return;
|
||
}
|
||
|
||
made = JObject.FromObject(from).ToObject<Profile>();
|
||
}
|
||
|
||
_profileFile.Profiles[name] = made;
|
||
break;
|
||
|
||
case "set":
|
||
if (existing == null)
|
||
{
|
||
c.Say($"There is no profile '{name}'.");
|
||
return;
|
||
}
|
||
|
||
if (args.Length < 4)
|
||
{
|
||
c.Say("Usage: rnpc.profile set <name> <field> <value>, e.g. health 200, ranges.sense 40, kits a,b, names Bob,Old Jim");
|
||
return;
|
||
}
|
||
|
||
string error = SetProfileField(name, existing, args[2], string.Join(" ", args.Skip(3).ToArray()));
|
||
if (error != null)
|
||
{
|
||
c.Say(error);
|
||
return;
|
||
}
|
||
|
||
break;
|
||
|
||
case "delete":
|
||
if (existing == null)
|
||
{
|
||
c.Say($"There is no profile '{name}'.");
|
||
return;
|
||
}
|
||
|
||
_profileFile.Profiles.Remove(name);
|
||
break;
|
||
|
||
default:
|
||
c.Say("Usage: rnpc." + _verbs["profile"].Usage);
|
||
return;
|
||
}
|
||
|
||
SaveProfiles();
|
||
RevalidateProfiles();
|
||
TickPlacements();
|
||
if (sub == "delete")
|
||
{
|
||
int waiting = _placements.Values.Count(ps => ps.Data.Profile == name);
|
||
c.Say($"Deleted '{name}'.{(waiting > 0 ? $" Its {waiting} placement(s) wait for a profile of that name (D237)." : "")}");
|
||
}
|
||
else
|
||
c.Say(_refused.ContainsKey(name) ? $"Saved '{name}', but it cannot spawn yet: {_refused[name]}" : $"Saved '{name}'; it is in use.");
|
||
}
|
||
|
||
/// <summary>One field of a profile, by its JSON path (<c>health</c>, <c>ranges.sense</c>, <c>kits</c>).</summary>
|
||
private string SetProfileField(string name, Profile profile, string field, string value)
|
||
{
|
||
JObject o = JObject.FromObject(profile);
|
||
|
||
// Stage 5: a profile's own exceptions are keyed by faction (D254), so one is set, or removed
|
||
// with none, by name: relations.scientists hostile, relations.profile:warden none.
|
||
if (field.StartsWith("relations."))
|
||
{
|
||
string key = field.Substring(10);
|
||
JObject relations = (JObject)o["relations"];
|
||
if (value == "none" || value == "-")
|
||
relations.Remove(key);
|
||
else
|
||
relations[key] = value;
|
||
_profileFile.Profiles[name] = o.ToObject<Profile>();
|
||
return null;
|
||
}
|
||
|
||
if (field == "faction" && (value == "none" || value == "-"))
|
||
{
|
||
o["faction"] = null;
|
||
_profileFile.Profiles[name] = o.ToObject<Profile>();
|
||
return null;
|
||
}
|
||
|
||
JToken target = o.SelectToken(field);
|
||
if (target == null || target is JObject)
|
||
return target == null
|
||
? $"A profile has no field '{field}'. Fields: {string.Join(", ", Flatten(o).ToArray())}"
|
||
: $"'{field}' has fields of its own: set them one at a time, e.g. {field}.{((JObject)target).Properties().First().Name}";
|
||
|
||
JToken replacement;
|
||
float f;
|
||
if (target is JArray)
|
||
{
|
||
string[] parts = value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
|
||
if (field == "healthThresholds")
|
||
{
|
||
var numbers = new JArray();
|
||
foreach (string s in parts)
|
||
{
|
||
if (!TryFloat(s, out f)) return $"'{s}' is not a number";
|
||
numbers.Add(f);
|
||
}
|
||
|
||
replacement = numbers;
|
||
}
|
||
else
|
||
replacement = new JArray(parts.Cast<object>().ToArray());
|
||
}
|
||
else if (target.Type == JTokenType.Float || target.Type == JTokenType.Integer)
|
||
{
|
||
if (!TryFloat(value, out f)) return $"'{field}' is a number";
|
||
replacement = new JValue(f);
|
||
}
|
||
else if (target.Type == JTokenType.Boolean)
|
||
{
|
||
string v = value.ToLowerInvariant();
|
||
if (v != "true" && v != "false" && v != "on" && v != "off") return $"'{field}' is true or false";
|
||
replacement = new JValue(v == "true" || v == "on");
|
||
}
|
||
else
|
||
replacement = new JValue(value);
|
||
|
||
target.Replace(replacement);
|
||
_profileFile.Profiles[name] = o.ToObject<Profile>();
|
||
return null;
|
||
}
|
||
|
||
private static IEnumerable<string> Flatten(JObject o, string prefix = "")
|
||
{
|
||
foreach (JProperty prop in o.Properties())
|
||
{
|
||
if (prop.Value is JObject)
|
||
foreach (string inner in Flatten((JObject)prop.Value, prefix + prop.Name + "."))
|
||
yield return inner;
|
||
else
|
||
yield return prefix + prop.Name;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Console
|
||
|
||
[ConsoleCommand("rnpc.status")]
|
||
private void CmdStatus(ConsoleSystem.Arg arg)
|
||
{
|
||
if (arg.Connection != null && !arg.IsAdmin)
|
||
return;
|
||
|
||
var fired = new List<string>();
|
||
var silent = new List<string>();
|
||
foreach (string hook in ExpectedHooks)
|
||
{
|
||
long count;
|
||
_hookCounts.TryGetValue(hook, out count);
|
||
if (count > 0L)
|
||
fired.Add($"{hook}={count}");
|
||
else
|
||
silent.Add(hook);
|
||
}
|
||
|
||
string nl = Environment.NewLine;
|
||
var sb = new StringBuilder();
|
||
sb.Append($"RunicNPC {Version} api={ApiVersion} hooks={ExpectedHooks.Length} fired={fired.Count} silent={silent.Count}").Append(nl);
|
||
sb.Append($"fired: {(fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)")}").Append(nl);
|
||
sb.Append($"silent: {(silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)")}").Append(nl);
|
||
sb.Append($"framework={Framework()} navmesh={(NavmeshReady() ? "ready" : "building")} " +
|
||
$"swap fields: npc={_npcFields?.Length ?? 0}/{SwapNpcFields.Length} brain={_brainFields?.Length ?? 0}/{SwapBrainFields.Length} " +
|
||
$"missing={(_fieldsMissing.Count > 0 ? string.Join(",", _fieldsMissing) : "-")} added={(_fieldsAdded.Count > 0 ? string.Join(",", _fieldsAdded) : "-")}").Append(nl);
|
||
|
||
IEnumerable<RunicNpcBrain> brains = _npcs.Values.Select(n => n.RnBrain).Where(b => b != null).ToList();
|
||
sb.Append($"npcs={_npcs.Count} awake={brains.Count(b => b.Rest == RunicNpcBrain.RestState.Awake)} " +
|
||
$"goingHome={brains.Count(b => b.Rest == RunicNpcBrain.RestState.GoingHome)} asleep={brains.Count(b => b.Rest == RunicNpcBrain.RestState.Asleep)} " +
|
||
$"owners: {string.Join(" ", _npcs.Values.GroupBy(n => n.Owner).Select(g => $"{g.Key}={g.Count()}").ToArray())}").Append(nl);
|
||
sb.Append($"profiles={_profiles.Count} managed={_profileFile.Managed} refused: " +
|
||
$"{(_refused.Count > 0 ? string.Join("; ", _refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()) : "-")}").Append(nl);
|
||
sb.Append($"placements={_placements.Count} queue={_queue.Count} waiting: " +
|
||
$"{string.Join("; ", _placements.Values.Where(ps => ps.Waiting != null).Select(ps => ps.Id + ": " + ps.Waiting).DefaultIfEmpty("-").ToArray())}").Append(nl);
|
||
sb.Append($"routes={_routeFile.Routes.Count}").Append(nl);
|
||
double fightMs = RunicNpcBrain.FightTicks > 0 ? RunicNpcBrain.FightMs / RunicNpcBrain.FightTicks : 0.0;
|
||
double senseMs = SenseRuns > 0 ? SenseMs / SenseRuns : 0.0;
|
||
sb.Append($"stage 5: factions={_profileFile.Factions.Count} pairs, tracked scientists={_scientists.Count} animals={_animals.Count}, " +
|
||
$"engaged={_npcs.Values.Count(n => n.Engaged)} defenders={_defenders.Count} alarms={Alarms} defences={Defences}, " +
|
||
$"fight {fightMs:0.000} ms/think over {RunicNpcBrain.FightTicks}, sense {senseMs:0.000} ms over {SenseRuns}").Append(nl);
|
||
sb.Append($"sentries (turrets: always): {_sentryPatch}, answered {SentryPatch.Answered}; zonemanager={(ZoneManager != null ? "loaded" : "absent")}").Append(nl);
|
||
CapsConfig caps = _config.Caps;
|
||
sb.Append($"caps: total={caps.Total} perOwner={caps.PerOwner} perProfile={caps.PerProfile} spawnsPerSecond={caps.SpawnsPerSecond} (0 = none)").Append(nl);
|
||
sb.Append(CostWarning(PlannedTotal()));
|
||
arg.ReplyWith(sb.ToString());
|
||
}
|
||
|
||
/// <summary>Re-reads profiles and routes after a hand edit of their files (standalone, D221).</summary>
|
||
[ConsoleCommand("rnpc.reload")]
|
||
private void CmdReload(ConsoleSystem.Arg arg)
|
||
{
|
||
if (arg.Connection != null && !arg.IsAdmin)
|
||
return;
|
||
ReadProfileFile();
|
||
_routeFile = ReadData<RouteFile>(DataRoutes);
|
||
if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary<string, Route>();
|
||
RevalidateProfiles();
|
||
TickPlacements();
|
||
arg.ReplyWith($"RunicNPC: {_profiles.Count} profile(s) in use, {_refused.Count} refused, {_routeFile.Routes.Count} route(s).");
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
}
|