All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m28s
RunicNpcHarness 0.2.0 adds the measurements stage 5 opens with: - rnh.npcfight: our own sensing and combat state against a stock scientist, another of ours, a boar and a wolf, and with an ally on the line of fire. Rust's BaseProjectile.ServerUse drops every hit on an NPC from an NPC's gun unless the victim's faction is Horror; the prototype marks only its current target Horror for the length of each of its own shots. - rnh.sense: what that sensing costs, 100 against 100 scientists and 50 against 50 of ours. - rnh.turrets: auto turret, flame turret and shotgun trap against a stand-in player, a stock scientist and ours (D259). - rnh.items: which kit items Rust's AI uses by itself (D260). - rnh.pve: what TruePVE or NextGenPVE does with our NPC, and with an answer to CanEntityTakeDamage (D261). Never shipped: a developer tool in tools/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2064 lines
102 KiB
C#
2064 lines
102 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>
|
|
///
|
|
/// And stage 5's spike (D231, D253): our own sensing and combat state against NPC targets
|
|
/// (<c>rnh.npcfight</c>), its cost (<c>rnh.sense</c>), turrets and traps (<c>rnh.turrets</c>,
|
|
/// D259), the kit items Rust's AI uses by itself (<c>rnh.items</c>, D260), and TruePVE or
|
|
/// NextGenPVE, whichever is loaded (<c>rnh.pve</c>, D261).
|
|
///
|
|
/// 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.2.0")]
|
|
[Description("RunicNPC stage 1 and stage 5 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>();
|
|
foreach (BaseNetworkable n in BaseNetworkable.serverEntities)
|
|
Register(n as BaseEntity);
|
|
Log("ready", $"registry scientists={Scientists.Count} animals={Animals.Count} pveStandIn.isSteamId={PveUserId.IsSteamId()}");
|
|
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);
|
|
Scientists.Clear();
|
|
Animals.Clear();
|
|
OursByTag.Clear();
|
|
_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;
|
|
}
|
|
|
|
// ---- stage 5: our own target, beside Rust's ----
|
|
|
|
/// <summary>This NPC's "profile" for the spike: allies share a tag.</summary>
|
|
public string RnhTag = "";
|
|
|
|
/// <summary>The factions it hunts: <c>scientists</c>, <c>animals</c>, <c>ours:<tag></c>. Null: none.</summary>
|
|
public HashSet<string> RnhHostile;
|
|
|
|
/// <summary>
|
|
/// BaseProjectile.ServerUse drops every hit on an NPC from an NPC's gun unless the victim's
|
|
/// faction is Horror. When set, the target alone is made Horror for the length of each of
|
|
/// this NPC's shots, then put back, so nothing else changes for anyone else's bullets.
|
|
/// </summary>
|
|
public bool RnhShotFaction;
|
|
|
|
/// <summary>When a turret or trap hurts it and it has no target, the turret becomes its target.</summary>
|
|
public bool RnhShootsBack;
|
|
|
|
public BaseCombatEntity RnhNpcTarget;
|
|
public int ShotTests, ShotsMarked;
|
|
public string LastHurtBy = "-";
|
|
|
|
private BaseCombatEntity _marked;
|
|
private BaseCombatEntity.Faction _markedWas;
|
|
|
|
/// <summary>
|
|
/// Marks the target unless a mark is already in place, and says whether it did. ShotTest
|
|
/// calls TriggerDown inside itself, so the two nest: an inner mark would save "Horror" as
|
|
/// the faction to put back and leave the target Horror for good, after which everyone's
|
|
/// bullets hit it, allies' included (the first 50-v-50 run showed exactly that).
|
|
/// </summary>
|
|
private bool Mark()
|
|
{
|
|
if (_marked != null)
|
|
return false;
|
|
BaseCombatEntity t = RnhNpcTarget;
|
|
if (!RnhShotFaction || t == null || t.IsDestroyed || !t.IsNpc)
|
|
return false;
|
|
_marked = t;
|
|
_markedWas = t.faction;
|
|
t.faction = BaseCombatEntity.Faction.Horror;
|
|
ShotsMarked++;
|
|
return true;
|
|
}
|
|
|
|
private void Unmark(bool marked)
|
|
{
|
|
if (!marked)
|
|
return;
|
|
if (_marked != null && !_marked.IsDestroyed)
|
|
_marked.faction = _markedWas;
|
|
_marked = null;
|
|
}
|
|
|
|
/// <summary>A single-shot weapon fires inside ShotTest; a burst weapon fires from TriggerDown.</summary>
|
|
public override bool ShotTest(float targetDist)
|
|
{
|
|
ShotTests++;
|
|
bool marked = Mark();
|
|
try { return base.ShotTest(targetDist); }
|
|
finally { Unmark(marked); }
|
|
}
|
|
|
|
public override void TriggerDown()
|
|
{
|
|
bool marked = Mark();
|
|
try { base.TriggerDown(); }
|
|
finally { Unmark(marked); }
|
|
}
|
|
|
|
public override void Hurt(HitInfo info)
|
|
{
|
|
if (info != null)
|
|
{
|
|
BaseEntity parent = info.Weapon != null ? info.Weapon.GetParentEntity() : null;
|
|
LastHurtBy = $"initiator={info.Initiator?.GetType().Name ?? "null"} weapon={info.Weapon?.ShortPrefabName ?? "null"} weaponParent={parent?.GetType().Name ?? "null"} type={info.damageTypes.GetMajorityDamageType()}";
|
|
BaseCombatEntity turret = TurretOf(info.Initiator) ?? TurretOf(parent);
|
|
if (RnhShootsBack && turret != null && (RnhNpcTarget == null || RnhNpcTarget.IsDestroyed))
|
|
RnhNpcTarget = turret;
|
|
}
|
|
base.Hurt(info);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The turret behind a hit. A flame turret's damage comes from its fireballs, whose
|
|
/// creator is the turret, or the fireball that spawned them: follow the chain.
|
|
/// </summary>
|
|
public static BaseCombatEntity TurretOf(BaseEntity e)
|
|
{
|
|
for (int i = 0; i < 6 && e is FireBall; i++)
|
|
e = e.creatorEntity == e ? null : e.creatorEntity;
|
|
return e is AutoTurret || e is FlameTurret || e is GunTrap ? (BaseCombatEntity)e : null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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));
|
|
}
|
|
}
|
|
|
|
if (npc != null && Navigator != null && (npc.RnhHostile != null || npc.RnhNpcTarget != null))
|
|
{
|
|
long o0 = Stopwatch.GetTimestamp();
|
|
OwnThink(npc, delta);
|
|
OwnMs += (Stopwatch.GetTimestamp() - o0) * 1000.0 / Stopwatch.Frequency;
|
|
OwnThinks++;
|
|
}
|
|
ThinkMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency;
|
|
Thinks++;
|
|
}
|
|
|
|
// ---- stage 5: the prototype of our own sensing and combat state ----
|
|
|
|
public const float OwnSenseRange = 40f;
|
|
public const float OwnLoseRange = 60f;
|
|
public const float OwnSenseEvery = 1f;
|
|
private const int LosBudget = 3;
|
|
private const int LosMask = 1218519041;
|
|
|
|
public static double OwnMs, SenseMs;
|
|
public static long OwnThinks, SenseRuns, Scanned, LosTests, Engaged;
|
|
|
|
private float _nextSense, _nextLos, _nextMove;
|
|
private bool _los;
|
|
|
|
private static readonly BaseCombatEntity[] Best = new BaseCombatEntity[LosBudget];
|
|
private static readonly float[] BestD2 = new float[LosBudget];
|
|
|
|
/// <summary>
|
|
/// After Rust's own think, so Rust's design still owns every fight with a player. With no
|
|
/// player to fight and a target of ours: face it (the states clear facing every think, so
|
|
/// it is set again after them; the navigator applies it on its own 0.1 s tick), close in
|
|
/// until it is in range and in sight, and hand Rust's own attack routine the target.
|
|
/// </summary>
|
|
private void OwnThink(RnhNpc npc, float delta)
|
|
{
|
|
float now = Time.time;
|
|
BaseCombatEntity t = npc.RnhNpcTarget;
|
|
if (t != null && (t.IsDestroyed || t.IsDead() || (t.transform.position - npc.transform.position).sqrMagnitude > OwnLoseRange * OwnLoseRange))
|
|
t = npc.RnhNpcTarget = null;
|
|
|
|
if (npc.RnhHostile != null && now >= _nextSense && RnhNpc.TurretOf(t) == null)
|
|
{
|
|
_nextSense = now + OwnSenseEvery;
|
|
long s0 = Stopwatch.GetTimestamp();
|
|
BaseCombatEntity found = SenseHostile(npc);
|
|
SenseMs += (Stopwatch.GetTimestamp() - s0) * 1000.0 / Stopwatch.Frequency;
|
|
SenseRuns++;
|
|
if (found != null)
|
|
t = npc.RnhNpcTarget = found;
|
|
}
|
|
|
|
if (t == null || FightingAPlayer())
|
|
return;
|
|
|
|
Engaged++;
|
|
if (now >= _nextLos)
|
|
{
|
|
_nextLos = now + 0.5f;
|
|
_los = Los(npc, t);
|
|
LosTests++;
|
|
}
|
|
|
|
Navigator.SetFacingDirectionEntity(t);
|
|
float dist = Vector3.Distance(npc.transform.position, t.transform.position);
|
|
if (!_los || dist > npc.EngagementRange() * 0.9f)
|
|
{
|
|
if (now >= _nextMove)
|
|
{
|
|
_nextMove = now + 1f;
|
|
Navigator.SetDestination(t.transform.position, BaseNavigator.NavigationSpeed.Fast);
|
|
}
|
|
}
|
|
else if (Navigator.Moving)
|
|
Navigator.Stop();
|
|
|
|
npc.AttackTick(delta, t, _los);
|
|
}
|
|
|
|
private bool FightingAPlayer()
|
|
{
|
|
if (CurrentState == null || !CurrentState.AgrresiveState || Senses?.Memory == null)
|
|
return false;
|
|
foreach (BaseEntity e in Senses.Memory.Targets)
|
|
if (e is BasePlayer p && !p.IsNpc && p.IsAlive())
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Only the factions this NPC is hostile to, from the harness's registry rather than a
|
|
/// physics query; distance first (no physics), then line of sight on the nearest few only.
|
|
/// </summary>
|
|
private static BaseCombatEntity SenseHostile(RnhNpc npc)
|
|
{
|
|
for (int i = 0; i < LosBudget; i++) { Best[i] = null; BestD2[i] = float.MaxValue; }
|
|
Vector3 at = npc.transform.position;
|
|
float r2 = OwnSenseRange * OwnSenseRange;
|
|
foreach (string faction in npc.RnhHostile)
|
|
{
|
|
HashSet<BaseCombatEntity> set = Registry(faction);
|
|
if (set == null) continue;
|
|
foreach (BaseCombatEntity e in set)
|
|
{
|
|
if (e == null || e.IsDestroyed || e == npc || e.IsDead()) continue;
|
|
Scanned++;
|
|
float d2 = (e.transform.position - at).sqrMagnitude;
|
|
if (d2 > r2 || d2 >= BestD2[LosBudget - 1]) continue;
|
|
int k = LosBudget - 1;
|
|
while (k > 0 && BestD2[k - 1] > d2) { Best[k] = Best[k - 1]; BestD2[k] = BestD2[k - 1]; k--; }
|
|
Best[k] = e; BestD2[k] = d2;
|
|
}
|
|
}
|
|
for (int i = 0; i < LosBudget && Best[i] != null; i++)
|
|
{
|
|
LosTests++;
|
|
if (Los(npc, Best[i])) return Best[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// HumanNPC.CanSeeTarget answers true for anything that is not a player, without a ray,
|
|
/// so animals and turrets get a linecast that counts the target's own collider as seen.
|
|
/// </summary>
|
|
private static bool Los(RnhNpc npc, BaseCombatEntity t)
|
|
{
|
|
BasePlayer p = t as BasePlayer;
|
|
if (p != null) return npc.CanSeeTarget(p);
|
|
RaycastHit hit;
|
|
if (!Physics.Linecast(npc.eyes.position, t.CenterPoint(), out hit, LosMask, QueryTriggerInteraction.Ignore))
|
|
return true;
|
|
BaseEntity e = hit.GetEntity();
|
|
return e != null && (e == t || e.EqualNetID(t) || e.HasParent() && e.GetParentEntity() == t);
|
|
}
|
|
}
|
|
|
|
// ---- the stage 5 registry: who can be hunted, by faction ----
|
|
|
|
private static readonly HashSet<BaseCombatEntity> Scientists = new HashSet<BaseCombatEntity>();
|
|
private static readonly HashSet<BaseCombatEntity> Animals = new HashSet<BaseCombatEntity>();
|
|
private static readonly Dictionary<string, HashSet<BaseCombatEntity>> OursByTag = new Dictionary<string, HashSet<BaseCombatEntity>>();
|
|
|
|
private static HashSet<BaseCombatEntity> Registry(string faction)
|
|
{
|
|
if (faction == "scientists") return Scientists;
|
|
if (faction == "animals") return Animals;
|
|
HashSet<BaseCombatEntity> set;
|
|
return faction.StartsWith("ours:") && OursByTag.TryGetValue(faction.Substring(5), out set) ? set : null;
|
|
}
|
|
|
|
private static void Register(BaseEntity e)
|
|
{
|
|
if (e is RnhNpc) return;
|
|
if (e is ScientistNPC s) Scientists.Add(s);
|
|
else if (e is BaseNpc || e is Rust.Ai.Gen2.BaseNPC2) Animals.Add((BaseCombatEntity)e);
|
|
}
|
|
|
|
private static void Tag(RnhNpc npc, string tag)
|
|
{
|
|
npc.RnhTag = tag;
|
|
HashSet<BaseCombatEntity> set;
|
|
if (!OursByTag.TryGetValue(tag, out set)) OursByTag[tag] = set = new HashSet<BaseCombatEntity>();
|
|
set.Add(npc);
|
|
}
|
|
|
|
private static void Unregister(BaseEntity e)
|
|
{
|
|
BaseCombatEntity c = e as BaseCombatEntity;
|
|
if (c == null) return;
|
|
Scientists.Remove(c);
|
|
Animals.Remove(c);
|
|
RnhNpc o = c as RnhNpc;
|
|
HashSet<BaseCombatEntity> set;
|
|
if (o != null && OursByTag.TryGetValue(o.RnhTag, out set)) set.Remove(o);
|
|
}
|
|
|
|
// ---- 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";
|
|
TallyPair(victim, info, weapon);
|
|
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)
|
|
{
|
|
string label;
|
|
if (victim != null && _labels.TryGetValue(victim, out label) && !_diedAt.ContainsKey(label))
|
|
_diedAt[label] = (float)Math.Round(Time.time - _scenarioStart, 1);
|
|
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 OnEntityKill(BaseNetworkable entity) => Unregister(entity as BaseEntity);
|
|
|
|
/// <summary>
|
|
/// TruePVE and NextGenPVE ask every plugin this before applying their rules (D261). The
|
|
/// harness counts the asks; in "allow" mode it allows any hit to or from one of ours, in
|
|
/// "deny" mode it refuses them.
|
|
/// </summary>
|
|
private object CanEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
|
|
{
|
|
_pveAsks++;
|
|
if (_pveMode == 0 || !(entity is RnhNpc || info?.Initiator is RnhNpc))
|
|
return null;
|
|
return _pveMode == 1;
|
|
}
|
|
|
|
private void OnEntitySpawned(BaseEntity entity)
|
|
{
|
|
Register(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"]);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ==== stage 5 (PLAN.md §9, stage 5; D231, D253-D262) ====
|
|
|
|
/// <summary>
|
|
/// The PVE stand-in's user id. TruePVE and NextGenPVE treat only Steam ids as players
|
|
/// (<c>IsSteamId</c>), so this one is in the Steam range, and far beyond any id Steam has issued.
|
|
/// </summary>
|
|
private const ulong PveUserId = 76570997960265728UL;
|
|
|
|
/// <summary>0 observe (answer nothing), 1 allow (D261), 2 deny (D261's per-profile opt-out).</summary>
|
|
private int _pveMode;
|
|
private int _pveAsks;
|
|
|
|
/// <summary>Labelled entities: every hit to or from one is tallied by "attacker>victim".</summary>
|
|
private readonly Dictionary<BaseEntity, string> _labels = new Dictionary<BaseEntity, string>();
|
|
private readonly Dictionary<string, HitTally> _pairs = new Dictionary<string, HitTally>();
|
|
private readonly Dictionary<string, float> _diedAt = new Dictionary<string, float>();
|
|
private readonly HashSet<BaseEntity> _invulnerable = new HashSet<BaseEntity>();
|
|
private float _scenarioStart;
|
|
|
|
private T Label<T>(T e, string label) where T : BaseEntity
|
|
{
|
|
if (e == null) return null;
|
|
_labels[e] = label;
|
|
_spawned.Add(e);
|
|
return e;
|
|
}
|
|
|
|
private void TallyPair(BaseCombatEntity victim, HitInfo info, string weapon)
|
|
{
|
|
string v = null, a = null;
|
|
if (victim != null) _labels.TryGetValue(victim, out v);
|
|
BaseEntity src = info.Initiator;
|
|
if (src != null) _labels.TryGetValue(src, out a);
|
|
if (a == null && info.Weapon != null)
|
|
{
|
|
BaseEntity parent = info.Weapon.GetParentEntity();
|
|
if (parent != null) _labels.TryGetValue(parent, out a);
|
|
}
|
|
if (v == null && a == null) return;
|
|
|
|
string key = (a ?? (src != null ? src.ShortPrefabName : "none")) + ">" + (v ?? victim.ShortPrefabName);
|
|
HitTally t;
|
|
if (!_pairs.TryGetValue(key, out t)) _pairs[key] = t = new HitTally();
|
|
t.Hits++;
|
|
t.Damage += info.damageTypes.Total();
|
|
int n;
|
|
t.Weapons.TryGetValue(weapon, out n);
|
|
t.Weapons[weapon] = n + 1;
|
|
|
|
if (victim != null && _invulnerable.Contains(victim))
|
|
info.damageTypes.ScaleAll(0f);
|
|
}
|
|
|
|
private Dictionary<string, object> Pairs() => _pairs.ToDictionary(kv => kv.Key, kv => (object)new Dictionary<string, object>
|
|
{
|
|
["hits"] = kv.Value.Hits,
|
|
["damage"] = Math.Round(kv.Value.Damage, 1),
|
|
["weapons"] = kv.Value.Weapons.ToDictionary(w => w.Key, w => (object)w.Value),
|
|
});
|
|
|
|
private sealed class Scenario
|
|
{
|
|
public string Name;
|
|
public float Seconds;
|
|
public Action Setup;
|
|
public Func<bool> Done;
|
|
public Func<Dictionary<string, object>> Extra;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs scenarios one after another: set up, wait until done or out of time, record the hit
|
|
/// pairs, deaths and extras, then remove everything labelled and the corpses near the field.
|
|
/// </summary>
|
|
private void RunScenarios(string test, Vector3 field, List<Scenario> list, Dictionary<string, object> header = null)
|
|
{
|
|
var results = new List<Dictionary<string, object>>();
|
|
int index = 0;
|
|
Action next = null;
|
|
next = () =>
|
|
{
|
|
if (index >= list.Count)
|
|
{
|
|
var rec = header ?? new Dictionary<string, object>();
|
|
rec["framework"] = Framework();
|
|
rec["scenarios"] = results;
|
|
Record(test + "." + Framework(), rec);
|
|
Log(test, "DONE");
|
|
return;
|
|
}
|
|
Scenario sc = list[index++];
|
|
_labels.Clear(); _pairs.Clear(); _diedAt.Clear(); _invulnerable.Clear();
|
|
RnhBrain.OwnMs = RnhBrain.SenseMs = 0; RnhBrain.OwnThinks = RnhBrain.SenseRuns = RnhBrain.Scanned = RnhBrain.LosTests = RnhBrain.Engaged = 0;
|
|
_scenarioStart = Time.time;
|
|
string error = null;
|
|
try { sc.Setup(); }
|
|
catch (Exception e) { error = e.GetType().Name + ": " + e.Message; }
|
|
|
|
float elapsed = 0f;
|
|
Timer poll = null;
|
|
poll = timer.Every(1f, () =>
|
|
{
|
|
elapsed += 1f;
|
|
bool done = error != null;
|
|
if (!done) { try { done = sc.Done != null && sc.Done(); } catch { done = true; } }
|
|
if (!done && elapsed < sc.Seconds) return;
|
|
poll.Destroy();
|
|
|
|
var r = new Dictionary<string, object>
|
|
{
|
|
["scenario"] = sc.Name,
|
|
["seconds"] = elapsed,
|
|
["pairs"] = Pairs(),
|
|
["diedAt"] = new Dictionary<string, float>(_diedAt),
|
|
};
|
|
if (error != null) r["error"] = error;
|
|
if (sc.Extra != null)
|
|
{
|
|
try { foreach (KeyValuePair<string, object> kv in sc.Extra()) r[kv.Key] = kv.Value; }
|
|
catch (Exception e) { r["extraError"] = e.Message; }
|
|
}
|
|
results.Add(r);
|
|
Log(test, $"{sc.Name}: {JsonConvert.SerializeObject(r)}");
|
|
|
|
foreach (BaseEntity e in _labels.Keys.ToArray())
|
|
if (e != null && !e.IsDestroyed) e.Kill();
|
|
ClearLeftovers(field, 80f);
|
|
timer.Once(2f, next);
|
|
});
|
|
};
|
|
Log(test, $"field {V(field)}, {list.Count} scenarios");
|
|
next();
|
|
}
|
|
|
|
/// <summary>Corpses and dropped backpacks near the field: a fight leaves both.</summary>
|
|
private static void ClearLeftovers(Vector3 field, float radius)
|
|
{
|
|
foreach (BaseEntity e in BaseNetworkable.serverEntities.OfType<BaseEntity>()
|
|
.Where(e => (e is BaseCorpse || e is DroppedItemContainer || e is DroppedItem) && Vector3.Distance(e.transform.position, field) < radius)
|
|
.ToArray())
|
|
e.Kill();
|
|
}
|
|
|
|
private RnhNpc Ours(Vector3 at, string label, string tag, bool shotFaction, params string[] hostile)
|
|
{
|
|
SpawnReport r = SpawnOurs(Ground(at), "Rnh" + label);
|
|
if (r.Npc == null) throw new Exception("our NPC did not spawn: " + r.Error);
|
|
Tag(r.Npc, tag);
|
|
r.Npc.RnhShotFaction = shotFaction;
|
|
if (hostile.Length > 0) r.Npc.RnhHostile = new HashSet<string>(hostile);
|
|
return Label(r.Npc, label);
|
|
}
|
|
|
|
private ScientistNPC Stock(Vector3 at, string label) => Label(SpawnStock(Ground(at)), label);
|
|
|
|
private static string FindPrefab(string suffix) =>
|
|
GameManifest.Current.entities.FirstOrDefault(p => p.EndsWith(suffix, StringComparison.OrdinalIgnoreCase));
|
|
|
|
private BaseCombatEntity Animal(string suffix, Vector3 at, string label)
|
|
{
|
|
string path = FindPrefab(suffix);
|
|
if (path == null) throw new Exception("no prefab ends with " + suffix);
|
|
BaseCombatEntity a = GameManager.server.CreateEntity(path, Ground(at)) as BaseCombatEntity;
|
|
if (a == null) throw new Exception(path + " is not a combat entity");
|
|
a.enableSaving = false;
|
|
a.Spawn();
|
|
return Label(a, label);
|
|
}
|
|
|
|
private static Dictionary<string, object> Stats(RnhNpc o) => o == null || o.IsDestroyed
|
|
? new Dictionary<string, object> { ["gone"] = true }
|
|
: new Dictionary<string, object>
|
|
{
|
|
["health"] = Math.Round(o.health, 0),
|
|
["target"] = o.RnhNpcTarget != null ? o.RnhNpcTarget.ShortPrefabName : "none",
|
|
["targetHealth"] = o.RnhNpcTarget != null && !o.RnhNpcTarget.IsDestroyed
|
|
? $"{o.RnhNpcTarget.health:0}/{o.RnhNpcTarget.MaxHealth():0} at {Vector3.Distance(o.transform.position, o.RnhNpcTarget.transform.position):0} m"
|
|
: "-",
|
|
["shotTests"] = o.ShotTests,
|
|
["shotsMarked"] = o.ShotsMarked,
|
|
["attackTicks"] = o.AttackTicks,
|
|
["attackTicksWithLos"] = o.AttackTicksLos,
|
|
["state"] = o.Brain?.CurrentState?.StateType.ToString() ?? "none",
|
|
["held"] = o.GetActiveItem()?.info.shortname ?? "none",
|
|
["lastHurtBy"] = o.LastHurtBy,
|
|
};
|
|
|
|
private static bool Dead(BaseEntity e) => e == null || e.IsDestroyed || (e as BaseCombatEntity)?.IsDead() == true;
|
|
|
|
// ---- 5.1 our sensing and combat state against an NPC target ----
|
|
|
|
[ConsoleCommand("rnh.npcfight")]
|
|
private void CmdNpcFight(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
Vector3 f;
|
|
if (!FindField(250f, 505, out f)) { Log("npcfight", "FAIL no field found"); return; }
|
|
Vector3 z(float m) => f + new Vector3(0f, 0f, m);
|
|
float secs = arg.GetFloat(0, 60f);
|
|
|
|
RnhNpc o = null, b = null, ally = null;
|
|
BaseCombatEntity foe = null;
|
|
Func<Dictionary<string, object>> extra = () => new Dictionary<string, object>
|
|
{
|
|
["ours"] = Stats(o),
|
|
["other"] = b != null ? Stats(b) : null,
|
|
["ally"] = ally != null ? Stats(ally) : null,
|
|
["foe"] = foe == null ? null : new Dictionary<string, object>
|
|
{
|
|
["type"] = foe.GetType().Name, ["dead"] = Dead(foe), ["health"] = foe.IsDestroyed ? 0 : Math.Round(foe.health, 0),
|
|
},
|
|
["engagedThinks"] = RnhBrain.Engaged,
|
|
["senseRuns"] = RnhBrain.SenseRuns,
|
|
["leftHorror"] = _labels.Keys.Count(e => e is BaseCombatEntity c && !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror),
|
|
};
|
|
Action reset = () => { o = b = ally = null; foe = null; };
|
|
|
|
var list = new List<Scenario>
|
|
{
|
|
// Rust's own filter, untouched: shots at a stock scientist, and what lands.
|
|
new Scenario { Name = "scientist.noFaction", Seconds = 30f, Extra = extra,
|
|
Setup = () => { reset(); o = Ours(f, "ours", "a", false, "scientists"); foe = Stock(z(15f), "stock"); } },
|
|
new Scenario { Name = "scientist.horrorPerShot", Seconds = secs, Extra = extra, Done = () => Dead(foe),
|
|
Setup = () => { reset(); o = Ours(f, "ours", "a", true, "scientists"); foe = Stock(z(15f), "stock"); } },
|
|
new Scenario { Name = "ours.vs.ours", Seconds = secs, Extra = extra, Done = () => Dead(o) || Dead(b),
|
|
Setup = () => { reset(); o = Ours(f, "ours", "a", true, "ours:b"); b = Ours(z(15f), "other", "b", true, "ours:a"); } },
|
|
new Scenario { Name = "animal.boar", Seconds = secs, Extra = extra, Done = () => Dead(foe),
|
|
Setup = () => { reset(); o = Ours(f, "ours", "a", true, "animals"); foe = Animal("/boar.prefab", z(15f), "boar"); } },
|
|
new Scenario { Name = "animal.wolf2", Seconds = secs, Extra = extra, Done = () => Dead(foe) || Dead(o),
|
|
Setup = () => { reset(); o = Ours(f, "ours", "a", true, "animals"); foe = Animal("/wolf2.prefab", z(15f), "wolf"); } },
|
|
// An ally of ours standing on the line of fire: the per-shot faction marks only the target.
|
|
new Scenario { Name = "crossfire.allyOnTheLine", Seconds = secs, Extra = extra, Done = () => Dead(foe),
|
|
Setup = () => { reset(); o = Ours(f, "ours", "a", true, "scientists"); ally = Ours(z(8f), "ally", "a", false); foe = Stock(z(16f), "stock"); } },
|
|
};
|
|
string only = arg.GetString(1, "");
|
|
RunScenarios("npcfight", f, only == "" ? list : list.Where(s => s.Name.Contains(only)).ToList());
|
|
}
|
|
|
|
// ---- 5.2 what our sensing costs ----
|
|
|
|
[ConsoleCommand("rnh.sense")]
|
|
private void CmdSense(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
Vector3 field;
|
|
if (!FindField(400f, 606, out field)) { Log("sense", "FAIL no field found"); return; }
|
|
float window = arg.GetFloat(0, 30f);
|
|
var phases = new List<Dictionary<string, object>>();
|
|
var all = new List<BaseEntity>();
|
|
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, 30f + (i / 10) * 3f));
|
|
Action killAll = () => { foreach (BaseEntity e in all) if (e != null && !e.IsDestroyed) e.Kill(); all.Clear(); _invulnerable.Clear(); };
|
|
|
|
Func<int, string, bool, string[], RnhNpc> spawn = (i, tag, foeSide, hostile) =>
|
|
{
|
|
SpawnReport r = SpawnOurs(foeSide ? foeGrid(i) : grid(i), "RnhSense" + tag + i);
|
|
if (r.Npc == null) return null;
|
|
Tag(r.Npc, tag);
|
|
r.Npc.RnhShotFaction = true;
|
|
r.Npc.RnhHostile = new HashSet<string>(hostile);
|
|
all.Add(r.Npc); _labels[r.Npc] = "ours." + tag; _invulnerable.Add(r.Npc);
|
|
return r.Npc;
|
|
};
|
|
|
|
var queue = new Queue<KeyValuePair<string, Action>>();
|
|
queue.Enqueue(new KeyValuePair<string, Action>("baseline", () => { }));
|
|
queue.Enqueue(new KeyValuePair<string, Action>("ours100.hostileToScientists.noneNear", () =>
|
|
{
|
|
for (int i = 0; i < 100; i++) spawn(i, "a", false, new[] { "scientists" });
|
|
}));
|
|
queue.Enqueue(new KeyValuePair<string, Action>("ours100.v.scientists100", () =>
|
|
{
|
|
for (int i = 0; i < 100; i++)
|
|
{
|
|
ScientistNPC s = SpawnStock(foeGrid(i));
|
|
if (s == null) continue;
|
|
all.Add(s); _labels[s] = "stock"; _invulnerable.Add(s);
|
|
}
|
|
}));
|
|
queue.Enqueue(new KeyValuePair<string, Action>("ours50.v.ours50", () =>
|
|
{
|
|
killAll();
|
|
for (int i = 0; i < 50; i++) spawn(i, "a", false, new[] { "ours:b" });
|
|
for (int i = 0; i < 50; i++) spawn(i, "b", true, new[] { "ours:a" });
|
|
}));
|
|
queue.Enqueue(new KeyValuePair<string, Action>("after.clear", killAll));
|
|
|
|
Action next = null;
|
|
next = () =>
|
|
{
|
|
if (queue.Count == 0)
|
|
{
|
|
Record("sense." + Framework(), new Dictionary<string, object>
|
|
{
|
|
["framework"] = Framework(), ["map"] = $"{World.Size}/{World.Seed}", ["players"] = BasePlayer.activePlayerList.Count,
|
|
["aiBudgetMs"] = AIThinkManager.framebudgetms, ["windowS"] = window, ["senseEveryS"] = RnhBrain.OwnSenseEvery,
|
|
["senseRangeM"] = RnhBrain.OwnSenseRange, ["phases"] = phases,
|
|
});
|
|
_labels.Clear();
|
|
Log("sense", "DONE");
|
|
return;
|
|
}
|
|
KeyValuePair<string, Action> step = queue.Dequeue();
|
|
Stopwatch sw = Stopwatch.StartNew();
|
|
step.Value();
|
|
double setupMs = sw.Elapsed.TotalMilliseconds;
|
|
// Five seconds to settle (spawn, first senses), then measure.
|
|
timer.Once(5f, () =>
|
|
{
|
|
RnhBrain.ThinkMs = 0; RnhBrain.Thinks = 0;
|
|
RnhBrain.OwnMs = RnhBrain.SenseMs = 0; RnhBrain.OwnThinks = RnhBrain.SenseRuns = RnhBrain.Scanned = RnhBrain.LosTests = RnhBrain.Engaged = 0;
|
|
_pairs.Clear();
|
|
foreach (BaseEntity e in all) if (e is RnhNpc o && o.Brain is RnhBrain rb) { rb.IntervalSum = 0; rb.IntervalCount = 0; }
|
|
_meter.Begin();
|
|
timer.Once(window, () =>
|
|
{
|
|
Dictionary<string, object> f = Summarise(_meter.End());
|
|
float isum = 0f; int icount = 0;
|
|
foreach (BaseEntity e in all) if (e is RnhNpc o && o.Brain is RnhBrain rb) { isum += rb.IntervalSum; icount += rb.IntervalCount; }
|
|
f["phase"] = step.Key;
|
|
f["setupMs"] = Math.Round(setupMs, 1);
|
|
f["ours"] = all.Count(e => e is RnhNpc && !Dead(e));
|
|
f["stock"] = all.Count(e => e is ScientistNPC && !(e is RnhNpc) && !Dead(e));
|
|
f["msPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.ThinkMs / RnhBrain.Thinks, 3) : 0;
|
|
f["ownMsPerThink"] = RnhBrain.Thinks > 0 ? Math.Round(RnhBrain.OwnMs / RnhBrain.Thinks, 3) : 0;
|
|
f["senseRuns"] = RnhBrain.SenseRuns;
|
|
f["msPerSense"] = RnhBrain.SenseRuns > 0 ? Math.Round(RnhBrain.SenseMs / RnhBrain.SenseRuns, 4) : 0;
|
|
f["scannedPerSense"] = RnhBrain.SenseRuns > 0 ? Math.Round((double)RnhBrain.Scanned / RnhBrain.SenseRuns, 1) : 0;
|
|
f["losTests"] = RnhBrain.LosTests;
|
|
f["engagedThinks"] = RnhBrain.Engaged;
|
|
f["thinkIntervalS"] = icount > 0 ? Math.Round(isum / icount, 3) : 0;
|
|
f["aiQueue"] = AIThinkManager._processQueue.Count;
|
|
f["leftHorror"] = all.Count(e => e is BaseCombatEntity c && !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror);
|
|
f["pairs"] = Pairs();
|
|
phases.Add(f);
|
|
Log("sense", JsonConvert.SerializeObject(f));
|
|
next();
|
|
});
|
|
});
|
|
};
|
|
Log("sense", $"field {V(field)}, {window:0} s per phase, {queue.Count} phases; players online {BasePlayer.activePlayerList.Count}");
|
|
next();
|
|
}
|
|
|
|
// ---- 5.3 turrets and traps (D259) ----
|
|
|
|
private BaseCombatEntity Turret(string kind, Vector3 at, Quaternion rot, List<string> notes)
|
|
{
|
|
string suffix = kind == "auto" ? "/autoturret_deployed.prefab" : kind == "flame" ? "/flameturret.deployed.prefab" : "/guntrap.deployed.prefab";
|
|
string path = FindPrefab(suffix);
|
|
if (path == null) throw new Exception("no prefab ends with " + suffix);
|
|
BaseCombatEntity t = GameManager.server.CreateEntity(path, at, rot) as BaseCombatEntity;
|
|
if (t == null) throw new Exception(path + " is not a combat entity");
|
|
t.enableSaving = false;
|
|
t.Spawn();
|
|
Label(t, "turret." + kind);
|
|
|
|
AutoTurret auto = t as AutoTurret;
|
|
if (auto != null)
|
|
{
|
|
ItemManager.CreateByName("rifle.ak").MoveToContainer(auto.inventory, 0);
|
|
ItemManager.CreateByName("ammo.rifle", 256).MoveToContainer(auto.inventory, 1);
|
|
auto.UpdateAttachedWeapon();
|
|
auto.UpdateTotalAmmo();
|
|
auto.UpdateFromInput(10, 0);
|
|
timer.Once(4f, () =>
|
|
{
|
|
if (auto.IsDestroyed) return;
|
|
notes.Add($"auto: powered={auto.IsPowered()} online={auto.IsOnline()} weapon={auto.GetAttachedWeapon()?.ShortPrefabName ?? "none"}");
|
|
if (!auto.IsOnline()) { auto.SetIsOnline(true); notes.Add("auto: forced online"); }
|
|
});
|
|
}
|
|
else if (t is FlameTurret flame)
|
|
ItemManager.CreateByName("lowgradefuel", 100).MoveToContainer(flame.inventory);
|
|
else if (t is GunTrap trap)
|
|
ItemManager.CreateByName("ammo.handmade.shell", 20).MoveToContainer(trap.inventory);
|
|
return t;
|
|
}
|
|
|
|
[ConsoleCommand("rnh.turrets")]
|
|
private void CmdTurrets(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
Vector3 f;
|
|
if (!FindField(250f, 808, out f)) { Log("turrets", "FAIL no field found"); return; }
|
|
float secs = arg.GetFloat(0, 30f);
|
|
var list = new List<Scenario>();
|
|
|
|
foreach (string kind in new[] { "auto", "flame", "trap" })
|
|
{
|
|
foreach (string victim in new[] { "standIn", "stock", "ours" })
|
|
{
|
|
string k = kind, v = victim;
|
|
BaseCombatEntity turret = null;
|
|
BaseEntity target = null;
|
|
var notes = new List<string>();
|
|
list.Add(new Scenario
|
|
{
|
|
Name = k + "." + v,
|
|
Seconds = secs,
|
|
Done = () => Dead(target) || Dead(turret),
|
|
Setup = () =>
|
|
{
|
|
notes.Clear();
|
|
float d = k == "auto" ? 10f : 3f;
|
|
Vector3 spot = Ground(f + new Vector3(0f, 0f, d));
|
|
// Spawned outside the turret's trigger and moved in: a flame turret's trigger only
|
|
// notices what ENTERS it (a target spawned inside, standing still, was never burned).
|
|
Vector3 outside = Ground(f + new Vector3(0f, 0f, d + 12f));
|
|
turret = Turret(k, Ground(f), Quaternion.LookRotation(new Vector3(0f, 0f, 1f)), notes);
|
|
if (v == "stock")
|
|
target = Stock(outside, "stock");
|
|
else if (v == "standIn")
|
|
{
|
|
// The control: a stand-in player shows the turret is armed. It takes no damage.
|
|
target = Label(SpawnDummy(outside, 40), "standIn");
|
|
_invulnerable.Add(target);
|
|
}
|
|
else
|
|
{
|
|
RnhNpc o = Ours(outside, "ours", "a", false);
|
|
o.RnhShootsBack = true;
|
|
target = o;
|
|
}
|
|
BaseEntity moving = target;
|
|
timer.Once(2f, () =>
|
|
{
|
|
if (Dead(moving)) return;
|
|
BasePlayer p = moving as BasePlayer;
|
|
if (p is HumanNPC h && h.Brain?.Navigator?.Agent != null)
|
|
h.Brain.Navigator.Agent.Warp(spot);
|
|
p?.Teleport(spot);
|
|
notes.Add($"moved in: at {V(moving.transform.position)}, spot {V(spot)}");
|
|
});
|
|
},
|
|
Extra = () => new Dictionary<string, object>
|
|
{
|
|
["turret"] = turret == null ? null : new Dictionary<string, object>
|
|
{
|
|
["type"] = turret.GetType().Name, ["dead"] = Dead(turret),
|
|
["health"] = turret.IsDestroyed ? 0 : Math.Round(turret.health, 0),
|
|
},
|
|
["target"] = target is RnhNpc o ? Stats(o) : new Dictionary<string, object>
|
|
{
|
|
["dead"] = Dead(target), ["health"] = target == null || target.IsDestroyed ? 0 : Math.Round(((BaseCombatEntity)target).health, 0),
|
|
},
|
|
["notes"] = new List<string>(notes),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
string only = arg.GetString(1, "");
|
|
RunScenarios("turrets", f, only == "" ? list : list.Where(s => s.Name.Contains(only)).ToList());
|
|
}
|
|
|
|
// ---- 5.4 the kit items Rust's AI uses on its own (D260) ----
|
|
|
|
private static Dictionary<string, object> Inventory(BasePlayer p)
|
|
{
|
|
var counts = new Dictionary<string, object>();
|
|
if (p == null || p.IsDestroyed || p.inventory == null) return counts;
|
|
foreach (ItemContainer c in new[] { p.inventory.containerBelt, p.inventory.containerMain })
|
|
{
|
|
if (c == null) continue;
|
|
foreach (Item i in c.itemList)
|
|
{
|
|
object n;
|
|
counts[i.info.shortname] = (counts.TryGetValue(i.info.shortname, out n) ? (int)n : 0) + i.amount;
|
|
}
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
private static void Give(BasePlayer p, string shortname, int amount, ItemContainer into, int slot = -1)
|
|
{
|
|
Item item = ItemManager.CreateByName(shortname, amount);
|
|
if (item == null) return;
|
|
if (!item.MoveToContainer(into, slot)) item.Remove();
|
|
}
|
|
|
|
[ConsoleCommand("rnh.items")]
|
|
private void CmdItems(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
Vector3 f;
|
|
if (!FindField(250f, 909, out f)) { Log("items", "FAIL no field found"); return; }
|
|
float secs = arg.GetFloat(0, 60f);
|
|
Vector3 z(float m) => f + new Vector3(0f, 0f, m);
|
|
|
|
RnhNpc o = null;
|
|
BasePlayer standIn = null;
|
|
Dictionary<string, object> before = null;
|
|
float lastHealth = 0f;
|
|
int heals = 0, scripted = 0;
|
|
float minHealth = float.MaxValue;
|
|
Timer pulse = null;
|
|
|
|
Action<float, int> addStandIn = (dist, index) =>
|
|
{
|
|
standIn = Label(SpawnDummy(Ground(z(dist)), index), "standIn");
|
|
_invulnerable.Add(standIn);
|
|
timer.Once(1f, () =>
|
|
{
|
|
if (o?.Brain?.Senses?.Memory != null && standIn != null && !standIn.IsDestroyed)
|
|
o.Brain.Senses.Memory.SetKnown(standIn, o, null);
|
|
});
|
|
};
|
|
Action<Action<RnhNpc>> spawn = kit =>
|
|
{
|
|
o = Ours(f, "ours", "a", false);
|
|
kit(o);
|
|
o.EquipWeapon();
|
|
before = Inventory(o);
|
|
lastHealth = o.health; minHealth = o.health; heals = 0; scripted = 0;
|
|
pulse?.Destroy();
|
|
pulse = timer.Every(1f, () =>
|
|
{
|
|
if (Dead(o)) return;
|
|
if (o.health > lastHealth + 5f) heals++;
|
|
lastHealth = o.health;
|
|
minHealth = Mathf.Min(minHealth, o.health);
|
|
});
|
|
};
|
|
Func<Dictionary<string, object>> extra = () =>
|
|
{
|
|
pulse?.Destroy();
|
|
return new Dictionary<string, object>
|
|
{
|
|
["ours"] = Stats(o),
|
|
["before"] = before,
|
|
["after"] = Inventory(o),
|
|
["healthRises"] = heals,
|
|
["minHealth"] = Math.Round(minHealth, 0),
|
|
["scriptedHurts"] = scripted,
|
|
["canUseHealingItems"] = o?.Brain != null && o.Brain.CanUseHealingItems,
|
|
};
|
|
};
|
|
|
|
var list = new List<Scenario>
|
|
{
|
|
// The kit's revolver plus every extra D260 names; the stand-in "shoots" ours every 4 s.
|
|
new Scenario { Name = "extras.healingOn", Seconds = secs, Extra = extra,
|
|
Setup = () =>
|
|
{
|
|
spawn(n =>
|
|
{
|
|
Give(n, "syringe.medical", 3, n.inventory.containerBelt, 1);
|
|
Give(n, "grenade.f1", 2, n.inventory.containerBelt, 2);
|
|
Give(n, "machete", 1, n.inventory.containerBelt, 3);
|
|
n.Brain.CanUseHealingItems = true;
|
|
});
|
|
addStandIn(15f, 31);
|
|
timer.Repeat(4f, (int)(secs / 4f), () =>
|
|
{
|
|
if (Dead(o) || Dead(standIn) || o.health < 45f) return;
|
|
AttackEntity gun = standIn.GetActiveItem()?.GetHeldEntity() as AttackEntity;
|
|
o.Hurt(new HitInfo(standIn, o, Rust.DamageType.Bullet, 20f) { Weapon = gun, WeaponPrefab = gun });
|
|
scripted++;
|
|
});
|
|
} },
|
|
new Scenario { Name = "main.rocketLauncher", Seconds = Mathf.Min(secs, 40f), Extra = extra,
|
|
Setup = () =>
|
|
{
|
|
spawn(n =>
|
|
{
|
|
n.inventory.Strip();
|
|
Give(n, "rocket.launcher", 1, n.inventory.containerBelt, 0);
|
|
Give(n, "ammo.rocket.basic", 6, n.inventory.containerMain);
|
|
});
|
|
addStandIn(25f, 32);
|
|
} },
|
|
new Scenario { Name = "main.flamethrower", Seconds = Mathf.Min(secs, 40f), Extra = extra,
|
|
Setup = () =>
|
|
{
|
|
spawn(n =>
|
|
{
|
|
n.inventory.Strip();
|
|
Give(n, "flamethrower", 1, n.inventory.containerBelt, 0);
|
|
Give(n, "lowgradefuel", 300, n.inventory.containerMain);
|
|
});
|
|
addStandIn(6f, 33);
|
|
} },
|
|
};
|
|
RunScenarios("items", f, list);
|
|
}
|
|
|
|
// ---- 5.5 TruePVE and NextGenPVE (D261) ----
|
|
|
|
[ConsoleCommand("rnh.pve")]
|
|
private void CmdPve(ConsoleSystem.Arg arg)
|
|
{
|
|
if (!arg.IsAdmin) return;
|
|
Vector3 f;
|
|
if (!FindField(250f, 1001, out f)) { Log("pve", "FAIL no field found"); return; }
|
|
_labels.Clear(); _pairs.Clear(); _invulnerable.Clear();
|
|
|
|
Plugin pve = plugins.Find("TruePVE") ?? plugins.Find("NextGenPVE");
|
|
string which = pve == null ? "none" : $"{pve.Name} {pve.Version}";
|
|
|
|
RnhNpc ours = Ours(f, "ours", "a", false);
|
|
ScientistNPC stock = Stock(f + new Vector3(4f, 0f, 0f), "stock");
|
|
Func<ulong, Vector3, string, BasePlayer> pvePlayer = (id, at, label) =>
|
|
{
|
|
BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, Ground(at)) as BasePlayer;
|
|
if (p == null) return null;
|
|
p.userID = id;
|
|
p.UserIDString = id.ToString();
|
|
p.displayName = "RnhPve" + label;
|
|
p.enableSaving = false;
|
|
p.Spawn();
|
|
p.EndSleeping();
|
|
LifeStoryStartMethod?.Invoke(p, null);
|
|
Label(p, label);
|
|
Equip(p);
|
|
return p;
|
|
};
|
|
BasePlayer player = pvePlayer(PveUserId, f + new Vector3(-4f, 0f, 0f), "player");
|
|
BasePlayer player2 = pvePlayer(PveUserId + 1UL, f + new Vector3(-4f, 0f, 4f), "player2");
|
|
if (player == null || player2 == null) { Log("pve", "FAIL no player"); return; }
|
|
|
|
timer.Once(3f, () =>
|
|
{
|
|
var pairs = new List<KeyValuePair<BaseCombatEntity, BaseCombatEntity>>
|
|
{
|
|
// The control: player against player is what a PVE plugin exists to stop.
|
|
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(player2, player),
|
|
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(player, ours),
|
|
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(ours, player),
|
|
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(player, stock),
|
|
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(stock, player),
|
|
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(ours, stock),
|
|
new KeyValuePair<BaseCombatEntity, BaseCombatEntity>(stock, ours),
|
|
};
|
|
var rows = new List<Dictionary<string, object>>();
|
|
foreach (int mode in new[] { 0, 1, 2 })
|
|
{
|
|
_pveMode = mode;
|
|
foreach (KeyValuePair<BaseCombatEntity, BaseCombatEntity> pr in pairs)
|
|
{
|
|
BaseCombatEntity a = pr.Key, v = pr.Value;
|
|
if (Dead(a) || Dead(v)) { rows.Add(new Dictionary<string, object> { ["pair"] = _labels[a] + ">" + _labels[v], ["error"] = "dead" }); continue; }
|
|
v.health = v.MaxHealth();
|
|
AttackEntity gun = (a as BasePlayer)?.GetActiveItem()?.GetHeldEntity() as AttackEntity;
|
|
int asks0 = _pveAsks;
|
|
float h0 = v.health;
|
|
v.Hurt(new HitInfo(a, v, Rust.DamageType.Bullet, 10f) { Weapon = gun, WeaponPrefab = gun });
|
|
rows.Add(new Dictionary<string, object>
|
|
{
|
|
["mode"] = mode == 0 ? "observe" : mode == 1 ? "allow" : "deny",
|
|
["pair"] = _labels[a] + ">" + _labels[v],
|
|
["damage"] = Math.Round(h0 - v.health, 2),
|
|
["asked"] = _pveAsks - asks0,
|
|
});
|
|
}
|
|
}
|
|
_pveMode = 0;
|
|
var rec = new Dictionary<string, object> { ["framework"] = Framework(), ["plugin"] = which, ["rows"] = rows };
|
|
Record("pve." + (pve == null ? "none" : pve.Name) + "." + Framework(), rec);
|
|
Log("pve", JsonConvert.SerializeObject(rec));
|
|
foreach (BaseEntity e in _labels.Keys.ToArray()) if (e != null && !e.IsDestroyed) e.Kill();
|
|
_labels.Clear();
|
|
ClearLeftovers(f, 40f);
|
|
});
|
|
}
|
|
|
|
// ---- 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");
|
|
}
|
|
}
|
|
}
|