All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 6s
A developer plugin, never shipped, that measured the six questions stage 2 depends on (docs/runicnpc/PLAN.md §9, stage 1) on both rigs: the subclass swap, saving across a hard kill, what Rust's brain honours, navmesh coverage, the cost of 1/10/100 idle and fighting, and the death screen and bridge frames. It spawns stand-in players (player.prefab, a made-up user id, IsNpc false) as targets, at the org lead's suggestion. It needs a hidden Kits kit, rnhrevolver. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
1102 lines
54 KiB
C#
1102 lines
54 KiB
C#
// Requires: Kits
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using Newtonsoft.Json;
|
|
using Oxide.Core;
|
|
using Oxide.Core.Plugins;
|
|
using UnityEngine;
|
|
using UnityEngine.AI;
|
|
|
|
namespace Oxide.Plugins
|
|
{
|
|
/// <summary>
|
|
/// RunicNPC's stage 1 harness (docs/runicnpc/PLAN.md §9). NEVER SHIPPED: it lives in tools/,
|
|
/// is loaded on a rig by hand, and answers the six questions stage 2 is built on:
|
|
///
|
|
/// <list type="number">
|
|
/// <item>the subclass swap spawns, walks, fights and dies cleanly (<c>rnh.swap</c>);</item>
|
|
/// <item><c>enableSaving = false</c> survives a hard kill and restart (<c>rnh.save plant|check</c>);</item>
|
|
/// <item>which ranges and scales hold on Rust's brain as it is (<c>rnh.tune</c>);</item>
|
|
/// <item>navmesh coverage at every monument and on a roof (<c>rnh.nav</c>);</item>
|
|
/// <item>what 1, 10 and 100 cost, idle and fighting (<c>rnh.cost</c>);</item>
|
|
/// <item>the name on the death screen, and what the bridge publishes (<c>rnh.dummy</c>).</item>
|
|
/// </list>
|
|
///
|
|
/// Every result is a line starting <c>[rnh]</c> in the console and a record in the data file
|
|
/// <c>RunicNpcHarness.json</c>, read back over the panel. Nothing here is RunicNPC's design:
|
|
/// it is the measurement that design waits for.
|
|
///
|
|
/// <para>
|
|
/// It needs a hidden Kits kit named <c>rnhrevolver</c> (a revolver, pistol ammo and burlap):
|
|
/// add it to the rig's <c>Kits/kits_data.json</c> and reload Kits before loading this, and
|
|
/// restore the file afterwards. <c>ready</c> in the console says whether it was found.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Its NPC is swapped the way stage 2's will be: the prefab's <c>ScientistNPC</c> and
|
|
/// <c>ScientistBrain</c> components are replaced, before the entity is spawned, by our own
|
|
/// subclasses carrying the prefab's serialised fields. Written from Rust's own classes; no
|
|
/// other plugin's code.
|
|
/// </para>
|
|
/// </summary>
|
|
[Info("RunicNpcHarness", "Runic Gateway", "0.1.0")]
|
|
[Description("RunicNPC stage 1 measurements. A developer tool: never ship it.")]
|
|
internal class RunicNpcHarness : RustPlugin
|
|
{
|
|
[PluginReference] private Plugin Kits;
|
|
|
|
private const string ScientistPrefab = "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab";
|
|
private const string PlayerPrefab = "assets/prefabs/player/player.prefab";
|
|
private const string Kit = "rnhrevolver";
|
|
|
|
/// <summary>The fake player's user id: outside the Steam range, over Rust's bot range (10,000,000).</summary>
|
|
private const ulong DummyUserId = 11400000001UL;
|
|
|
|
private static RunicNpcHarness _self;
|
|
private readonly Dictionary<string, object> _results = new Dictionary<string, object>();
|
|
private readonly HashSet<BaseEntity> _spawned = new HashSet<BaseEntity>();
|
|
private GameObject _meterHost;
|
|
private FrameMeter _meter;
|
|
|
|
// ---- counters the hooks feed ----
|
|
|
|
private sealed class HitTally
|
|
{
|
|
public int Hits;
|
|
public float Damage;
|
|
public readonly Dictionary<string, int> Weapons = new Dictionary<string, int>();
|
|
}
|
|
|
|
private readonly HitTally _hitsByOurs = new HitTally();
|
|
private readonly HitTally _hitsOnOurs = new HitTally();
|
|
private readonly HitTally _hitsOnStandIns = new HitTally();
|
|
|
|
/// <summary>While set, stand-in players take no damage: the cost run's targets never die.</summary>
|
|
private bool _standInsInvulnerable;
|
|
|
|
private static bool IsStandIn(BaseEntity e) =>
|
|
e is BasePlayer p && !p.IsNpc && (ulong)p.userID >= DummyUserId && (ulong)p.userID < DummyUserId + 1000UL;
|
|
private readonly List<string> _deaths = new List<string>();
|
|
private readonly List<string> _corpses = new List<string>();
|
|
|
|
// ---- lifecycle ----
|
|
|
|
private void Init() => _self = this;
|
|
|
|
private void OnServerInitialized()
|
|
{
|
|
_meterHost = new GameObject("RunicNpcHarness.FrameMeter");
|
|
_meter = _meterHost.AddComponent<FrameMeter>();
|
|
Log("ready", $"oxide-or-carbon={Framework()} fps.target={Application.targetFrameRate} " +
|
|
$"ai.budget.ms={AIThinkManager.framebudgetms} map={World.Size} seed={World.Seed} navmeshBuilt={NavmeshBuilt()} " +
|
|
$"kit.{Kit}={KitExists()}");
|
|
}
|
|
|
|
private void Unload()
|
|
{
|
|
foreach (BaseEntity e in _spawned.ToArray())
|
|
if (e != null && !e.IsDestroyed)
|
|
e.Kill();
|
|
_spawned.Clear();
|
|
if (_meterHost != null)
|
|
UnityEngine.Object.Destroy(_meterHost);
|
|
_self = null;
|
|
}
|
|
|
|
private static string Framework() =>
|
|
AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide";
|
|
|
|
// ---- output ----
|
|
|
|
private void Log(string test, string line) => Puts($"[rnh] {test}: {line}");
|
|
|
|
private void Record(string test, object value)
|
|
{
|
|
_results[test] = value;
|
|
Interface.Oxide.DataFileSystem.WriteObject(Name, _results);
|
|
}
|
|
|
|
// ---- the swap ----
|
|
|
|
/// <summary>
|
|
/// Copies the fields Unity itself would copy between two instances of a component: every
|
|
/// instance field of <paramref name="from"/>'s type chain down to <see cref="MonoBehaviour"/>
|
|
/// that is public and not [NonSerialized], or private with [SerializeField]. That is the
|
|
/// prefab's authored data. Runtime state (delegates, caches, anything [NonSerialized]) is
|
|
/// left at the new component's own defaults, so nothing bound to the destroyed instance is
|
|
/// carried across.
|
|
/// </summary>
|
|
private static int CopyPrefabFields(Component from, Component to)
|
|
{
|
|
int copied = 0;
|
|
for (Type t = from.GetType(); 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.IsNotSerialized) || f.IsDefined(typeof(SerializeField), false);
|
|
if (!serialised || typeof(Delegate).IsAssignableFrom(f.FieldType))
|
|
continue;
|
|
f.SetValue(to, f.GetValue(from));
|
|
copied++;
|
|
}
|
|
}
|
|
return copied;
|
|
}
|
|
|
|
private sealed class SpawnReport
|
|
{
|
|
public RnhNpc Npc;
|
|
public int NpcFields;
|
|
public int BrainFields;
|
|
public double Ms;
|
|
public string Error;
|
|
public string Kit;
|
|
}
|
|
|
|
/// <summary>Our NPC: the prefab's scientist, its components swapped before it spawns.</summary>
|
|
private SpawnReport SpawnOurs(Vector3 at, string name, Action<RnhBrain> tune = null, bool save = false)
|
|
{
|
|
var report = new SpawnReport();
|
|
Stopwatch sw = Stopwatch.StartNew();
|
|
|
|
BaseEntity made = GameManager.server.CreateEntity(ScientistPrefab, at, Quaternion.identity, false);
|
|
ScientistNPC stock = made as ScientistNPC;
|
|
if (stock == null)
|
|
{
|
|
report.Error = "prefab did not make a ScientistNPC";
|
|
if (made != null) UnityEngine.Object.Destroy(made.gameObject);
|
|
return report;
|
|
}
|
|
|
|
ScientistBrain stockBrain = stock.GetComponent<ScientistBrain>();
|
|
GameObject go = stock.gameObject;
|
|
|
|
RnhNpc npc = go.AddComponent<RnhNpc>();
|
|
RnhBrain brain = go.AddComponent<RnhBrain>();
|
|
report.NpcFields = CopyPrefabFields(stock, npc);
|
|
report.BrainFields = stockBrain != null ? CopyPrefabFields(stockBrain, brain) : -1;
|
|
|
|
UnityEngine.Object.DestroyImmediate(stock, true);
|
|
if (stockBrain != null)
|
|
UnityEngine.Object.DestroyImmediate(stockBrain, true);
|
|
|
|
npc.RnhName = name;
|
|
npc.enableSaving = save;
|
|
tune?.Invoke(brain);
|
|
|
|
// 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.
|
|
go.AwakeFromInstantiate();
|
|
npc.Spawn();
|
|
report.Ms = sw.Elapsed.TotalMilliseconds;
|
|
report.Npc = npc;
|
|
_spawned.Add(npc);
|
|
|
|
report.Kit = Equip(npc);
|
|
return report;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Senses NPC players too, does not wait for a target to be "hostile", and checks line of
|
|
/// sight as it senses: Rust refreshes LOS every think only for targets that are NOT NPCs
|
|
/// (AIBrainSenses.UpdateKnownPlayersLOS), and HumanNPC.TickAttack never fires without it.
|
|
/// </summary>
|
|
private static void FightsScientists(RnhBrain b)
|
|
{
|
|
b.SenseTypes |= EntityType.BasePlayerNPC;
|
|
b.HostileTargetsOnly = false;
|
|
b.CheckLOS = true;
|
|
RnhNpc npc = b.GetComponent<RnhNpc>();
|
|
if (npc != null) npc.RnhFightsNpcs = true;
|
|
}
|
|
|
|
private ScientistNPC SpawnStock(Vector3 at)
|
|
{
|
|
ScientistNPC s = GameManager.server.CreateEntity(ScientistPrefab, at) as ScientistNPC;
|
|
if (s == null) return null;
|
|
s.enableSaving = false;
|
|
s.Spawn();
|
|
_spawned.Add(s);
|
|
return s;
|
|
}
|
|
|
|
private string Equip(BasePlayer p)
|
|
{
|
|
p.inventory.Strip();
|
|
object r = Kits?.Call("GiveKit", p, Kit);
|
|
(p as NPCPlayer)?.EquipWeapon();
|
|
return r == null ? "null" : r.ToString();
|
|
}
|
|
|
|
private bool KitExists() => Kits != null && Kits.Call("IsKit", Kit) is bool b && b;
|
|
|
|
/// <summary>The scientist subclass. Only what stage 1 has to prove it can override.</summary>
|
|
public class RnhNpc : ScientistNPC, IAISenses, IAIAttack
|
|
{
|
|
public int AttackTicks, AttackTicksLos;
|
|
public string LastAttackTarget = "-";
|
|
|
|
/// <summary>Counts what Rust's AttackTick event hands us: the last step before a shot.</summary>
|
|
public new void AttackTick(float delta, BaseEntity target, bool targetIsLOS)
|
|
{
|
|
AttackTicks++;
|
|
if (targetIsLOS) AttackTicksLos++;
|
|
LastAttackTarget = target != null ? target.ShortPrefabName : "null";
|
|
base.AttackTick(delta, target, targetIsLOS);
|
|
}
|
|
|
|
public string RnhName = "Scientist";
|
|
|
|
/// <summary>
|
|
/// Rust's HumanNPC.IsTarget answers true only for non-NPC players, pets and scarecrows,
|
|
/// so a scientist never attacks another NPC. Re-implementing IAISenses is how ours can:
|
|
/// it targets stock scientists (not other RnhNpcs) when this is set.
|
|
/// </summary>
|
|
public bool RnhFightsNpcs;
|
|
|
|
public new bool IsTarget(BaseEntity entity) =>
|
|
base.IsTarget(entity) || (RnhFightsNpcs && entity is ScientistNPC && !(entity is RnhNpc));
|
|
|
|
public new bool IsThreat(BaseEntity entity) => IsTarget(entity);
|
|
|
|
public new bool IsFriendly(BaseEntity entity) => !IsTarget(entity) && base.IsFriendly(entity);
|
|
public float RnhAimCone = -1f;
|
|
|
|
public override string displayName => RnhName;
|
|
|
|
public override float GetAimConeScale() => RnhAimCone >= 0f ? RnhAimCone : base.GetAimConeScale();
|
|
|
|
/// <summary>
|
|
/// HumanNPC.AttackerInfo writes the prefab's short name as the killer on the victim's
|
|
/// death screen, after BasePlayer's displayName. Ours puts the name back.
|
|
/// </summary>
|
|
public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info)
|
|
{
|
|
base.AttackerInfo(info);
|
|
info.attackerName = displayName;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The brain subclass. It adds no state of its own: stage 1 asks what Rust's own design
|
|
/// does with our values. It times its own thinking, which is what the cost warning needs
|
|
/// beside frame time, because Rust budgets human AI per frame (aithinkmanager.framebudgetms).
|
|
/// </summary>
|
|
public class RnhBrain : ScientistBrain
|
|
{
|
|
public static double ThinkMs;
|
|
public static long Thinks;
|
|
public float LastThinkAt = -1f;
|
|
public float IntervalSum;
|
|
public int IntervalCount;
|
|
|
|
public override void Think(float delta)
|
|
{
|
|
if (LastThinkAt >= 0f)
|
|
{
|
|
IntervalSum += Time.time - LastThinkAt;
|
|
IntervalCount++;
|
|
}
|
|
LastThinkAt = Time.time;
|
|
long t0 = Stopwatch.GetTimestamp();
|
|
base.Think(delta);
|
|
|
|
// Harness-only stand-in for the combat state stage 5 will write: Rust's design never
|
|
// runs its AttackTick event for an NPC target, so when this NPC is set to fight NPCs
|
|
// and knows one, face it and hand Rust's own attack routine the target and its LOS.
|
|
RnhNpc npc = GetBaseEntity() as RnhNpc;
|
|
if (npc != null && npc.RnhFightsNpcs && Senses?.Memory != null)
|
|
{
|
|
BaseEntity target = Senses.GetNearestTarget(1f);
|
|
if (target != null)
|
|
{
|
|
Navigator?.SetFacingDirectionEntity(target);
|
|
npc.AttackTick(delta, target, Senses.Memory.IsLOS(target));
|
|
}
|
|
}
|
|
ThinkMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency;
|
|
Thinks++;
|
|
}
|
|
}
|
|
|
|
// ---- frame meter ----
|
|
|
|
public class FrameMeter : MonoBehaviour
|
|
{
|
|
private readonly List<float> _frames = new List<float>(20000);
|
|
public bool Recording;
|
|
|
|
public void Begin()
|
|
{
|
|
_frames.Clear();
|
|
Recording = true;
|
|
}
|
|
|
|
public List<float> End()
|
|
{
|
|
Recording = false;
|
|
return new List<float>(_frames);
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (Recording)
|
|
_frames.Add(Time.unscaledDeltaTime * 1000f);
|
|
}
|
|
}
|
|
|
|
private static Dictionary<string, object> Summarise(List<float> ms)
|
|
{
|
|
if (ms.Count == 0)
|
|
return new Dictionary<string, object> { ["frames"] = 0 };
|
|
ms.Sort();
|
|
Func<double, double> pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)];
|
|
double mean = ms.Average();
|
|
return new Dictionary<string, object>
|
|
{
|
|
["frames"] = ms.Count,
|
|
["meanMs"] = Math.Round(mean, 2),
|
|
["fps"] = Math.Round(1000.0 / mean, 1),
|
|
["p50Ms"] = Math.Round(pct(0.50), 2),
|
|
["p95Ms"] = Math.Round(pct(0.95), 2),
|
|
["p99Ms"] = Math.Round(pct(0.99), 2),
|
|
["maxMs"] = Math.Round(ms[ms.Count - 1], 2),
|
|
};
|
|
}
|
|
|
|
// ---- places ----
|
|
|
|
/// <summary>
|
|
/// A flat, dry spot on the navmesh at least <paramref name="clear"/> m from every
|
|
/// monument, found by sampling the map from a fixed seed so a rerun lands in the same field.
|
|
/// </summary>
|
|
private bool FindField(float clear, int seed, out Vector3 at)
|
|
{
|
|
var rng = new System.Random(seed);
|
|
float half = World.Size / 2f - 300f;
|
|
List<MonumentInfo> monuments = TerrainMeta.Path.Monuments;
|
|
|
|
int wet = 0, steep = 0, nearMonument = 0, offMesh = 0, offAnyMesh = 0;
|
|
for (int i = 0; i < 4000; i++)
|
|
{
|
|
var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half);
|
|
p.y = TerrainMeta.HeightMap.GetHeight(p);
|
|
if (p.y < TerrainMeta.WaterMap.GetHeight(p) + 1f) { wet++; continue; }
|
|
if (TerrainMeta.HeightMap.GetSlope(p) > 15f) { steep++; continue; }
|
|
if (monuments.Any(m => Vector3.Distance(m.transform.position, p) < clear)) { nearMonument++; continue; }
|
|
NavMeshHit hit;
|
|
if (!OnNavmesh(p, 2f, out hit))
|
|
{
|
|
offMesh++;
|
|
if (!NavMesh.SamplePosition(p, out hit, 2f, NavMesh.AllAreas)) offAnyMesh++;
|
|
continue;
|
|
}
|
|
at = hit.position;
|
|
return true;
|
|
}
|
|
_self?.Log("field", $"none: wet={wet} steep={steep} nearMonument={nearMonument} offRustNavmesh={offMesh} offUnityNavmesh={offAnyMesh} rustNavmeshBuilt={NavmeshBuilt()}");
|
|
at = Vector3.zero;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rust's own navmesh, the one a scientist walks: its navigator is a Gen2
|
|
/// <c>RustNavMeshAgent</c>, not Unity's NavMeshAgent, and Unity's NavMesh queries see none of
|
|
/// the open world. This is the helper Rust's navigator samples with, and it answers false
|
|
/// until the default navmesh is built (minutes after a map's first boot).
|
|
/// </summary>
|
|
private static bool OnNavmesh(Vector3 p, float radius, out NavMeshHit hit) =>
|
|
Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, radius, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas);
|
|
|
|
private static bool NavmeshBuilt() =>
|
|
Rust.Ai.Gen2.Nav.RustNavigation.Instance != null && Rust.Ai.Gen2.Nav.RustNavigation.Instance.IsDefaultNavmeshBuilt();
|
|
|
|
private static Vector3 Ring(Vector3 centre, int i, int n, float r) =>
|
|
centre + new Vector3(Mathf.Cos(i * 2f * Mathf.PI / n) * r, 0f, Mathf.Sin(i * 2f * Mathf.PI / n) * r);
|
|
|
|
private static readonly FieldInfo StatesField = typeof(BaseAIBrain).GetField("states", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
private static readonly FieldInfo SleepingField = typeof(BaseAIBrain).GetField("sleeping", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
private static readonly FieldInfo DisabledField = typeof(BaseAIBrain).GetField("disabled", BindingFlags.Instance | BindingFlags.NonPublic);
|
|
|
|
/// <summary>A brain's insides: what it takes for a scientist to have a state at all.</summary>
|
|
private static string Diag(HumanNPC n)
|
|
{
|
|
if (n == null || n.IsDestroyed) return "gone";
|
|
ScientistBrain b = n.Brain;
|
|
if (b == null) return $"{n.GetType().Name} Brain=null comps={string.Join(",", n.GetComponents<BaseAIBrain>().Select(c => c.GetType().Name).ToArray())}";
|
|
var states = StatesField?.GetValue(b) as System.Collections.IDictionary;
|
|
return $"{n.GetType().Name}/{b.GetType().Name} enabled={b.enabled} state={b.CurrentState?.StateType} states={(states == null ? "null" : states.Count.ToString())} " +
|
|
$"useDesign={b.UseAIDesign} design={(b.AIDesign != null)} designs={b.Designs?.Count} defaultSO={(b.DefaultDesignSO != null)} think={b.ThinkMode} " +
|
|
$"sleeping={SleepingField?.GetValue(b)} disabled={DisabledField?.GetValue(b)} nav={(b.Navigator != null)} agent={(n.NavAgent != null)} " +
|
|
$"onMesh={(n.NavAgent != null && n.NavAgent.isOnNavMesh)} pathFinder={(b.PathFinder != null)} senses={b.Senses?.Players?.Count} " +
|
|
$"targets={b.Senses?.Memory?.Targets?.Count} threats={b.Senses?.Memory?.Threats?.Count} " +
|
|
$"slot={(b.Events != null ? b.Events.Memory.Entity.Get(b.Events.CurrentInputMemorySlot)?.ShortPrefabName : "-")} " +
|
|
(n is RnhNpc r ? $"attackTicks={r.AttackTicks} withLos={r.AttackTicksLos} lastTarget={r.LastAttackTarget} " : "") +
|
|
$"pos={V(n.transform.position)}";
|
|
}
|
|
|
|
private static Vector3 Ground(Vector3 p)
|
|
{
|
|
p.y = TerrainMeta.HeightMap.GetHeight(p);
|
|
return p;
|
|
}
|
|
|
|
private static string V(Vector3 p) => $"{p.x:0},{p.y:0.#},{p.z:0}";
|
|
|
|
// ---- hooks (all void: none of them may change what the game does) ----
|
|
|
|
private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info)
|
|
{
|
|
if (info == null) return;
|
|
string weapon = info.WeaponPrefab != null ? info.WeaponPrefab.ShortPrefabName : info.Weapon != null ? info.Weapon.ShortPrefabName : "none";
|
|
if (IsStandIn(victim) && info.Initiator is RnhNpc)
|
|
{
|
|
_hitsOnStandIns.Hits++;
|
|
if (_standInsInvulnerable) info.damageTypes.ScaleAll(0f);
|
|
}
|
|
HitTally tally = info.Initiator is RnhNpc ? _hitsByOurs : victim is RnhNpc ? _hitsOnOurs : null;
|
|
if (tally == null) return;
|
|
tally.Hits++;
|
|
tally.Damage += info.damageTypes.Total();
|
|
int n;
|
|
tally.Weapons.TryGetValue(weapon, out n);
|
|
tally.Weapons[weapon] = n + 1;
|
|
}
|
|
|
|
private void OnEntityDeath(BaseCombatEntity victim, HitInfo info)
|
|
{
|
|
if (!(victim is RnhNpc) && !IsStandIn(victim)) return;
|
|
BasePlayer p = (BasePlayer)victim;
|
|
_deaths.Add($"{p.GetType().Name} '{p.displayName}' by {(info?.Initiator != null ? info.Initiator.GetType().Name + " '" + (info.Initiator as BasePlayer)?.displayName + "'" : "none")}");
|
|
}
|
|
|
|
private void OnEntitySpawned(BaseEntity entity)
|
|
{
|
|
PlayerCorpse corpse = entity as PlayerCorpse;
|
|
if (corpse == null) return;
|
|
if (corpse.playerSteamID != DummyUserId && (corpse.playerName == null || !corpse.playerName.StartsWith("Rnh"))) return;
|
|
_corpses.Add($"{corpse.GetType().Name} playerName='{corpse.playerName}' steamId={corpse.playerSteamID}");
|
|
}
|
|
|
|
// ---- 1. the swap ----
|
|
|
|
[ConsoleCommand("rnh.swap")]
|
|
private void CmdSwap(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
Vector3 field;
|
|
if (!FindField(250f, 101, out field)) { Log("swap", "FAIL no field found"); return; }
|
|
|
|
_hitsByOurs.Hits = _hitsOnOurs.Hits = 0;
|
|
_hitsByOurs.Weapons.Clear(); _hitsOnOurs.Weapons.Clear();
|
|
_deaths.Clear(); _corpses.Clear();
|
|
|
|
SpawnReport r = SpawnOurs(field, "RnhWarden", FightsScientists);
|
|
if (r.Npc == null) { Log("swap", "FAIL " + r.Error); return; }
|
|
RnhNpc npc = r.Npc;
|
|
Vector3 start = npc.transform.position;
|
|
|
|
var rec = new Dictionary<string, object>
|
|
{
|
|
["at"] = V(start),
|
|
["spawnMs"] = Math.Round(r.Ms, 2),
|
|
["npcFieldsCopied"] = r.NpcFields,
|
|
["brainFieldsCopied"] = r.BrainFields,
|
|
["type"] = npc.GetType().Name,
|
|
["brainType"] = npc.Brain != null ? npc.Brain.GetType().Name : "null",
|
|
["isNpc"] = npc.IsNpc,
|
|
["displayName"] = npc.displayName,
|
|
["shortPrefab"] = npc.ShortPrefabName,
|
|
["enableSaving"] = npc.enableSaving,
|
|
["navigator"] = npc.Brain?.Navigator != null,
|
|
["kitResult"] = r.Kit,
|
|
["belt0"] = npc.inventory.containerBelt.GetSlot(0)?.info.shortname ?? "none",
|
|
["held"] = npc.GetHeldEntity() != null ? npc.GetHeldEntity().ShortPrefabName : "none",
|
|
};
|
|
Log("swap", string.Join(" ", rec.Select(kv => kv.Key + "=" + kv.Value).ToArray()));
|
|
|
|
var samples = new List<string>();
|
|
float maxMoved = 0f;
|
|
int t = 0;
|
|
ScientistNPC foe = null;
|
|
|
|
timer.Repeat(5f, 18, () =>
|
|
{
|
|
t += 5;
|
|
if (t == 30 && npc != null && !npc.IsDestroyed && !npc.IsDead())
|
|
foe = SpawnStock(Ground(start + new Vector3(8f, 0f, 0f)));
|
|
if (npc == null || npc.IsDestroyed) { samples.Add($"+{t}s gone"); return; }
|
|
float moved = Vector3.Distance(start, npc.transform.position);
|
|
maxMoved = Mathf.Max(maxMoved, moved);
|
|
string s = $"+{t}s state={npc.Brain?.CurrentState?.StateType} moved={moved:0.0} hp={npc.health:0} " +
|
|
$"thinking={((npc.Brain as RnhBrain)?.LastThinkAt > Time.time - 2f)} foe={(foe != null && !foe.IsDestroyed ? foe.health.ToString("0") : "-")}";
|
|
samples.Add(s);
|
|
Log("swap", s);
|
|
if (t == 5 || t == 35 || t == 60)
|
|
{
|
|
Log("swap", " ours " + Diag(npc));
|
|
if (foe != null) Log("swap", " stock " + Diag(foe));
|
|
}
|
|
});
|
|
|
|
timer.Once(92f, () =>
|
|
{
|
|
bool alive = npc != null && !npc.IsDestroyed && !npc.IsDead();
|
|
if (alive)
|
|
npc.Hurt(1000f, Rust.DamageType.Bullet, foe != null && !foe.IsDestroyed ? foe : null, false);
|
|
timer.Once(3f, () =>
|
|
{
|
|
rec["samples"] = samples;
|
|
rec["walked"] = maxMoved > 3f;
|
|
rec["maxMovedM"] = Math.Round(maxMoved, 1);
|
|
rec["hitsByOurs"] = _hitsByOurs.Hits;
|
|
rec["hitsByOursWeapons"] = new Dictionary<string, int>(_hitsByOurs.Weapons);
|
|
rec["hitsOnOurs"] = _hitsOnOurs.Hits;
|
|
rec["killedByHarness"] = alive;
|
|
rec["deaths"] = new List<string>(_deaths);
|
|
rec["corpses"] = new List<string>(_corpses);
|
|
rec["destroyedAfterDeath"] = npc == null || npc.IsDestroyed;
|
|
Record("swap", rec);
|
|
Log("swap", $"DONE walked={rec["walked"]} hitsByOurs={_hitsByOurs.Hits} weapons={string.Join(",", _hitsByOurs.Weapons.Select(kv => kv.Key + ":" + kv.Value).ToArray())} " +
|
|
$"hitsOnOurs={_hitsOnOurs.Hits} deaths={_deaths.Count} corpses={string.Join(" | ", _corpses.ToArray())} destroyed={rec["destroyedAfterDeath"]}");
|
|
if (foe != null && !foe.IsDestroyed) foe.Kill();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---- 2. saving ----
|
|
|
|
private sealed class Planted
|
|
{
|
|
public ulong NetId;
|
|
public float X, Y, Z;
|
|
public bool Saved;
|
|
}
|
|
|
|
[ConsoleCommand("rnh.save")]
|
|
private void CmdSave(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
string mode = arg.GetString(0);
|
|
|
|
if (mode == "plant")
|
|
{
|
|
Vector3 field;
|
|
if (!FindField(250f, 202, out field)) { Log("save", "FAIL no field found"); return; }
|
|
var planted = new List<Planted>();
|
|
for (int i = 0; i < 6; i++)
|
|
{
|
|
// Five never saved, one saved: the control that proves the check can see a saved NPC.
|
|
bool saved = i == 5;
|
|
SpawnReport r = SpawnOurs(Ground(field + new Vector3(i * 6f, 0f, 0f)), "RnhSave" + i, null, saved);
|
|
if (r.Npc == null) continue;
|
|
_spawned.Remove(r.Npc); // Unload must not kill them: the restart is the test.
|
|
planted.Add(new Planted { NetId = r.Npc.net.ID.Value, Saved = saved });
|
|
}
|
|
timer.Once(3f, () =>
|
|
{
|
|
foreach (Planted p in planted)
|
|
{
|
|
BaseNetworkable e = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId));
|
|
if (e == null) continue;
|
|
p.X = e.transform.position.x; p.Y = e.transform.position.y; p.Z = e.transform.position.z;
|
|
}
|
|
Interface.Oxide.DataFileSystem.WriteObject(Name + "_planted", planted);
|
|
ConsoleSystem.Run(ConsoleSystem.Option.Server, "server.save");
|
|
Log("save", $"planted {planted.Count} ({planted.Count(p => p.Saved)} saved control) at {V(field)} and ran server.save; now hard-kill the server");
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (mode == "check")
|
|
{
|
|
List<Planted> planted = Interface.Oxide.DataFileSystem.ReadObject<List<Planted>>(Name + "_planted") ?? new List<Planted>();
|
|
int backUnsaved = 0, backSaved = 0;
|
|
var found = new List<string>();
|
|
foreach (Planted p in planted)
|
|
{
|
|
// By net id: Rust restores a saved entity under the id it had. (A distance match was
|
|
// tried first and matched the saved control from its neighbour 6 m away.)
|
|
BaseNetworkable hit = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId));
|
|
BaseNetworkable byId = hit;
|
|
if (hit == null) continue;
|
|
if (p.Saved) backSaved++; else backUnsaved++;
|
|
found.Add($"{p.NetId} saved={p.Saved} -> {hit.GetType().Name} '{(hit as BasePlayer)?.displayName}' byId={byId != null}");
|
|
}
|
|
var rec = new Dictionary<string, object>
|
|
{
|
|
["planted"] = planted.Count,
|
|
["unsavedBack"] = backUnsaved,
|
|
["savedControlBack"] = backSaved,
|
|
["found"] = found,
|
|
["pass"] = backUnsaved == 0 && backSaved == planted.Count(p => p.Saved),
|
|
};
|
|
Record("save." + Framework(), rec);
|
|
Log("save", $"{(true.Equals(rec["pass"]) ? "PASS" : "FAIL")} unsaved back={backUnsaved}/{planted.Count(p => !p.Saved)} saved control back={backSaved}/{planted.Count(p => p.Saved)} :: {string.Join(" | ", found.ToArray())}");
|
|
foreach (Planted p in planted)
|
|
{
|
|
BaseNetworkable e = BaseNetworkable.serverEntities.Find(new NetworkableId(p.NetId));
|
|
if (e != null) e.Kill();
|
|
}
|
|
return;
|
|
}
|
|
|
|
Log("save", "usage: rnh.save plant|check");
|
|
}
|
|
|
|
// ---- 3. tuning ----
|
|
|
|
/// <summary>
|
|
/// Which of a profile's numbers Rust's own brain honours. Each is set either BEFORE the brain
|
|
/// starts (the values <c>Senses.Init</c> copies) or AFTER it is running, and then observed.
|
|
/// </summary>
|
|
[ConsoleCommand("rnh.tune")]
|
|
private void CmdTune(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
Vector3 field;
|
|
if (!FindField(250f, 303, out field)) { Log("tune", "FAIL no field found"); return; }
|
|
|
|
var rec = new Dictionary<string, object>();
|
|
|
|
// Health and name and aim cone: the NPC's own values.
|
|
SpawnReport h = SpawnOurs(field, "RnhTune", null);
|
|
RnhNpc n = h.Npc;
|
|
n.startHealth = 400f;
|
|
n.InitializeHealth(400f, 400f);
|
|
n.RnhAimCone = 0.5f;
|
|
rec["health.set400"] = $"{n.health:0}/{n.MaxHealth():0}";
|
|
rec["name"] = n.displayName;
|
|
rec["aimCone.override0.5"] = n.GetAimConeScale();
|
|
n.Kill();
|
|
|
|
// Sense range: a stock scientist 35 m away. Default SenseRange on the prefab vs 50 set before start vs after.
|
|
Vector3 foeAt = Ground(field + new Vector3(35f, 0f, 0f));
|
|
ScientistNPC target = SpawnStock(foeAt);
|
|
target.Brain.SetEnabled(false);
|
|
|
|
SpawnReport dflt = SpawnOurs(Ground(field + new Vector3(0f, 0f, 4f)), "RnhDefault", b => b.SenseTypes |= EntityType.BasePlayerNPC);
|
|
SpawnReport before = SpawnOurs(Ground(field + new Vector3(0f, 0f, -4f)), "RnhBefore", b =>
|
|
{
|
|
b.SenseTypes |= EntityType.BasePlayerNPC;
|
|
b.SenseRange = 50f;
|
|
b.TargetLostRange = 60f;
|
|
});
|
|
SpawnReport after = SpawnOurs(Ground(field + new Vector3(0f, 0f, -8f)), "RnhAfter", b => b.SenseTypes |= EntityType.BasePlayerNPC);
|
|
|
|
rec["prefab.SenseRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.SenseRange : -1f;
|
|
rec["prefab.TargetLostRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.TargetLostRange : -1f;
|
|
rec["prefab.AttackRangeMultiplier"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.AttackRangeMultiplier : -1f;
|
|
rec["prefab.VisionCone"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.VisionCone : -1f;
|
|
rec["prefab.HostileTargetsOnly"] = dflt.Npc.Brain != null && dflt.Npc.Brain.HostileTargetsOnly;
|
|
rec["prefab.CheckLOS"] = dflt.Npc.Brain != null && dflt.Npc.Brain.CheckLOS;
|
|
rec["prefab.CheckVisionCone"] = dflt.Npc.Brain != null && dflt.Npc.Brain.CheckVisionCone;
|
|
rec["prefab.ListenRange"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.ListenRange : -1f;
|
|
rec["prefab.MemoryDuration"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.MemoryDuration : -1f;
|
|
rec["prefab.SenseTypes"] = dflt.Npc.Brain != null ? dflt.Npc.Brain.SenseTypes.ToString() : "-";
|
|
rec["prefab.MaxRoamDistanceFromHome"] = dflt.Npc.Brain?.Navigator != null ? dflt.Npc.Brain.Navigator.MaxRoamDistanceFromHome : -1f;
|
|
rec["prefab.aimConeScale"] = dflt.Npc.aimConeScale;
|
|
rec["prefab.startHealth"] = dflt.Npc.startHealth;
|
|
|
|
timer.Once(3f, () =>
|
|
{
|
|
if (after.Npc?.Brain != null)
|
|
{
|
|
after.Npc.Brain.SenseRange = 50f;
|
|
after.Npc.Brain.TargetLostRange = 60f;
|
|
}
|
|
});
|
|
|
|
timer.Once(10f, () =>
|
|
{
|
|
Func<SpawnReport, string> sees = s =>
|
|
s.Npc == null || s.Npc.IsDestroyed ? "gone" :
|
|
$"knows={s.Npc.Brain.Senses.Players.Contains(target)} state={s.Npc.Brain.CurrentState?.StateType}";
|
|
rec["senseRange.default@35m"] = sees(dflt);
|
|
rec["senseRange.50.beforeStart@35m"] = sees(before);
|
|
rec["senseRange.50.afterStart@35m"] = sees(after);
|
|
|
|
// Roam: where does a roamer go with no AI zone vs its home range?
|
|
rec["roam.aiZoneHere"] = AIInformationZone.GetForPoint(field, false) != null;
|
|
Record("tune", rec);
|
|
Log("tune", string.Join(" ", rec.Select(kv => kv.Key + "=" + kv.Value).ToArray()));
|
|
foreach (SpawnReport s in new[] { dflt, before, after })
|
|
if (s.Npc != null && !s.Npc.IsDestroyed) s.Npc.Kill();
|
|
if (target != null && !target.IsDestroyed) target.Kill();
|
|
});
|
|
}
|
|
|
|
/// <summary>Roaming at a monument (inside an AI zone) vs in a field (outside one), 60 s each.</summary>
|
|
[ConsoleCommand("rnh.roam")]
|
|
private void CmdRoam(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
Vector3 field;
|
|
if (!FindField(250f, 404, out field)) { Log("roam", "FAIL no field found"); return; }
|
|
AIInformationZone zone = AIInformationZone.zones.FirstOrDefault(z => z != null && z.GetComponentInParent<MonumentInfo>() != null);
|
|
Vector3 monument = Vector3.zero;
|
|
NavMeshHit mh;
|
|
if (zone != null && OnNavmesh(zone.transform.position, 20f, out mh)) monument = mh.position;
|
|
|
|
SpawnReport inField = SpawnOurs(field, "RnhField", null);
|
|
SpawnReport inZone = monument != Vector3.zero ? SpawnOurs(monument, "RnhZone", null) : null;
|
|
Vector3 f0 = field, z0 = monument;
|
|
float fMax = 0f, zMax = 0f;
|
|
var states = new List<string>();
|
|
timer.Repeat(5f, 12, () =>
|
|
{
|
|
if (inField.Npc != null && !inField.Npc.IsDestroyed) fMax = Mathf.Max(fMax, Vector3.Distance(f0, inField.Npc.transform.position));
|
|
if (inZone?.Npc != null && !inZone.Npc.IsDestroyed) zMax = Mathf.Max(zMax, Vector3.Distance(z0, inZone.Npc.transform.position));
|
|
states.Add($"field={inField.Npc?.Brain?.CurrentState?.StateType} zone={inZone?.Npc?.Brain?.CurrentState?.StateType}");
|
|
});
|
|
timer.Once(62f, () =>
|
|
{
|
|
var rec = new Dictionary<string, object>
|
|
{
|
|
["field.maxMovedM"] = Math.Round(fMax, 1),
|
|
["zone"] = zone != null ? zone.GetComponentInParent<MonumentInfo>()?.name : "none",
|
|
["zone.maxMovedM"] = Math.Round(zMax, 1),
|
|
["states"] = states,
|
|
};
|
|
Record("roam", rec);
|
|
Log("roam", $"field moved {fMax:0.0} m, zone ({rec["zone"]}) moved {zMax:0.0} m; last {states.LastOrDefault()}");
|
|
inField.Npc?.Kill();
|
|
inZone?.Npc?.Kill();
|
|
});
|
|
}
|
|
|
|
// ---- 4. navmesh ----
|
|
|
|
[ConsoleCommand("rnh.nav")]
|
|
private void CmdNav(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
int mask = LayerMask.GetMask("Terrain", "World", "Construction", "Default", "Deployed");
|
|
var rows = new List<object>();
|
|
int points = 0, onMesh = 0, roofs = 0, roofsOnMesh = 0;
|
|
Stopwatch sw = Stopwatch.StartNew();
|
|
long checkTicks = 0;
|
|
|
|
foreach (MonumentInfo m in TerrainMeta.Path.Monuments)
|
|
{
|
|
if (m == null) continue;
|
|
Vector3 c = m.transform.position;
|
|
float r = Mathf.Clamp(Mathf.Min(m.Bounds.extents.x, m.Bounds.extents.z) * 0.5f, 5f, 40f);
|
|
int p = 0, on = 0, rf = 0, rfOn = 0;
|
|
for (int i = 0; i < 9; i++)
|
|
{
|
|
Vector3 probe = i == 0 ? c : Ring(c, i - 1, 8, r);
|
|
RaycastHit hit;
|
|
if (!Physics.Raycast(new Vector3(probe.x, 1000f, probe.z), Vector3.down, out hit, 1100f, mask)) continue;
|
|
p++;
|
|
bool roof = hit.point.y > TerrainMeta.HeightMap.GetHeight(hit.point) + 2.5f;
|
|
long t0 = Stopwatch.GetTimestamp();
|
|
NavMeshHit nh;
|
|
bool ok = OnNavmesh(hit.point, 1f, out nh) && Mathf.Abs(nh.position.y - hit.point.y) < 0.6f;
|
|
checkTicks += Stopwatch.GetTimestamp() - t0;
|
|
if (ok) on++;
|
|
if (roof) { rf++; if (ok) rfOn++; }
|
|
}
|
|
points += p; onMesh += on; roofs += rf; roofsOnMesh += rfOn;
|
|
rows.Add(new Dictionary<string, object>
|
|
{
|
|
["monument"] = System.IO.Path.GetFileNameWithoutExtension(m.name),
|
|
["points"] = p, ["onMesh"] = on, ["elevated"] = rf, ["elevatedOnMesh"] = rfOn,
|
|
});
|
|
}
|
|
|
|
// A player-built roof: a floor held 4 m up on the field, sampled on top, then removed.
|
|
Vector3 field;
|
|
string built = "no field";
|
|
if (FindField(250f, 505, out field))
|
|
{
|
|
BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", field + Vector3.up * 4f);
|
|
if (floor != null)
|
|
{
|
|
floor.enableSaving = false;
|
|
floor.Spawn();
|
|
RaycastHit hit;
|
|
bool gotTop = Physics.Raycast(field + Vector3.up * 20f, Vector3.down, out hit, 30f, mask);
|
|
NavMeshHit nh;
|
|
bool ok = gotTop && OnNavmesh(hit.point, 1f, out nh) && Mathf.Abs(nh.position.y - hit.point.y) < 0.6f;
|
|
built = $"top={(gotTop ? hit.point.y - field.y : -1f):0.0}m onMesh={ok}";
|
|
floor.Kill();
|
|
}
|
|
}
|
|
|
|
double checkUs = points > 0 ? checkTicks * 1e6 / Stopwatch.Frequency / points : 0;
|
|
var rec = new Dictionary<string, object>
|
|
{
|
|
["monuments"] = rows.Count, ["points"] = points, ["onMesh"] = onMesh,
|
|
["elevated"] = roofs, ["elevatedOnMesh"] = roofsOnMesh,
|
|
["builtFloor4m"] = built,
|
|
["checkMicrosEach"] = Math.Round(checkUs, 1),
|
|
["scanMs"] = sw.ElapsedMilliseconds,
|
|
["rows"] = rows,
|
|
};
|
|
Record("nav." + Framework(), rec);
|
|
Log("nav", $"{rows.Count} monuments, {onMesh}/{points} points on mesh, elevated {roofsOnMesh}/{roofs}, built floor {built}, check {checkUs:0.0} us each, scan {sw.ElapsedMilliseconds} ms");
|
|
foreach (Dictionary<string, object> row in rows.Cast<Dictionary<string, object>>())
|
|
Log("nav", $" {row["monument"]}: {row["onMesh"]}/{row["points"]} elevated {row["elevatedOnMesh"]}/{row["elevated"]}");
|
|
}
|
|
|
|
// ---- 5. cost ----
|
|
|
|
[ConsoleCommand("rnh.cost")]
|
|
private void CmdCost(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
Vector3 field;
|
|
if (!FindField(400f, 606, out field)) { Log("cost", "FAIL no field found"); return; }
|
|
|
|
float window = arg.GetFloat(0, 30f);
|
|
var phases = new List<Dictionary<string, object>>();
|
|
var ours = new List<RnhNpc>();
|
|
var stock = new List<ScientistNPC>();
|
|
var standIns = new List<BasePlayer>();
|
|
int stockInWorld = BaseNetworkable.serverEntities.OfType<ScientistNPC>().Count();
|
|
|
|
Func<int, Vector3> grid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f));
|
|
Func<int, Vector3> foeGrid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, 45f + (i / 10) * 3f));
|
|
|
|
var queue = new Queue<KeyValuePair<string, Action>>();
|
|
|
|
// Each phase: run its setup (timed), then record frames for `window` seconds.
|
|
Action next = null;
|
|
next = () =>
|
|
{
|
|
if (queue.Count == 0)
|
|
{
|
|
foreach (RnhNpc o in ours) if (o != null && !o.IsDestroyed) o.Kill();
|
|
foreach (ScientistNPC s in stock) if (s != null && !s.IsDestroyed) s.Kill();
|
|
foreach (BasePlayer d in standIns) if (d != null && !d.IsDestroyed) d.Kill();
|
|
_standInsInvulnerable = false;
|
|
var rec = new Dictionary<string, object>
|
|
{
|
|
["framework"] = Framework(),
|
|
["map"] = $"{World.Size}/{World.Seed}",
|
|
["players"] = BasePlayer.activePlayerList.Count,
|
|
["stockScientistsInWorld"] = stockInWorld,
|
|
["aiBudgetMs"] = AIThinkManager.framebudgetms,
|
|
["windowS"] = window,
|
|
["phases"] = phases,
|
|
};
|
|
Record("cost." + Framework(), rec);
|
|
Log("cost", "DONE");
|
|
return;
|
|
}
|
|
KeyValuePair<string, Action> step = queue.Dequeue();
|
|
Stopwatch sw = Stopwatch.StartNew();
|
|
step.Value();
|
|
double setupMs = sw.Elapsed.TotalMilliseconds;
|
|
RnhBrain.ThinkMs = 0; RnhBrain.Thinks = 0;
|
|
foreach (RnhNpc o in ours)
|
|
{
|
|
if (o?.Brain is RnhBrain rb) { rb.IntervalSum = 0; rb.IntervalCount = 0; }
|
|
if (o != null) { o.AttackTicks = 0; o.AttackTicksLos = 0; }
|
|
}
|
|
_hitsByOurs.Hits = 0; _hitsOnOurs.Hits = 0; _hitsOnStandIns.Hits = 0;
|
|
long gc0 = GC.GetTotalMemory(false);
|
|
_meter.Begin();
|
|
timer.Once(window, () =>
|
|
{
|
|
Dictionary<string, object> f = Summarise(_meter.End());
|
|
float isum = 0f; int icount = 0;
|
|
foreach (RnhNpc o in ours) if (o?.Brain is RnhBrain rb) { isum += rb.IntervalSum; icount += rb.IntervalCount; }
|
|
f["phase"] = step.Key;
|
|
f["setupMs"] = Math.Round(setupMs, 1);
|
|
f["ours"] = ours.Count(o => o != null && !o.IsDestroyed && !o.IsDead());
|
|
f["stock"] = stock.Count(s => s != null && !s.IsDestroyed && !s.IsDead());
|
|
f["thinkMsPerSecond"] = Math.Round(RnhBrain.ThinkMs / window, 2);
|
|
f["thinksPerSecond"] = Math.Round(RnhBrain.Thinks / window, 1);
|
|
f["msPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.ThinkMs / RnhBrain.Thinks, 3) : 0;
|
|
f["thinkIntervalS"] = icount > 0 ? Math.Round(isum / icount, 3) : 0;
|
|
f["aiQueue"] = AIThinkManager._processQueue.Count;
|
|
f["hitsByOurs"] = _hitsByOurs.Hits;
|
|
f["hitsOnOurs"] = _hitsOnOurs.Hits;
|
|
f["hitsOnStandIns"] = _hitsOnStandIns.Hits;
|
|
f["attackTicksWithLos"] = ours.Where(o => o != null && !o.IsDestroyed).Sum(o => o.AttackTicksLos);
|
|
f["gcDeltaMB"] = Math.Round((GC.GetTotalMemory(false) - gc0) / 1048576.0, 1);
|
|
phases.Add(f);
|
|
Log("cost", string.Join(" ", f.Select(kv => kv.Key + "=" + kv.Value).ToArray()));
|
|
next();
|
|
});
|
|
};
|
|
|
|
// Rust's default senses (players only) unless a phase asks for NPC fighting: sensing NPCs with
|
|
// line of sight is a cost of its own, measured in its own phase.
|
|
Action<int, Action<RnhBrain>> spawnTo = (n, tune) =>
|
|
{
|
|
for (int i = ours.Count; i < n; i++)
|
|
{
|
|
SpawnReport r = SpawnOurs(grid(i), "RnhCost" + i, tune);
|
|
if (r.Npc != null) ours.Add(r.Npc);
|
|
}
|
|
};
|
|
|
|
queue.Enqueue(new KeyValuePair<string, Action>("baseline", () => { }));
|
|
queue.Enqueue(new KeyValuePair<string, Action>("spawn1", () => spawnTo(1, null)));
|
|
queue.Enqueue(new KeyValuePair<string, Action>("spawn10", () => spawnTo(10, null)));
|
|
queue.Enqueue(new KeyValuePair<string, Action>("spawn100.idle", () => spawnTo(100, null)));
|
|
// The org lead's second load: real gunfire. Rust's AI only shoots non-NPC players, so the
|
|
// targets are 20 stand-ins (IsNpc false) in a row 12 m in front of the grid; the harness
|
|
// zeroes the damage they take, so none die and the bridge records no deaths.
|
|
queue.Enqueue(new KeyValuePair<string, Action>("fight100vStandIns", () =>
|
|
{
|
|
_standInsInvulnerable = true;
|
|
for (int i = 1; i <= 20; i++)
|
|
{
|
|
BasePlayer d = SpawnDummy(Ground(field + new Vector3((i - 1) * 1.5f, 0f, -12f)), i);
|
|
if (d != null) standIns.Add(d);
|
|
}
|
|
}));
|
|
// The org lead's first load: a fresh 100 set to fight NPCs (sensing NPCs, LOS checks, the
|
|
// harness attack stand-in) beside 100 stock scientists. Rust's design never fires at an NPC,
|
|
// so this measures NPCs in combat without gunfire.
|
|
queue.Enqueue(new KeyValuePair<string, Action>("fight100vScientists", () =>
|
|
{
|
|
foreach (RnhNpc o in ours) if (o != null && !o.IsDestroyed) o.Kill();
|
|
foreach (BasePlayer d in standIns) if (d != null && !d.IsDestroyed) d.Kill();
|
|
_standInsInvulnerable = false;
|
|
ours.Clear(); standIns.Clear();
|
|
spawnTo(100, FightsScientists);
|
|
for (int i = 0; i < 100; i++)
|
|
{
|
|
ScientistNPC s = SpawnStock(foeGrid(i));
|
|
if (s != null) stock.Add(s);
|
|
}
|
|
}));
|
|
queue.Enqueue(new KeyValuePair<string, Action>("after.clear", () =>
|
|
{
|
|
foreach (RnhNpc o in ours) if (o != null && !o.IsDestroyed) o.Kill();
|
|
foreach (ScientistNPC s in stock) if (s != null && !s.IsDestroyed) s.Kill();
|
|
ours.Clear(); stock.Clear();
|
|
}));
|
|
|
|
Log("cost", $"field {V(field)}, {window:0} s per phase, {queue.Count} phases; players online {BasePlayer.activePlayerList.Count}");
|
|
next();
|
|
}
|
|
|
|
// ---- 6. the name on the death screen, and the bridge ----
|
|
|
|
private static readonly MethodInfo LifeStoryStartMethod =
|
|
typeof(BasePlayer).GetMethod("LifeStoryStart", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
|
|
|
/// <summary>
|
|
/// A stand-in player: <c>player.prefab</c> with a made-up user id, so <c>IsNpc</c> is false
|
|
/// and the bridge reports it as it would a real player (PLAN.md §1.2 found that by
|
|
/// accident with HumanNPC). It never connects. Only the harness ever makes one.
|
|
/// </summary>
|
|
private BasePlayer SpawnDummy(Vector3 at, int index = 0)
|
|
{
|
|
BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, at) as BasePlayer;
|
|
if (p == null) return null;
|
|
ulong id = DummyUserId + (ulong)index;
|
|
p.userID = id;
|
|
p.UserIDString = id.ToString();
|
|
p.displayName = index == 0 ? "RnhDummy" : "RnhStandIn" + index;
|
|
p.enableSaving = false;
|
|
p.Spawn();
|
|
p.EndSleeping();
|
|
LifeStoryStartMethod?.Invoke(p, null);
|
|
_spawned.Add(p);
|
|
Equip(p);
|
|
return p;
|
|
}
|
|
|
|
[ConsoleCommand("rnh.dummy")]
|
|
private void CmdDummy(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
Vector3 field;
|
|
if (!FindField(250f, 707, out field)) { Log("dummy", "FAIL no field found"); return; }
|
|
_deaths.Clear(); _corpses.Clear();
|
|
var rec = new Dictionary<string, object>();
|
|
|
|
BasePlayer dummy = SpawnDummy(field);
|
|
if (dummy == null) { Log("dummy", "FAIL no dummy"); return; }
|
|
rec["dummy"] = $"isNpc={dummy.IsNpc} isBot={dummy.IsBot} sleeping={dummy.IsSleeping()} lifeStory={dummy.lifeStory != null} held={dummy.GetActiveItem()?.info.shortname}";
|
|
Log("dummy", "spawned " + rec["dummy"]);
|
|
|
|
// (a) The stand-in kills one of ours, with the kit's revolver as the weapon on the hit.
|
|
SpawnReport victim = SpawnOurs(Ground(field + new Vector3(6f, 0f, 0f)), "RnhWarden", null);
|
|
timer.Once(2f, () =>
|
|
{
|
|
Item gunItem = dummy.inventory.containerBelt.GetSlot(0);
|
|
AttackEntity gun = gunItem?.GetHeldEntity() as AttackEntity;
|
|
var hit = new HitInfo(dummy, victim.Npc, Rust.DamageType.Bullet, 1000f) { Weapon = gun, WeaponPrefab = gun };
|
|
victim.Npc.Hurt(hit);
|
|
rec["playerKillsOurs"] = $"weapon={(gun != null ? gun.ShortPrefabName : "none")} dead={victim.Npc == null || victim.Npc.IsDestroyed || victim.Npc.IsDead()}";
|
|
Log("dummy", "(a) " + rec["playerKillsOurs"]);
|
|
});
|
|
|
|
// (b) One of ours kills the stand-in, fighting with its own kit.
|
|
timer.Once(6f, () =>
|
|
{
|
|
SpawnReport killer = SpawnOurs(Ground(field + new Vector3(-14f, 0f, 0f)), "RnhWarden", b => b.HostileTargetsOnly = false);
|
|
timer.Once(1f, () =>
|
|
{
|
|
if (killer.Npc?.Brain?.Senses?.Memory != null && dummy != null && !dummy.IsDestroyed)
|
|
killer.Npc.Brain.Senses.Memory.SetKnown(dummy, killer.Npc, null);
|
|
});
|
|
int waited = 0;
|
|
timer.Repeat(2f, 45, () =>
|
|
{
|
|
waited += 2;
|
|
if (rec.ContainsKey("deathScreen")) return;
|
|
if (dummy == null || dummy.IsDestroyed || dummy.IsDead())
|
|
{
|
|
ProtoBuf.PlayerLifeStory story = dummy?.previousLifeStory ?? dummy?.lifeStory;
|
|
ProtoBuf.PlayerLifeStory.DeathInfo d = story?.deathInfo;
|
|
rec["deathScreen"] = d == null ? "no death info" :
|
|
$"attackerName='{d.attackerName}' attackerSteamID={d.attackerSteamID} inflictorName='{d.inflictorName}' hitBone='{d.hitBone}' distance={d.attackerDistance:0.0}";
|
|
rec["ourKillerStillNamed"] = killer.Npc != null ? killer.Npc.displayName : "gone";
|
|
rec["secondsToKill"] = waited;
|
|
rec["deaths"] = new List<string>(_deaths);
|
|
rec["corpses"] = new List<string>(_corpses);
|
|
Record("dummy." + Framework(), rec);
|
|
Log("dummy", "(b) " + rec["deathScreen"] + " after " + waited + " s; deaths: " + string.Join(" | ", _deaths.ToArray()) + "; corpses: " + string.Join(" | ", _corpses.ToArray()));
|
|
timer.Once(5f, () =>
|
|
{
|
|
if (killer.Npc != null && !killer.Npc.IsDestroyed) killer.Npc.Kill();
|
|
if (dummy != null && !dummy.IsDestroyed) dummy.Kill();
|
|
foreach (PlayerCorpse c in BaseNetworkable.serverEntities.OfType<PlayerCorpse>().Where(c => c.playerSteamID == DummyUserId || c.playerName == "RnhWarden").ToArray())
|
|
c.Kill();
|
|
});
|
|
}
|
|
else if (waited >= 90)
|
|
{
|
|
rec["deathScreen"] = $"FAIL the stand-in was not killed in 90 s (hp {dummy.health:0}, killer state {killer.Npc?.Brain?.CurrentState?.StateType})";
|
|
Record("dummy." + Framework(), rec);
|
|
Log("dummy", (string)rec["deathScreen"]);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---- housekeeping ----
|
|
|
|
[ConsoleCommand("rnh.clear")]
|
|
private void CmdClear(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
int n = 0;
|
|
foreach (BaseEntity e in _spawned.ToArray())
|
|
if (e != null && !e.IsDestroyed) { e.Kill(); n++; }
|
|
_spawned.Clear();
|
|
int loose = 0;
|
|
foreach (RnhNpc o in BaseNetworkable.serverEntities.OfType<RnhNpc>().ToArray()) { o.Kill(); loose++; }
|
|
Log("clear", $"killed {n} tracked and {loose} untracked harness NPCs");
|
|
}
|
|
}
|
|
}
|