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