From 18c90961ba982d989b3092aebec86895e0bff0e8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 29 Sep 2026 23:04:46 -0500 Subject: [PATCH 01/24] chore(tools): the stage 1 harness, RunicNpcHarness.cs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 2 +- tools/RunicNpcHarness.cs | 1101 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 1102 insertions(+), 1 deletion(-) create mode 100644 tools/RunicNpcHarness.cs diff --git a/README.md b/README.md index 6b505de..1e522c3 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ changes shape, not on every release. | `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. | | `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). | -| `tools/` | Developer scaffolding for the test rigs, never shipped (see `tools/rigs.example.json`). | +| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`) and `RunicNpcHarness.cs`, the stage 1 measurement plugin (`docs/runicnpc/PLAN.md` §9). | ## Releases diff --git a/tools/RunicNpcHarness.cs b/tools/RunicNpcHarness.cs new file mode 100644 index 0000000..8066790 --- /dev/null +++ b/tools/RunicNpcHarness.cs @@ -0,0 +1,1101 @@ +// 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 +{ + /// + /// 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: + /// + /// + /// the subclass swap spawns, walks, fights and dies cleanly (rnh.swap); + /// enableSaving = false survives a hard kill and restart (rnh.save plant|check); + /// which ranges and scales hold on Rust's brain as it is (rnh.tune); + /// navmesh coverage at every monument and on a roof (rnh.nav); + /// what 1, 10 and 100 cost, idle and fighting (rnh.cost); + /// the name on the death screen, and what the bridge publishes (rnh.dummy). + /// + /// + /// Every result is a line starting [rnh] in the console and a record in the data file + /// RunicNpcHarness.json, read back over the panel. Nothing here is RunicNPC's design: + /// it is the measurement that design waits for. + /// + /// + /// It needs a hidden Kits kit named rnhrevolver (a revolver, pistol ammo and burlap): + /// add it to the rig's Kits/kits_data.json and reload Kits before loading this, and + /// restore the file afterwards. ready in the console says whether it was found. + /// + /// + /// + /// Its NPC is swapped the way stage 2's will be: the prefab's ScientistNPC and + /// ScientistBrain 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. + /// + /// + [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"; + + /// The fake player's user id: outside the Steam range, over Rust's bot range (10,000,000). + private const ulong DummyUserId = 11400000001UL; + + private static RunicNpcHarness _self; + private readonly Dictionary _results = new Dictionary(); + private readonly HashSet _spawned = new HashSet(); + private GameObject _meterHost; + private FrameMeter _meter; + + // ---- counters the hooks feed ---- + + private sealed class HitTally + { + public int Hits; + public float Damage; + public readonly Dictionary Weapons = new Dictionary(); + } + + private readonly HitTally _hitsByOurs = new HitTally(); + private readonly HitTally _hitsOnOurs = new HitTally(); + private readonly HitTally _hitsOnStandIns = new HitTally(); + + /// While set, stand-in players take no damage: the cost run's targets never die. + 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 _deaths = new List(); + private readonly List _corpses = new List(); + + // ---- lifecycle ---- + + private void Init() => _self = this; + + private void OnServerInitialized() + { + _meterHost = new GameObject("RunicNpcHarness.FrameMeter"); + _meter = _meterHost.AddComponent(); + 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 ---- + + /// + /// Copies the fields Unity itself would copy between two instances of a component: every + /// instance field of 's type chain down to + /// 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. + /// + 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; + } + + /// Our NPC: the prefab's scientist, its components swapped before it spawns. + private SpawnReport SpawnOurs(Vector3 at, string name, Action 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(); + GameObject go = stock.gameObject; + + RnhNpc npc = go.AddComponent(); + RnhBrain brain = go.AddComponent(); + 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; + } + + /// + /// 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. + /// + private static void FightsScientists(RnhBrain b) + { + b.SenseTypes |= EntityType.BasePlayerNPC; + b.HostileTargetsOnly = false; + b.CheckLOS = true; + RnhNpc npc = b.GetComponent(); + 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; + + /// The scientist subclass. Only what stage 1 has to prove it can override. + public class RnhNpc : ScientistNPC, IAISenses, IAIAttack + { + public int AttackTicks, AttackTicksLos; + public string LastAttackTarget = "-"; + + /// Counts what Rust's AttackTick event hands us: the last step before a shot. + 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"; + + /// + /// 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. + /// + 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(); + + /// + /// 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. + /// + public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info) + { + base.AttackerInfo(info); + info.attackerName = displayName; + } + } + + /// + /// 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). + /// + 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 _frames = new List(20000); + public bool Recording; + + public void Begin() + { + _frames.Clear(); + Recording = true; + } + + public List End() + { + Recording = false; + return new List(_frames); + } + + private void Update() + { + if (Recording) + _frames.Add(Time.unscaledDeltaTime * 1000f); + } + } + + private static Dictionary Summarise(List ms) + { + if (ms.Count == 0) + return new Dictionary { ["frames"] = 0 }; + ms.Sort(); + Func pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)]; + double mean = ms.Average(); + return new Dictionary + { + ["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 ---- + + /// + /// A flat, dry spot on the navmesh at least m from every + /// monument, found by sampling the map from a fixed seed so a rerun lands in the same field. + /// + private bool FindField(float clear, int seed, out Vector3 at) + { + var rng = new System.Random(seed); + float half = World.Size / 2f - 300f; + List 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; + } + + /// + /// Rust's own navmesh, the one a scientist walks: its navigator is a Gen2 + /// RustNavMeshAgent, 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). + /// + 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); + + /// A brain's insides: what it takes for a scientist to have a state at all. + 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().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 + { + ["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(); + 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(_hitsByOurs.Weapons); + rec["hitsOnOurs"] = _hitsOnOurs.Hits; + rec["killedByHarness"] = alive; + rec["deaths"] = new List(_deaths); + rec["corpses"] = new List(_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(); + 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 = Interface.Oxide.DataFileSystem.ReadObject>(Name + "_planted") ?? new List(); + int backUnsaved = 0, backSaved = 0; + var found = new List(); + 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 + { + ["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 ---- + + /// + /// Which of a profile's numbers Rust's own brain honours. Each is set either BEFORE the brain + /// starts (the values Senses.Init copies) or AFTER it is running, and then observed. + /// + [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(); + + // 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 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(); + }); + } + + /// Roaming at a monument (inside an AI zone) vs in a field (outside one), 60 s each. + [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() != 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(); + 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 + { + ["field.maxMovedM"] = Math.Round(fMax, 1), + ["zone"] = zone != null ? zone.GetComponentInParent()?.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(); + 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 + { + ["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 + { + ["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 row in rows.Cast>()) + 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>(); + var ours = new List(); + var stock = new List(); + var standIns = new List(); + int stockInWorld = BaseNetworkable.serverEntities.OfType().Count(); + + Func grid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f)); + Func foeGrid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, 45f + (i / 10) * 3f)); + + var queue = new Queue>(); + + // 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 + { + ["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 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 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> 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("baseline", () => { })); + queue.Enqueue(new KeyValuePair("spawn1", () => spawnTo(1, null))); + queue.Enqueue(new KeyValuePair("spawn10", () => spawnTo(10, null))); + queue.Enqueue(new KeyValuePair("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("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("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("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); + + /// + /// A stand-in player: player.prefab with a made-up user id, so IsNpc 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. + /// + 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(); + + 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(_deaths); + rec["corpses"] = new List(_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().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().ToArray()) { o.Kill(); loose++; } + Log("clear", $"killed {n} tracked and {loose} untracked harness NPCs"); + } + } +} -- 2.49.1 From f8084955e8f63c9d2d891eba28cc61a12c57c7d7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 01:31:04 -0500 Subject: [PATCH 02/24] feat: stage 2, the NPC and its API (API 2) RunicNPC now spawns its own NPC: Rust's scientist with its ScientistNPC and ScientistBrain swapped for ours, copying a fixed field list generated from Rust's unmodified assembly (D232, tools/fieldlist). Profiles live in data/RunicNPC/profiles.json in the approved shape (D238); placements persist with each/group respawn (D236) and wait for a missing profile or route (D237); roamers wander, follow Rust's monument paths, or walk a route (D233, D234), with our own chase where Rust's needs an AI zone; sentries hold their spot; NPCs walk home and sleep past 160 m of any player (D235). Spawns wait for the navmesh and are spread over frames; caps are off by default and the cost warning is shown instead (D227). The whole PLAN.md section 4 API and its hooks are in, documented in docs/runicnpc/API.md. tools/RunicNpcTest.cs is the stage 2 harness; every group passed on both rigs, and after a plugin reload and a server restart. checkPlugin no longer counts an override (RunicNpcPlayer.OnDied) as a hook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 29 +- plugin.toml | 5 +- plugin/RunicNPC.cs | 1950 +++++++++++++++++++++++++++++- scripts/checkPlugin.js | 6 +- scripts/checkPlugin.test.js | 22 +- tools/RunicNpcTest.cs | 802 ++++++++++++ tools/fieldlist/FieldList.csproj | 18 + tools/fieldlist/Program.cs | 98 ++ tools/managed.js | 49 + 9 files changed, 2937 insertions(+), 42 deletions(-) create mode 100644 tools/RunicNpcTest.cs create mode 100644 tools/fieldlist/FieldList.csproj create mode 100644 tools/fieldlist/Program.cs create mode 100644 tools/managed.js diff --git a/README.md b/README.md index 1e522c3..f03544c 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicG website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is [`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). -> **Status: stage 0.** The plugin loads, answers its API version, and reports on itself. It spawns -> nothing yet. Stage 1 is a measuring spike; the NPC and its API arrive in stage 2. +> **Status: stage 2.** The NPC, its profiles, placements and routes, and the API other plugins call. +> The in-game `/rnpc` commands arrive in stage 3; until then profiles are edited in +> `data/RunicNPC/profiles.json` (then `rnpc.reload`), and placements and routes are made through the API. ## Requirements @@ -53,9 +54,10 @@ Every call is prefixed `RunicNpc_` and reached through `Call`: int api = RunicNPC?.Call("RunicNpc_ApiVersion") ?? 0; ``` -Stage 0 has only `RunicNpc_ApiVersion()`. The full API is planned in PLAN.md §4 and will be -documented as `docs/runicnpc/API.md` in stage 2. The API version moves when a call or a raised hook -changes shape, not on every release. +The API is version 2, documented in +[`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md): +spawning and removing NPCs by owner, profiles, placements, routes, the cost warning, and the hooks it raises. +The API version moves when a call or a raised hook changes shape, not on every release. ## Repository layout @@ -64,7 +66,22 @@ changes shape, not on every release. | `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. | | `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). | -| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`) and `RunicNpcHarness.cs`, the stage 1 measurement plugin (`docs/runicnpc/PLAN.md` §9). | +| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | + +## After a Rust update: the swap's field list + +Our NPC is Rust's scientist with two components swapped, and the swap copies a fixed list of fields (D232), +generated from Rust's unmodified assembly. `rnpc.status` reports it as `swap fields: npc=64/64 brain=32/32 +missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since +the list was made. To regenerate it: + +```bash +node tools/managed.js carbon # the Carbon rig's RustDedicated_Data/Managed +dotnet run --project tools/fieldlist -- # rewrites the block in plugin/RunicNPC.cs +``` + +Use the Carbon rig: Oxide's patcher makes Rust's private fields public, so its assembly no longer says which +fields Rust itself serialises. ## Releases diff --git a/plugin.toml b/plugin.toml index 7d3515f..062206a 100644 --- a/plugin.toml +++ b/plugin.toml @@ -22,8 +22,9 @@ # a RunicNPC too old for the bridge it is pairing with. Declaring it here is what # lets a bundle check the pair BEFORE an operator installs it. # -# Current: 1 — `RunicNpc_ApiVersion()` and nothing else (stage 0). -api = 1 +# 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else. +# Current: 2 — stage 2's whole API, PLAN.md §4, documented in docs/runicnpc/API.md. +api = 2 # ── Framework floors ───────────────────────────────────────────────────────── # diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index edfd207..201f7c5 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -2,6 +2,17 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Oxide.Core; +using Oxide.Core.Plugins; +using UnityEngine; +using UnityEngine.AI; namespace Oxide.Plugins { @@ -9,9 +20,10 @@ namespace Oxide.Plugins /// RunicNPC — Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon. /// /// - /// This is the stage 0 plugin (docs/runicnpc/PLAN.md §9): it loads, answers its API version, - /// and reports on itself. It spawns nothing. Everything it will do is planned in that - /// document, stage by stage. + /// This is the stage 2 plugin (docs/runicnpc/PLAN.md §9): the NPC, its profiles, placements + /// and owners, and the API other plugins call. The in-game commands are stage 3's; until then + /// profiles live in data/RunicNPC/profiles.json, and placements and routes are made + /// through the API. /// /// /// @@ -20,11 +32,21 @@ namespace Oxide.Plugins /// and requires_plugins in plugin.toml are two statements of one fact, and the /// PR check holds them equal. /// + /// + /// + /// Our NPC is Rust's scientist with two components swapped (D223): the prefab's + /// ScientistNPC and ScientistBrain are replaced, before the entity spawns, by + /// and , carrying the prefab's + /// authored fields across. That swap and the brain's states are what a Rust update can + /// break, so they are kept in one place: the "the swap" and "the NPC" regions below. + /// /// [Info("RunicNPC", "Runic Gateway", "0.0.0")] [Description("Runic Gateway's NPCs: profiles, placements and an API for other plugins.")] internal class RunicNPC : RustPlugin { + [PluginReference] private Plugin Kits; + /// /// The version of the API other plugins call (RunicNpc_*, PLAN.md §4). Declared /// twice, here and as api in plugin.toml, which the release copies into @@ -34,28 +56,1631 @@ namespace Oxide.Plugins /// /// It moves when a call or a raised hook changes shape, not on every release: the /// release version says what was built, this says what a caller can rely on. + /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// - private const int ApiVersion = 1; + private const int ApiVersion = 2; /// - /// Every hook this plugin declares. Both frameworks bind a hook by name and arity - /// through reflection, and neither says a word when a name matches nothing, so the - /// plugin counts its own: rnpc.status lists the ones that have never fired. The - /// list is seeded at zero, because a hook that never fired is exactly the one a - /// dictionary that learns names as they arrive could never report. + /// Every hook this plugin implements. rnpc.status reports which have fired, because + /// a hook that matches nothing is silent on both frameworks: this list is how a renamed + /// one is noticed. The PR check holds it equal to the methods below. /// private static readonly string[] ExpectedHooks = { - "OnServerInitialized" + "OnServerInitialized", + "OnServerSave", + "OnEntityKill", + "OnPluginUnloaded" }; private readonly Dictionary _hookCounts = new Dictionary(); - // ---- lifecycle ---- + /// The loaded plugin, for the NPC and brain classes, which Rust instantiates. + private static RunicNPC _self; + + private bool _ready; + private bool _unloading; + + private static readonly Regex NameRule = new Regex("^[a-z0-9_-]{1,40}$"); + private static readonly Regex OwnerRule = new Regex(@"^(run|plugin):\S{1,64}$"); + + private const string DataProfiles = "RunicNPC/profiles"; + private const string DataPlacements = "RunicNPC/placements"; + private const string DataRoutes = "RunicNPC/routes"; + + /// How far from a placement's spot its NPCs after the first are scattered. + private const float Scatter = 3f; + + /// How far above or below a roamer's spot the navmesh it is put on may be. + private const float MaxDrop = 2f; + + #region Config + + private PluginConfig _config; + + /// + /// The plugin's config. No caps by default (D227): each is 0, meaning none, until an + /// admin sets it. The cost warning is shown instead, wherever NPCs are added. + /// + private sealed class PluginConfig + { + [JsonProperty("caps")] public CapsConfig Caps = new CapsConfig(); + + /// + /// Milliseconds per server frame the placement queue may spend spawning. Stage 1 + /// measured about 5 ms per NPC, and 100 in one frame as a half-second hitch. + /// + [JsonProperty("spawnBudgetMs")] public float SpawnBudgetMs = 8f; + } + + private sealed class CapsConfig + { + [JsonProperty("total")] public int Total; + [JsonProperty("perOwner")] public int PerOwner; + [JsonProperty("perProfile")] public int PerProfile; + [JsonProperty("spawnsPerSecond")] public float SpawnsPerSecond; + } + + protected override void LoadDefaultConfig() => _config = new PluginConfig(); + + protected override void LoadConfig() + { + base.LoadConfig(); + try + { + _config = Config.ReadObject(); + } + catch (Exception e) + { + PrintError($"The config could not be read, so the defaults are used: {e.Message}"); + _config = null; + } + + if (_config == null) + _config = new PluginConfig(); + if (_config.Caps == null) + _config.Caps = new CapsConfig(); + SaveConfig(); + } + + protected override void SaveConfig() => Config.WriteObject(_config, true); + + #endregion + + #region Data: profiles, placements, routes + + /// A position in a data file. + public sealed class Pos + { + [JsonProperty("x")] public float X; + [JsonProperty("y")] public float Y; + [JsonProperty("z")] public float Z; + + public Vector3 V => new Vector3(X, Y, Z); + + public static Pos Of(Vector3 v) => new Pos { X = v.x, Y = v.y, Z = v.z }; + } + + /// How a roamer moves (D233): wander, monument or route:<name>. + public sealed class Movement + { + [JsonProperty("mode")] public string Mode = "wander"; + [JsonProperty("radius")] public float Radius = 20f; + } + + public sealed class DamageTaken + { + [JsonProperty("head")] public float Head = 1f; + [JsonProperty("body")] public float Body = 1f; + [JsonProperty("legs")] public float Legs = 1f; + } + + public sealed class Ranges + { + [JsonProperty("sense")] public float Sense = 30f; + [JsonProperty("loseTarget")] public float LoseTarget = 40f; + [JsonProperty("chase")] public float Chase = 40f; + [JsonProperty("attack")] public float Attack = 30f; + } + + /// A named description of an NPC, in the shape the org lead approved (D238). + public sealed class Profile + { + [JsonProperty("names")] public List Names = new List(); + [JsonProperty("kits")] public List Kits = new List(); + [JsonProperty("prefab")] public string Prefab = "scientistnpc_roam"; + [JsonProperty("role")] public string Role = "roamer"; + [JsonProperty("movement")] public Movement Movement = new Movement(); + [JsonProperty("health")] public float Health = 150f; + [JsonProperty("damageDealt")] public float DamageDealt = 1f; + [JsonProperty("damageTaken")] public DamageTaken DamageTaken = new DamageTaken(); + [JsonProperty("aimCone")] public float AimCone = 2f; + [JsonProperty("ranges")] public Ranges Ranges = new Ranges(); + [JsonProperty("visionCone")] public float VisionCone = -0.8f; + [JsonProperty("sleepDistance")] public float SleepDistance = 160f; + [JsonProperty("healthThresholds")] public List HealthThresholds = new List(); + + public bool IsSentry => Role == "sentry"; + } + + private sealed class ProfileFile + { + /// True once a site has pushed profiles (D221); in-game edits are then refused. + [JsonProperty("managed")] public bool Managed; + [JsonProperty("profiles")] public Dictionary Profiles = new Dictionary(); + } + + /// + /// An admin's (or the site's) persistent placement (D222). Only this is saved: its NPCs + /// never are, and are spawned fresh from it at boot (§2). + /// + public sealed class Placement + { + [JsonProperty("profile")] public string Profile; + [JsonProperty("position")] public Pos Position; + [JsonProperty("yaw")] public float Yaw; + [JsonProperty("count")] public int Count = 1; + + /// Seconds from a death to the respawn. + [JsonProperty("respawn")] public float Respawn = 300f; + + /// each (the default) or group (D236). + [JsonProperty("respawnMode")] public string RespawnMode = "each"; + + /// Overrides the profile's movement for this placement only (D233); null keeps it. + [JsonProperty("movement", NullValueHandling = NullValueHandling.Ignore)] public Movement Movement; + } + + private sealed class PlacementFile + { + [JsonProperty("placements")] public Dictionary Placements = new Dictionary(); + } + + /// + /// Points an admin records in game (stage 3) and a route:<name> roamer walks in + /// order (D233, D234). loop goes from the last point back to the first; otherwise the + /// route is walked back and forth. + /// + public sealed class Route + { + [JsonProperty("points")] public List Points = new List(); + [JsonProperty("loop")] public bool Loop = true; + } + + private sealed class RouteFile + { + [JsonProperty("routes")] public Dictionary Routes = new Dictionary(); + } + + private ProfileFile _profileFile = new ProfileFile(); + private PlacementFile _placementFile = new PlacementFile(); + private RouteFile _routeFile = new RouteFile(); + + /// The profiles that passed validation. Only these spawn. + private readonly Dictionary _profiles = new Dictionary(); + + /// The profiles that did not, and why. + private readonly Dictionary _refused = new Dictionary(); + + /// The one flag that marks placements for saving, on every change including removal (§2). + private bool _placementsDirty; + + private T ReadData(string name) where T : class, new() + { + try + { + T value = Interface.Oxide.DataFileSystem.ReadObject(name); + return value ?? new T(); + } + catch (Exception e) + { + PrintError($"data/{name}.json could not be read and is treated as empty: {e.Message}"); + return new T(); + } + } + + private void LoadData() + { + _profileFile = ReadData(DataProfiles); + if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); + _routeFile = ReadData(DataRoutes); + if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary(); + _placementFile = ReadData(DataPlacements); + if (_placementFile.Placements == null) _placementFile.Placements = new Dictionary(); + + // Create the files on first boot: the plugin must make its own data directory, because one + // made from outside the game (a panel's file API) is not writable by it (PLAN.md §1.5). + SaveProfiles(); + SaveRoutes(); + SavePlacements(); + } + + private void SaveProfiles() => Interface.Oxide.DataFileSystem.WriteObject(DataProfiles, _profileFile); + + private void SaveRoutes() => Interface.Oxide.DataFileSystem.WriteObject(DataRoutes, _routeFile); + + private void SavePlacements() + { + Interface.Oxide.DataFileSystem.WriteObject(DataPlacements, _placementFile); + _placementsDirty = false; + } + + /// Validates every profile in the file; only the valid ones are used. + private void RevalidateProfiles() + { + _profiles.Clear(); + _refused.Clear(); + foreach (KeyValuePair kv in _profileFile.Profiles) + { + string error = NameRule.IsMatch(kv.Key) ? ValidateProfile(kv.Value) : "the name must be 1–40 of a-z, 0-9, _ and -"; + if (error == null) + _profiles[kv.Key] = kv.Value; + else + _refused[kv.Key] = error; + } + + foreach (KeyValuePair kv in _refused) + PrintWarning($"Profile '{kv.Key}' is refused: {kv.Value}"); + } + + /// Null when the profile can spawn; otherwise the reason, for the log and the caller. + private string ValidateProfile(Profile p) + { + if (p == null) return "it is empty"; + if (p.Names == null || p.Names.Count == 0 || p.Names.Any(string.IsNullOrWhiteSpace)) + return "names: give at least one, and no blank ones"; + if (p.Kits == null || p.Kits.Count == 0) + return "kits: give at least one; Kits is how an NPC is equipped (D217)"; + foreach (string kit in p.Kits) + if (!KitExists(kit)) + return $"kits: this server has no kit '{kit}'"; + if (ResolvePrefab(p.Prefab) == null) + return $"prefab: '{p.Prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)"; + if (p.Role != "roamer" && p.Role != "sentry") + return $"role: '{p.Role}' is not roamer or sentry"; + string movement = ValidateMovement(p.Movement); + if (movement != null) return movement; + if (!(p.Health > 0f)) return "health: must be above 0"; + if (p.DamageDealt < 0f) return "damageDealt: must not be negative"; + if (p.DamageTaken == null || p.DamageTaken.Head < 0f || p.DamageTaken.Body < 0f || p.DamageTaken.Legs < 0f) + return "damageTaken: head, body and legs must not be negative"; + if (p.AimCone < 0f) return "aimCone: must not be negative"; + if (p.Ranges == null || !(p.Ranges.Sense > 0f) || !(p.Ranges.Attack > 0f) || p.Ranges.LoseTarget < p.Ranges.Sense || p.Ranges.Chase < 0f) + return "ranges: sense and attack above 0, loseTarget at least sense, chase not negative"; + if (p.VisionCone < -1f || p.VisionCone > 1f) return "visionCone: between -1 and 1"; + if (p.SleepDistance < 0f) return "sleepDistance: 0 (never sleeps) or more"; + if (p.HealthThresholds == null || p.HealthThresholds.Any(t => !(t > 0f && t < 1f))) + return "healthThresholds: fractions between 0 and 1"; + return null; + } + + private static string ValidateMovement(Movement m) + { + if (m == null) return "movement: missing"; + string kind, route; + if (!ParseMode(m.Mode, out kind, out route)) + return $"movement.mode: '{m.Mode}' is not wander, monument or route:"; + if (kind == "wander" && !(m.Radius > 0f)) + return "movement.radius: a wanderer's radius must be above 0"; + return null; + } + + private static bool ParseMode(string mode, out string kind, out string route) + { + kind = null; + route = null; + if (mode == "wander" || mode == "monument") + { + kind = mode; + return true; + } + + if (mode != null && mode.StartsWith("route:") && NameRule.IsMatch(mode.Substring(6))) + { + kind = "route"; + route = mode.Substring(6); + return true; + } + + return false; + } + + private string ValidatePlacement(Placement p) + { + if (p == null) return "it is empty"; + if (p.Profile == null || !NameRule.IsMatch(p.Profile)) return "profile: a profile name"; + if (p.Position == null || float.IsNaN(p.Position.X) || float.IsNaN(p.Position.Y) || float.IsNaN(p.Position.Z)) + return "position: x, y and z"; + if (p.Count < 1) return "count: at least 1"; + if (!(p.Respawn >= 1f)) return "respawn: at least 1 second"; + if (p.RespawnMode != "each" && p.RespawnMode != "group") return "respawnMode: each or group"; + if (p.Movement != null) + { + string movement = ValidateMovement(p.Movement); + if (movement != null) return movement; + } + + return null; + } + + private static string ValidateRoute(Route r) + { + if (r == null || r.Points == null || r.Points.Count < 2) return "points: at least two"; + for (int i = 0; i < r.Points.Count; i++) + { + NavMeshHit hit; + if (r.Points[i] == null || !SampleNavmesh(r.Points[i].V, 3f, out hit)) + return $"points[{i}] is not on Rust's navmesh"; + } + + return null; + } + + #endregion + + #region Rust's facts: kits, prefabs, the navmesh + + private bool KitExists(string kit) => Kits != null && !string.IsNullOrEmpty(kit) && Kits.Call("IsKit", kit) is bool b && b; + + private static Dictionary _prefabPaths; + + /// A scientist prefab's full path from its short name, read from the game's manifest. + private static string ResolvePrefab(string shortName) + { + if (string.IsNullOrEmpty(shortName) || !shortName.StartsWith("scientistnpc_")) + return null; + if (_prefabPaths == null) + { + _prefabPaths = new Dictionary(); + // prefabProperties lists every prefab; the manifest's `entities` does not include the NPCs. + foreach (GameManifest.PrefabProperties prefab in GameManifest.Current.prefabProperties) + { + string path = prefab.name; + if (!path.StartsWith("assets/rust.ai/agents/npcplayer/humannpc/") || !path.EndsWith(".prefab")) + continue; + string name = path.Substring(path.LastIndexOf('/') + 1); + _prefabPaths[name.Substring(0, name.Length - ".prefab".Length)] = path; + } + } + + string full; + return _prefabPaths.TryGetValue(shortName, out full) ? full : null; + } + + /// + /// Rust's own navmesh, the one a scientist walks (stage 1, Q4): its navigator is a Gen2 + /// RustNavMeshAgent, and Unity's NavMesh queries see none of the open world. + /// + private static bool SampleNavmesh(Vector3 p, float radius, out NavMeshHit hit) => + Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, radius, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas); + + /// False until the map's navmesh is built: 8–10 minutes on a map's first boot (stage 1, Q4). + private static bool NavmeshReady() => + Rust.Ai.Gen2.Nav.RustNavigation.Instance != null && Rust.Ai.Gen2.Nav.RustNavigation.Instance.IsDefaultNavmeshBuilt(); + + private static string Framework() => + AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide"; + + #endregion + + #region The swap (D223, D232) + + // + // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 2573b149-ba5d-458a-9b83-7f5f32c76afc. + // Do not edit by hand: regenerate after a Rust update (D232). + private static readonly string[] SwapNpcFields = + { + "ScientistNPC.RadioChatterEffects", + "ScientistNPC.DeathEffects", + "ScientistNPC.deathStatName", + "ScientistNPC.IdleChatterRepeatRange", + "ScientistNPC.radioChatterType", + "HumanNPC.AdditionalLosBlockingLayer", + "HumanNPC.LootSpawnSlots", + "HumanNPC.aimConeScale", + "HumanNPC.lastDismountTime", + "NPCPlayer.MovementTickStartDelay", + "NPCPlayer.VirtualInfoZone", + "NPCPlayer.finalDestination", + "NPCPlayer.loadouts", + "NPCPlayer.movementMask", + "NPCPlayer.LegacyNavigation", + "NPCPlayer.NavAgent", + "NPCPlayer.damageScale", + "NPCPlayer.shortRange", + "NPCPlayer.attackLengthMaxShortRangeScale", + "BasePlayer.GestureViewModel", + "BasePlayer.currentTeam", + "BasePlayer.PetEntity", + "BasePlayer.lastPlayerVisibility", + "BasePlayer.eyeHistory", + "BasePlayer.tickHistory", + "BasePlayer.fallDamageEffect", + "BasePlayer.drownEffect", + "BasePlayer.playerFlags", + "BasePlayer.Belt", + "BasePlayer.weaponMoveSpeedScale", + "BasePlayer.clothingBlocksAiming", + "BasePlayer.clothingMoveSpeedReduction", + "BasePlayer.clothingWaterSpeedBonus", + "BasePlayer.clothingAccuracyBonus", + "BasePlayer.equippingBlocked", + "BasePlayer.eggVision", + "BasePlayer.designingAIEntity", + "BaseCombatEntity.spawnDeployableCorpseOnDeath", + "BaseCombatEntity.deployableCorpsePrefab", + "BaseCombatEntity.skeletonProperties", + "BaseCombatEntity.baseProtection", + "BaseCombatEntity.startHealth", + "BaseCombatEntity.pickup", + "BaseCombatEntity.repair", + "BaseCombatEntity.ShowHealthInfo", + "BaseCombatEntity.showHealthInfoThreshold", + "BaseCombatEntity.lifestate", + "BaseCombatEntity.sendsHitNotification", + "BaseCombatEntity.sendsMeleeHitNotification", + "BaseCombatEntity.markAttackerHostile", + "BaseCombatEntity.faction", + "BaseEntity.bounds", + "BaseEntity.impactEffect", + "BaseEntity.enableSaving", + "BaseEntity.syncPosition", + "BaseEntity.model", + "BaseEntity.flags", + "BaseEntity.HasBrain", + "BaseNetworkable.postNetworkUpdateComponents", + "BaseNetworkable.prefabID", + "BaseNetworkable.globalBroadcast", + "BaseNetworkable.globalNetworkBehavior", + "BaseNetworkable.globalBuildingBlock", + "BaseNetworkable.networkRange", + }; + private static readonly string[] SwapBrainFields = + { + "BaseAIBrain.SendClientCurrentState", + "BaseAIBrain.UseQueuedMovementUpdates", + "BaseAIBrain.AllowedToSleep", + "BaseAIBrain.DefaultDesignSO", + "BaseAIBrain.Designs", + "BaseAIBrain.InstanceSpecificDesign", + "BaseAIBrain.SenseRange", + "BaseAIBrain.AttackRangeMultiplier", + "BaseAIBrain.TargetLostRange", + "BaseAIBrain.VisionCone", + "BaseAIBrain.CheckVisionCone", + "BaseAIBrain.CheckLOS", + "BaseAIBrain.IgnoreNonVisionSneakers", + "BaseAIBrain.IgnoreSneakersMaxDistance", + "BaseAIBrain.IgnoreNonVisionMaxDistance", + "BaseAIBrain.ListenRange", + "BaseAIBrain.SenseTypes", + "BaseAIBrain.HostileTargetsOnly", + "BaseAIBrain.IgnoreSafeZonePlayers", + "BaseAIBrain.MaxGroupSize", + "BaseAIBrain.MemoryDuration", + "BaseAIBrain.RefreshKnownLOS", + "BaseAIBrain.CanBeBlinded", + "BaseAIBrain.BlindDurationMultiplier", + "BaseAIBrain.MovementTickStartDelay", + "BaseAIBrain.ClientCurrentState", + "BaseAIBrain.mainInterestPoint", + "BaseAIBrain.UseAIDesign", + "BaseAIBrain.Pet", + "BaseAIBrain.CanUseHealingItems", + "BaseAIBrain.HealChance", + "BaseAIBrain.HealBelowHealthFraction", + }; + // + + private FieldInfo[] _npcFields; + private FieldInfo[] _brainFields; + private readonly List _fieldsMissing = new List(); + private readonly List _fieldsAdded = new List(); + + /// + /// Resolves the generated field list against the running game. A name Rust has since + /// removed is reported, not fatal. On Carbon, where Rust's assembly is unmodified, the + /// list is also checked against the rule it was generated with, so a field Rust has + /// ADDED is named at boot (D232). On Oxide the rule cannot be applied: its patcher makes + /// private fields public. + /// + private void ResolveFields() + { + _fieldsMissing.Clear(); + _fieldsAdded.Clear(); + _npcFields = ResolveFields(typeof(ScientistNPC), SwapNpcFields, _fieldsMissing); + _brainFields = ResolveFields(typeof(ScientistBrain), SwapBrainFields, _fieldsMissing); + + if (Framework() == "carbon") + { + _fieldsAdded.AddRange(RuleFields(typeof(ScientistNPC)).Except(SwapNpcFields)); + _fieldsAdded.AddRange(RuleFields(typeof(ScientistBrain)).Except(SwapBrainFields)); + } + + if (_fieldsMissing.Count > 0) + PrintWarning($"The swap's field list names fields this Rust no longer has: {string.Join(", ", _fieldsMissing)}. Regenerate it (tools/fieldlist)."); + if (_fieldsAdded.Count > 0) + PrintWarning($"Rust has fields the swap's list does not copy: {string.Join(", ", _fieldsAdded)}. Regenerate it (tools/fieldlist)."); + } + + private static FieldInfo[] ResolveFields(Type type, string[] names, List missing) + { + var chain = new List(); + for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType) + chain.Add(t); + + var found = new List(); + foreach (string name in names) + { + int dot = name.IndexOf('.'); + string typeName = name.Substring(0, dot); + Type owner = chain.FirstOrDefault(t => t.Name == typeName); + FieldInfo f = owner?.GetField(name.Substring(dot + 1), + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + if (f == null) + missing.Add(name); + else + found.Add(f); + } + + return found.ToArray(); + } + + /// tools/fieldlist's rule, applied at run time: only meaningful on an unmodified assembly. + private static List RuleFields(Type type) + { + var names = new List(); + for (Type t = type; t != null && t != typeof(MonoBehaviour); t = t.BaseType) + { + foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + { + if (f.IsInitOnly || f.IsLiteral) + continue; + bool serialised = (f.IsPublic && (f.Attributes & FieldAttributes.NotSerialized) == 0) || f.IsDefined(typeof(SerializeField), false); + if (serialised && !typeof(Delegate).IsAssignableFrom(f.FieldType)) + names.Add($"{t.Name}.{f.Name}"); + } + } + + return names; + } + + private static void CopyFields(FieldInfo[] fields, Component from, Component to) + { + foreach (FieldInfo f in fields) + f.SetValue(to, f.GetValue(from)); + } + + /// + /// Makes one of our NPCs, not yet spawned: the prefab's scientist with its two components + /// swapped for ours. Everything the brain reads once, as it starts, is set here (stage 1, + /// Q3: Senses.Init copies the sense values a single time). + /// + private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, out string error) + { + error = null; + BaseEntity made = GameManager.server.CreateEntity(ResolvePrefab(p.Prefab), at, Quaternion.Euler(0f, yaw, 0f), false); + ScientistNPC stock = made as ScientistNPC; + ScientistBrain stockBrain = stock != null ? stock.GetComponent() : null; + if (stock == null || stock.GetType() != typeof(ScientistNPC) || stockBrain == null || stockBrain.GetType() != typeof(ScientistBrain)) + { + error = $"prefab '{p.Prefab}' is not a plain scientist (ScientistNPC with ScientistBrain)"; + if (made != null) UnityEngine.Object.Destroy(made.gameObject); + return null; + } + + GameObject go = stock.gameObject; + RunicNpcPlayer npc = go.AddComponent(); + RunicNpcBrain brain = go.AddComponent(); + CopyFields(_npcFields, stock, npc); + CopyFields(_brainFields, stockBrain, brain); + UnityEngine.Object.DestroyImmediate(stock, true); + UnityEngine.Object.DestroyImmediate(stockBrain, true); + + npc.enableSaving = false; + npc.startHealth = p.Health; + npc.damageScale = p.DamageDealt; + npc.aimConeScale = p.AimCone; + + brain.SenseRange = p.Ranges.Sense; + brain.TargetLostRange = p.Ranges.LoseTarget; + brain.VisionCone = p.VisionCone; + + // Our own sleep replaces an AI zone's (D235): a zone must never put ours to sleep. + brain.AllowedToSleep = false; + brain.Sentry = p.IsSentry; + return npc; + } + + #endregion + + #region The NPC + + /// + /// Our NPC. What only a subclass can do (PLAN.md §2): its own name, its name on the + /// victim's death screen, its attack range, and damage taken by body part. + /// + public class RunicNpcPlayer : ScientistNPC, IAIAttack + { + public string RnName = "Scientist"; + public string ProfileName; + public Profile Profile; + public string Owner; + public string PlacementId; + public int Slot = -1; + public Vector3 Home; + public string MoveKind = "wander"; + public float MoveRadius = 20f; + public string RouteName; + public string Kit; + public bool Died; + public readonly Dictionary Contributors = new Dictionary(); + public readonly HashSet ThresholdsFired = new HashSet(); + + public ulong NetId => net != null ? net.ID.Value : 0UL; + + public RunicNpcBrain RnBrain => Brain as RunicNpcBrain; + + public override string displayName => RnName; + + /// + /// HumanNPC.AttackerInfo writes the prefab's short name as the killer on the victim's + /// death screen, after BasePlayer's display name (stage 1, Q6). Ours puts the name back. + /// + public override void AttackerInfo(ProtoBuf.PlayerLifeStory.DeathInfo info) + { + base.AttackerInfo(info); + info.attackerName = displayName; + } + + /// + /// The profile's attack range replaces the weapon's. HumanNPC.TickAttack asks through + /// IAIAttack, which this class re-implements, so the call lands here. + /// + public new bool IsTargetInRange(BaseEntity entity, out float dist) + { + dist = Vector3.Distance(entity.transform.position, transform.position); + return dist <= EngagementRange(); + } + + public new float EngagementRange() => Profile != null ? Profile.Ranges.Attack : base.EngagementRange(); + + public override void Hurt(HitInfo info) + { + if (Profile != null && info != null) + { + // A hit with no bone (fire, an explosion, a fall) reports every area at once, + // (HitArea)(-1), so only an exact area counts as head or legs; the rest is body. + HitArea area = info.boneArea; + float scale = area == HitArea.Head ? Profile.DamageTaken.Head + : area == HitArea.Leg || area == HitArea.Foot ? Profile.DamageTaken.Legs + : Profile.DamageTaken.Body; + if (!Mathf.Approximately(scale, 1f)) + info.damageTypes.ScaleAll(scale); + + } + + // A contributor is credited with the health it actually took, after Rust's own + // protection and never past zero. A killing blow is credited in OnDied, which + // base.Hurt reaches before it returns, so OnRunicNpcDied carries it. + BasePlayer attacker = info?.InitiatorPlayer; + ulong attackerId = attacker != null && !attacker.IsNpc ? (ulong)attacker.userID : 0UL; + _pendingAttacker = attackerId; + _healthBefore = health; + base.Hurt(info); + _pendingAttacker = 0UL; + if (attackerId != 0UL && !Died) + Credit(attackerId, _healthBefore - health); + if (Profile != null && !IsDead()) + _self?.CheckThresholds(this); + } + + private ulong _pendingAttacker; + private float _healthBefore; + + private void Credit(ulong id, float damage) + { + if (damage <= 0f) + return; + float sum; + Contributors.TryGetValue(id, out sum); + Contributors[id] = sum + damage; + } + + public override void OnDied(HitInfo info) + { + if (_pendingAttacker != 0UL) + Credit(_pendingAttacker, _healthBefore); + Died = true; + _self?.HandleDied(this, info); + base.OnDied(info); + } + } + + /// + /// Our brain: Rust's scientist brain, whose AI design still decides when to chase, fight + /// and take cover, with three things of ours (D233, D235): + /// + /// + /// the roam state replaced by our wander or route follower (a monument + /// roamer keeps Rust's own); + /// a sentry's moving states replaced by one that holds its spot and turns; + /// sleep: past the profile's distance from every player it walks home, then + /// stops thinking; a player in range wakes it. + /// + /// + /// The design references states by type, so a replaced state keeps its events: the + /// shooting still happens where Rust's design puts it. + /// + public class RunicNpcBrain : ScientistBrain + { + public bool Sentry; + + public enum RestState + { + Awake, + GoingHome, + Asleep + } + + public RestState Rest = RestState.Awake; + public int Leashes; + + private float _nextCheck; + private bool _playerNear = true; + private float _homeBy; + + private static readonly BasePlayer[] NearBuffer = new BasePlayer[4]; + + private static readonly AIState[] SentryHolds = + { + AIState.Roam, AIState.Chase, AIState.Combat, AIState.TakeCover, AIState.Cover, AIState.MoveTowards, + AIState.MoveToVector3, AIState.FollowPath, AIState.NavigateHome, AIState.Flee, AIState.Blinded + }; + + private static readonly HashSet Aggressive = new HashSet + { + AIState.Chase, AIState.Combat, AIState.TakeCover, AIState.Cover + }; + + public override void AddStates() + { + base.AddStates(); + RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer; + if (npc == null) + return; + + if (Sentry) + { + foreach (AIState type in SentryHolds) + if (states.ContainsKey(type)) + Replace(new HoldState(type, Aggressive.Contains(type))); + } + else if (npc.MoveKind == "wander" || npc.MoveKind == "route") + { + // Rust's own roam and chase both need an AI zone's move points, so outside a + // monument they do nothing (stage 1, Q3; stage 2 found the chase's Error branch). + // A monument roamer keeps both of Rust's. + Replace(npc.MoveKind == "wander" ? (BasicAIState)new WanderState() : new RouteState()); + Replace(new PursueState()); + } + } + + private void Replace(BasicAIState state) + { + states.Remove(state.StateType); + AddState(state); + } + + public override void Think(float delta) + { + RunicNpcPlayer npc = GetBaseEntity() as RunicNpcPlayer; + + // Rust's think manager can reach a brain before Unity has started it, and until then + // it has no navigator and no state: leave that think to Rust. + if (npc == null || npc.Profile == null || npc.IsDead() || Navigator == null) + { + base.Think(delta); + return; + } + + float now = Time.time; + if (now >= _nextCheck) + { + _nextCheck = now + 2f; + _playerNear = npc.Profile.SleepDistance <= 0f || PlayerWithin(npc.transform.position, npc.Profile.SleepDistance); + } + + switch (Rest) + { + case RestState.Asleep: + if (!_playerNear) + { + lastThinkTime = now; + return; + } + + Rest = RestState.Awake; + ((IAISleepable)this).WakeAI(); + break; + + case RestState.GoingHome: + if (_playerNear) + { + Rest = RestState.Awake; + break; + } + + lastThinkTime = now; + if (Flat(npc.transform.position - npc.Home) <= 2f || now >= _homeBy) + Sleep(); + else if (!Navigator.Moving && !Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal)) + Sleep(); + return; + + default: + if (!_playerNear && (CurrentState == null || !CurrentState.AgrresiveState)) + { + // D235: walk home first, then sleep. Nothing thinks on the way: no player + // is within the sleep distance, so there is nothing to sense or fight. + Rest = RestState.GoingHome; + _homeBy = now + 120f; + lastThinkTime = now; + if (Sentry || !Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal)) + Sleep(); + return; + } + + break; + } + + if (!Sentry && npc.Profile.Ranges.Chase > 0f && CurrentState != null && CurrentState.AgrresiveState && + Flat(npc.transform.position - npc.Home) > npc.Profile.Ranges.Chase) + Leash(npc); + + base.Think(delta); + } + + /// Past its chase range from home: forget the target and head back. + public void Leash(RunicNpcPlayer npc) + { + Leashes++; + Rust.Ai.SimpleAIMemory memory = Senses.Memory; + memory.All.Clear(); + memory.Players.Clear(); + memory.Targets.Clear(); + memory.Threats.Clear(); + memory.LOS.Clear(); + Events.Memory.Entity.Clear(); + Senses.DelaySenseUpdate(5f); + Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Fast); + } + + private void Sleep() + { + Rest = RestState.Asleep; + Navigator.Stop(); + ((IAISleepable)this).SleepAI(); + } + + private static bool PlayerWithin(Vector3 at, float distance) => + BaseEntity.Query.Server.GetPlayersInSphere(at, distance, NearBuffer, p => !p.IsNpc && p.IsAlive() && !p.IsSleeping()) > 0; + + private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude; + } + + /// D233 wander: a walkable point within the radius of home, walk there, finish. + public class WanderState : BaseAIBrain.BasicAIState + { + private StateStatus _status = StateStatus.Error; + + public WanderState() : base(AIState.Roam) + { + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _status = StateStatus.Error; + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc == null) + return; + + for (int i = 0; i < 6; i++) + { + Vector2 offset = UnityEngine.Random.insideUnitCircle * npc.MoveRadius; + NavMeshHit hit; + if (SampleNavmesh(npc.Home + new Vector3(offset.x, 0f, offset.y), 3f, out hit) && + brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Slow)) + { + _status = StateStatus.Running; + return; + } + } + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + if (_status == StateStatus.Error) + return _status; + return brain.Navigator.Moving ? StateStatus.Running : StateStatus.Finished; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.Stop(); + } + } + + /// D233 route:<name>: walks a recorded route's points in order. + public class RouteState : BaseAIBrain.BasicAIState + { + private int _index = -1; + private int _step = 1; + private StateStatus _status = StateStatus.Error; + + public int Index => _index; + + public RouteState() : base(AIState.Roam) + { + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _status = StateStatus.Error; + RunicNpcPlayer npc = entity as RunicNpcPlayer; + Route route = npc != null ? _self?.RouteFor(npc) : null; + if (route == null) + return; + + if (_index < 0 || _index >= route.Points.Count) + _index = Nearest(route, entity.transform.position); + _status = Go(brain, route) ? StateStatus.Running : StateStatus.Error; + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + if (_status == StateStatus.Error) + return _status; + + RunicNpcPlayer npc = entity as RunicNpcPlayer; + Route route = npc != null ? _self?.RouteFor(npc) : null; + if (route == null) + return _status = StateStatus.Error; + + if (_index >= route.Points.Count) + _index = Nearest(route, entity.transform.position); + if (!brain.Navigator.Moving || Vector3.Distance(entity.transform.position, route.Points[_index].V) < 1.5f) + { + Advance(route); + if (!Go(brain, route)) + return _status = StateStatus.Error; + } + + return StateStatus.Running; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.Stop(); + } + + /// Sets off for the current point, skipping any Rust cannot path to. + private bool Go(BaseAIBrain brain, Route route) + { + for (int tries = 0; tries < route.Points.Count; tries++) + { + if (brain.Navigator.SetDestination(route.Points[_index].V, BaseNavigator.NavigationSpeed.Slow)) + return true; + Advance(route); + } + + return false; + } + + private void Advance(Route route) + { + int n = route.Points.Count; + if (route.Loop) + { + _index = (_index + 1) % n; + return; + } + + if (_index + _step < 0 || _index + _step >= n) + _step = -_step; + _index += _step; + } + + private static int Nearest(Route route, Vector3 at) + { + int best = 0; + float bestDistance = float.MaxValue; + for (int i = 0; i < route.Points.Count; i++) + { + float d = (route.Points[i].V - at).sqrMagnitude; + if (d < bestDistance) + { + bestDistance = d; + best = i; + } + } + + return best; + } + } + + /// + /// Our chase, for a wander or route roamer: close to three quarters of the attack + /// range, never past the chase range from home. A target that stays out of reach while ours + /// stands at that edge for 5 s is given up (the leash), and ours walks home. + /// + public class PursueState : BaseAIBrain.BasicAIState + { + private float _next; + private float _edgeSince = -1f; + + public PursueState() : base(AIState.Chase) + { + AgrresiveState = true; + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _next = 0f; + _edgeSince = -1f; + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + BaseEntity target = brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (target == null || npc == null || npc.Profile == null) + return StateStatus.Error; + + Vector3 here = entity.transform.position; + Vector3 there = target.transform.position; + float distance = Vector3.Distance(here, there); + if (distance <= 10f || brain.Senses.Memory.IsLOS(target)) + brain.Navigator.SetFacingDirectionEntity(target); + else + brain.Navigator.ClearFacingDirectionOverride(); + + if (Time.time < _next) + return StateStatus.Running; + _next = Time.time + UnityEngine.Random.Range(0.5f, 1f); + + float stopAt = Mathf.Max(2f, npc.Profile.Ranges.Attack * 0.75f); + if (distance <= stopAt) + { + brain.Navigator.Stop(); + _edgeSince = -1f; + return StateStatus.Running; + } + + Vector3 goal = there - (there - here).normalized * stopAt; + float chase = npc.Profile.Ranges.Chase; + Vector3 fromHome = goal - npc.Home; + fromHome.y = 0f; + bool clamped = chase > 0f && fromHome.magnitude > chase; + if (clamped) + goal = npc.Home + fromHome.normalized * chase; + + if (clamped && distance > npc.Profile.Ranges.Attack) + { + if (_edgeSince < 0f) + _edgeSince = Time.time; + if (Time.time - _edgeSince >= 5f) + { + (brain as RunicNpcBrain)?.Leash(npc); + return StateStatus.Error; + } + } + else + _edgeSince = -1f; + + NavMeshHit hit; + if (!SampleNavmesh(goal, 3f, out hit)) + return StateStatus.Error; + brain.Navigator.SetDestination(hit.position, distance > 10f ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal); + return StateStatus.Running; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.Stop(); + brain.Navigator.ClearFacingDirectionOverride(); + } + } + + /// + /// A sentry's stand-in for every state that would move it: it stops, and turns to face the + /// design's current target. The design's attack events still run in it. + /// + public class HoldState : BaseAIBrain.BasicAIState + { + public HoldState(AIState type, bool aggressive) : base(type) + { + AgrresiveState = aggressive; + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + brain.Navigator.Stop(); + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + BaseEntity target = brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot); + if (target != null) + brain.Navigator.SetFacingDirectionEntity(target); + else + brain.Navigator.ClearFacingDirectionOverride(); + return StateStatus.Running; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.ClearFacingDirectionOverride(); + } + } + + #endregion + + #region Registry, owners, spawning + + /// Every live NPC of ours, by net id. + private readonly Dictionary _npcs = new Dictionary(); + + private sealed class Slot + { + public RunicNpcPlayer Npc; + public float DiedAt = float.NegativeInfinity; + public bool Queued; + } + + private sealed class PlacementState + { + public string Id; + public Placement Data; + public Slot[] Slots; + public string Waiting; + public string LastError; + } + + private readonly Dictionary _placements = new Dictionary(); + + private sealed class SpawnRequest + { + public PlacementState Placement; + public int Slot; + } + + private readonly Queue _queue = new Queue(); + private bool _pumpScheduled; + private bool _navmeshWaitLogged; + private float _tokens; + private float _tokensAt; + + private int CountOwner(string owner) => _npcs.Values.Count(n => n.Owner == owner); + + private int CountProfile(string profile) => _npcs.Values.Count(n => n.ProfileName == profile); + + /// Null when a cap allows one more; otherwise which cap, for the caller. + private string CapRefusal(string owner, string profile) + { + CapsConfig caps = _config.Caps; + if (caps.Total > 0 && _npcs.Count >= caps.Total) + return $"the server's cap of {caps.Total} RunicNPC NPCs is reached"; + if (caps.PerOwner > 0 && CountOwner(owner) >= caps.PerOwner) + return $"the cap of {caps.PerOwner} per owner is reached for {owner}"; + if (caps.PerProfile > 0 && CountProfile(profile) >= caps.PerProfile) + return $"the cap of {caps.PerProfile} per profile is reached for '{profile}'"; + return null; + } + + /// The spawn-rate cap, a token bucket; always true when no rate is set. + private bool TakeSpawnToken() + { + float rate = _config.Caps.SpawnsPerSecond; + if (rate <= 0f) + return true; + float now = Time.realtimeSinceStartup; + _tokens = Mathf.Min(Mathf.Max(1f, rate), _tokens + (now - _tokensAt) * rate); + _tokensAt = now; + if (_tokens < 1f) + return false; + _tokens -= 1f; + return true; + } + + private static Profile Effective(Profile profile, JObject overrides) + { + JObject merged = JObject.FromObject(profile); + if (overrides != null) + merged.Merge(overrides, new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Replace }); + return merged.ToObject(); + } + + /// + /// Spawns one NPC now, or returns null with the reason. Every path into the world comes + /// through here: the API, and the placement queue. + /// + private RunicNpcPlayer Spawn(string profileName, Profile p, Vector3 at, float yaw, string owner, Movement movementOverride, + string placementId, int slot, out string error) + { + if (!_ready || _unloading) { error = "RunicNPC is not ready"; return null; } + if (!NavmeshReady()) { error = "the map's navmesh is not built yet"; return null; } + + error = CapRefusal(owner, profileName); + if (error != null) return null; + + Movement movement = movementOverride ?? p.Movement; + string kind, route; + ParseMode(movement.Mode, out kind, out route); + if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) + { + error = $"route '{route}' does not exist"; + return null; + } + + Vector3 position = at; + if (!p.IsSentry) + { + // Rust's sampler reaches further down than across: without the height check, a spot + // on a roof finds the ground below it (stage 2). + NavMeshHit hit; + if (!SampleNavmesh(at, Scatter, out hit) || Mathf.Abs(hit.position.y - at.y) > MaxDrop) + { + error = "the spot is not on Rust's navmesh, and only a sentry may stand off it (D219)"; + return null; + } + + position = hit.position; + } + + if (!TakeSpawnToken()) + { + error = $"the spawn-rate cap of {_config.Caps.SpawnsPerSecond}/s is reached"; + return null; + } + + RunicNpcPlayer npc = CreateNpc(p, position, yaw, out error); + if (npc == null) + return null; + + npc.RnName = p.Names[UnityEngine.Random.Range(0, p.Names.Count)]; + npc.ProfileName = profileName; + npc.Profile = p; + npc.Owner = owner; + npc.PlacementId = placementId; + npc.Slot = slot; + npc.Home = position; + npc.MoveKind = kind; + npc.MoveRadius = movement.Radius; + npc.RouteName = route; + + // CreateEntity(..., startActive: false) leaves the GameObject inactive and skips exactly this + // call, which GameManager.CreatePrefab makes when active is true. Without it the entity still + // spawns and thinks, but Unity never starts the brain: no design, no navigator, no state + // (stage 1, Q1). + npc.gameObject.AwakeFromInstantiate(); + npc.Spawn(); + npc.InitializeHealth(p.Health, p.Health); + + npc.Kit = p.Kits[UnityEngine.Random.Range(0, p.Kits.Count)]; + npc.inventory.Strip(); + object given = Kits?.Call("GiveKit", npc, npc.Kit); + if (given is string) + PrintWarning($"Kits did not give '{npc.Kit}' to {npc.RnName} ({profileName}): {given}"); + npc.EquipWeapon(); + + _npcs[npc.NetId] = npc; + Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner); + return npc; + } + + /// Removes one NPC from the registry, however it went: death, despawn or unload. + private void Unregister(RunicNpcPlayer npc) + { + if (!_npcs.Remove(npc.NetId)) + return; + + if (npc.PlacementId != null) + { + PlacementState ps; + if (_placements.TryGetValue(npc.PlacementId, out ps) && npc.Slot >= 0 && npc.Slot < ps.Slots.Length && ps.Slots[npc.Slot].Npc == npc) + { + ps.Slots[npc.Slot].Npc = null; + ps.Slots[npc.Slot].DiedAt = Time.realtimeSinceStartup; + } + } + + if (!npc.Died && !_unloading) + Interface.CallHook("OnRunicNpcDespawned", npc, npc.Owner); + } + + private int Despawn(IEnumerable npcs) + { + int n = 0; + foreach (RunicNpcPlayer npc in npcs.ToList()) + { + if (npc == null || npc.IsDestroyed) + continue; + Unregister(npc); + npc.Kill(); + n++; + } + + return n; + } + + /// + /// A death. It is unregistered at once, so a placement's respawn delay counts from the death, + /// not from whenever the body's entity is finally removed. + /// + private void HandleDied(RunicNpcPlayer npc, HitInfo info) + { + var contributors = new Dictionary(npc.Contributors); + Unregister(npc); + Interface.CallHook("OnRunicNpcDied", npc, npc.ProfileName, npc.Owner, info, contributors); + } + + private void CheckThresholds(RunicNpcPlayer npc) + { + float fraction = npc.health / npc.MaxHealth(); + foreach (float t in npc.Profile.HealthThresholds.OrderByDescending(x => x)) + if (fraction <= t && npc.ThresholdsFired.Add(t)) + Interface.CallHook("OnRunicNpcHealth", npc, npc.ProfileName, t); + } + + private Route RouteFor(RunicNpcPlayer npc) + { + Route route; + return npc.RouteName != null && _routeFile.Routes.TryGetValue(npc.RouteName, out route) && route.Points.Count > 0 ? route : null; + } + + #endregion + + #region Placements + + private void RebuildPlacements() + { + foreach (PlacementState ps in _placements.Values) + Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); + _placements.Clear(); + _queue.Clear(); + + foreach (KeyValuePair kv in _placementFile.Placements) + { + string error = ValidatePlacement(kv.Value); + if (error != null) + { + PrintWarning($"Placement '{kv.Key}' is ignored: {error}"); + continue; + } + + _placements[kv.Key] = NewState(kv.Key, kv.Value); + } + } + + private static PlacementState NewState(string id, Placement data) + { + var ps = new PlacementState { Id = id, Data = data, Slots = new Slot[data.Count] }; + for (int i = 0; i < ps.Slots.Length; i++) + ps.Slots[i] = new Slot(); + return ps; + } + + /// Why a placement cannot spawn right now, or null (D237: it waits, it is not removed). + private string WaitReason(Placement data) + { + Profile p; + if (!_profiles.TryGetValue(data.Profile, out p)) + return _refused.ContainsKey(data.Profile) ? $"profile '{data.Profile}' is refused: {_refused[data.Profile]}" : $"profile '{data.Profile}' is missing"; + Movement movement = data.Movement ?? p.Movement; + string kind, route; + ParseMode(movement.Mode, out kind, out route); + if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) + return $"route '{route}' is missing"; + return null; + } + + /// Once a second: what each placement is waiting for, and which slots are due. + private void TickPlacements() + { + if (_placementsDirty) + SavePlacements(); + if (!_ready || _unloading) + return; + + float now = Time.realtimeSinceStartup; + bool queued = false; + foreach (PlacementState ps in _placements.Values) + { + string waiting = WaitReason(ps.Data); + if (waiting != null) + { + if (ps.Waiting != waiting) + PrintWarning($"Placement '{ps.Id}' waits: {waiting}"); + ps.Waiting = waiting; + Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); + foreach (Slot s in ps.Slots) + { + s.DiedAt = float.NegativeInfinity; + s.Queued = false; + } + + continue; + } + + if (ps.Waiting != null) + Puts($"Placement '{ps.Id}' no longer waits."); + ps.Waiting = null; + + if (ps.Data.RespawnMode == "group") + { + if (ps.Slots.All(s => s.Npc == null && !s.Queued) && now >= ps.Slots.Max(s => s.DiedAt) + ps.Data.Respawn) + for (int i = 0; i < ps.Slots.Length; i++) + queued |= Enqueue(ps, i); + } + else + { + for (int i = 0; i < ps.Slots.Length; i++) + if (ps.Slots[i].Npc == null && !ps.Slots[i].Queued && now >= ps.Slots[i].DiedAt + ps.Data.Respawn) + queued |= Enqueue(ps, i); + } + } + + if (queued) + SchedulePump(); + } + + private bool Enqueue(PlacementState ps, int slot) + { + ps.Slots[slot].Queued = true; + _queue.Enqueue(new SpawnRequest { Placement = ps, Slot = slot }); + return true; + } + + private void SchedulePump() + { + if (_pumpScheduled || _unloading) + return; + _pumpScheduled = true; + NextFrame(Pump); + } + + private void PumpLater(float seconds) + { + _pumpScheduled = true; + timer.Once(seconds, () => + { + _pumpScheduled = false; + SchedulePump(); + }); + } + + /// + /// Spawns queued placement slots within the frame budget, and carries the rest to the next + /// frame (stage 1, Q5). Nothing spawns until the map's navmesh is built (Q4). + /// + private void Pump() + { + _pumpScheduled = false; + if (_unloading) + return; + + if (!NavmeshReady()) + { + if (!_navmeshWaitLogged) + Puts($"Waiting for the map's navmesh before spawning {_queue.Count} placement NPC(s); on a map's first boot this takes minutes."); + _navmeshWaitLogged = true; + PumpLater(5f); + return; + } + + Stopwatch clock = Stopwatch.StartNew(); + while (_queue.Count > 0 && clock.Elapsed.TotalMilliseconds < _config.SpawnBudgetMs) + { + SpawnRequest r = _queue.Peek(); + if (_config.Caps.SpawnsPerSecond > 0f && _tokens < 1f && !WouldHaveToken()) + { + PumpLater(0.25f); + return; + } + + _queue.Dequeue(); + Slot slot = r.Placement.Slots.Length > r.Slot ? r.Placement.Slots[r.Slot] : null; + if (slot == null || !_placements.ContainsKey(r.Placement.Id) || _placements[r.Placement.Id] != r.Placement) + continue; + slot.Queued = false; + if (slot.Npc != null || r.Placement.Waiting != null) + continue; + SpawnSlot(r.Placement, r.Slot); + } + + if (_queue.Count > 0) + SchedulePump(); + } + + private bool WouldHaveToken() + { + float rate = _config.Caps.SpawnsPerSecond; + return _tokens + (Time.realtimeSinceStartup - _tokensAt) * rate >= 1f; + } + + private void SpawnSlot(PlacementState ps, int slot) + { + Placement data = ps.Data; + Profile p = _profiles[data.Profile]; + Vector3 spot = data.Position.V; + Vector3 at = spot; + if (slot > 0) + { + Vector2 offset = UnityEngine.Random.insideUnitCircle * Scatter; + at = spot + new Vector3(offset.x, 0f, offset.y); + } + + string error; + RunicNpcPlayer npc = Spawn(data.Profile, p, at, data.Yaw, "placement:" + ps.Id, data.Movement, ps.Id, slot, out error); + if (npc == null) + { + if (ps.LastError != error) + PrintWarning($"Placement '{ps.Id}' could not spawn: {error}. It retries after its respawn delay."); + ps.LastError = error; + ps.Slots[slot].DiedAt = Time.realtimeSinceStartup; + return; + } + + ps.LastError = null; + ps.Slots[slot].Npc = npc; + } + + /// The count a placement plans, whether or not its NPCs are alive now. + private int PlannedTotal() => + _placements.Values.Sum(ps => ps.Slots.Length) + _npcs.Values.Count(n => n.PlacementId == null); + + #endregion + + #region The cost warning (D227) + + /// + /// What NPCs cost the server, from stage 1's measurements on both + /// frameworks (PLAN.md §9, stage 1, Q5). The rig ran about 50 fps empty, so the warning + /// says what NPCs ADD to a frame, not an fps. + /// + private static string CostWarning(int total) + { + if (total <= 0) + return "RunicNPC: no NPCs on this server."; + float hundreds = total / 100f; + string estimate = total > 100 ? " (measured up to 100; beyond that this is an estimate)" : ""; + return $"RunicNPC: {total} NPC(s) on this server{estimate}. Measured on a test server, they add about " + + $"{Mathf.Max(0.1f, hundreds * 1.5f):0.#} ms to every server frame while idle. If all fight at once they add about " + + $"{hundreds * 5f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " + + $"{Mathf.Max(0.3f, hundreds * 2.5f):0.#} s."; + } + + #endregion + + #region Lifecycle and hooks private void Init() { + _self = this; foreach (string hook in ExpectedHooks) _hookCounts[hook] = 0L; } @@ -63,7 +1688,52 @@ namespace Oxide.Plugins private void OnServerInitialized() { MarkHook("OnServerInitialized"); - Puts($"RunicNPC {Version} loaded, API {ApiVersion}. Stage 0: it spawns nothing yet."); + ResolveFields(); + LoadData(); + RevalidateProfiles(); + RebuildPlacements(); + _ready = true; + timer.Every(1f, TickPlacements); + Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " + + $"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}"); + TickPlacements(); + } + + private void OnServerSave() + { + MarkHook("OnServerSave"); + if (_placementsDirty) + SavePlacements(); + } + + private void OnEntityKill(BaseNetworkable entity) + { + MarkHook("OnEntityKill"); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc != null) + Unregister(npc); + } + + /// A plugin's NPCs die with it (§2: plugin:<name>). + private void OnPluginUnloaded(Plugin plugin) + { + MarkHook("OnPluginUnloaded"); + if (plugin == null || plugin == this) + return; + string owner = "plugin:" + plugin.Name; + int n = Despawn(_npcs.Values.Where(x => x.Owner == owner)); + if (n > 0) + Puts($"{plugin.Name} unloaded: its {n} NPC(s) were removed."); + } + + private void Unload() + { + _unloading = true; + Despawn(_npcs.Values); + _npcs.Clear(); + if (_placementsDirty) + SavePlacements(); + _self = null; } private void MarkHook(string name) @@ -73,23 +1743,210 @@ namespace Oxide.Plugins _hookCounts[name] = count + 1; } - // ---- the API (PLAN.md §4) ---- - // - // Every call is private and prefixed `RunicNpc_`. Private because Oxide's `Call` reaches a - // non-public method by name, and a PUBLIC one only when it carries [HookMethod] — the trap - // HumanNPC's RefreshNPC and RemoveNPC fell into (PLAN.md §1.2). Prefixed because `Call` - // matches by name alone, and no hook of any other plugin may ever match one of ours. + #endregion + + #region The API (PLAN.md §4, docs/runicnpc/API.md) + + // Every call is private: Oxide's Call reaches a non-public method by name, and a public one only + // with [HookMethod] (the PR check holds this). Each returns a value or null and says why in the log. - /// The API's version. A caller that needs more refuses this RunicNPC and says so. private int RunicNpc_ApiVersion() => ApiVersion; - // ---- console ---- + private BasePlayer RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides) + { + string error = null; + RunicNpcPlayer npc = null; + Profile p; + if (owner == null || !OwnerRule.IsMatch(owner)) + error = "owner must be run: or plugin: (placement: owners are RunicNPC's own)"; + else if (profile == null || !_profiles.TryGetValue(profile, out p)) + error = profile != null && _refused.ContainsKey(profile) ? $"profile '{profile}' is refused: {_refused[profile]}" : $"no profile '{profile}'"; + else + { + Profile effective = p; + if (overrides != null) + { + try + { + effective = Effective(p, overrides); + error = ValidateProfile(effective); + } + catch (Exception e) + { + error = "overrides could not be applied: " + e.Message; + } + } + + if (error == null) + npc = Spawn(profile, effective, at, 0f, owner, null, null, -1, out error); + } + + if (npc == null) + PrintWarning($"RunicNpc_Spawn('{profile}', {owner}) refused: {error}"); + return npc; + } + + private int RunicNpc_Despawn(ulong netId) + { + RunicNpcPlayer npc; + return _npcs.TryGetValue(netId, out npc) ? Despawn(new[] { npc }) : 0; + } + + private int RunicNpc_DespawnOwner(string owner) => owner == null ? 0 : Despawn(_npcs.Values.Where(n => n.Owner == owner)); + + private List> RunicNpc_List(string owner) => + _npcs.Values.Where(n => owner == null || n.Owner == owner).Select(Describe).ToList(); + + private static Dictionary Describe(RunicNpcPlayer n) + { + Vector3 p = n.transform.position; + RunicNpcBrain brain = n.RnBrain; + return new Dictionary + { + ["netId"] = n.NetId, + ["profile"] = n.ProfileName, + ["name"] = n.RnName, + ["owner"] = n.Owner, + ["placement"] = n.PlacementId, + ["kit"] = n.Kit, + ["position"] = new Dictionary { ["x"] = p.x, ["y"] = p.y, ["z"] = p.z }, + ["home"] = new Dictionary { ["x"] = n.Home.x, ["y"] = n.Home.y, ["z"] = n.Home.z }, + ["health"] = n.health, + ["maxHealth"] = n.MaxHealth(), + ["movement"] = n.MoveKind == "route" ? "route:" + n.RouteName : n.MoveKind, + ["rest"] = brain != null ? brain.Rest.ToString() : "?", + ["state"] = brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none", + ["leashes"] = brain != null ? brain.Leashes : 0 + }; + } + + private JObject RunicNpc_Profiles() + { + JObject all = JObject.FromObject(_profileFile); + all["refused"] = JObject.FromObject(_refused); + return all; + } /// - /// What this RunicNPC is and which of its hooks have fired. Answers over RCON as well - /// as at the console, and is the first thing to ask for when a server says it has no - /// RunicNPC. + /// Replaces the whole profile set, as a site does on every push (D221), and marks the + /// server managed. Returns the profiles refused and why; the rest are in use at once. /// + private Dictionary RunicNpc_SetProfiles(JObject all) + { + Dictionary profiles; + try + { + profiles = all != null ? all.ToObject>() : new Dictionary(); + } + catch (Exception e) + { + return new Dictionary { ["*"] = "the profile set could not be read: " + e.Message }; + } + + _profileFile.Profiles = profiles; + _profileFile.Managed = true; + SaveProfiles(); + RevalidateProfiles(); + TickPlacements(); + return new Dictionary(_refused); + } + + private List> RunicNpc_Placements() => _placements.Values.Select(ps => new Dictionary + { + ["id"] = ps.Id, + ["placement"] = JObject.FromObject(ps.Data), + ["alive"] = ps.Slots.Count(s => s.Npc != null), + ["waiting"] = ps.Waiting, + ["lastError"] = ps.LastError + }).ToList(); + + /// Adds or replaces a placement (D222). Null on success; otherwise why not. + private string RunicNpc_SetPlacement(string id, JObject placement) + { + if (id == null || !NameRule.IsMatch(id)) + return "id: 1–40 of a-z, 0-9, _ and -"; + Placement data; + try + { + data = placement?.ToObject(); + } + catch (Exception e) + { + return "the placement could not be read: " + e.Message; + } + + string error = ValidatePlacement(data); + if (error != null) + return error; + + PlacementState old; + if (_placements.TryGetValue(id, out old)) + Despawn(old.Slots.Select(s => s.Npc).Where(n => n != null)); + + _placementFile.Placements[id] = data; + _placements[id] = NewState(id, data); + _placementsDirty = true; + Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}"); + TickPlacements(); + return null; + } + + private bool RunicNpc_RemovePlacement(string id) + { + PlacementState ps; + if (id == null || !_placements.TryGetValue(id, out ps)) + return false; + _placements.Remove(id); + _placementFile.Placements.Remove(id); + _placementsDirty = true; + Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); + return true; + } + + private JObject RunicNpc_Routes() => JObject.FromObject(_routeFile.Routes); + + /// Adds or replaces a route (D234). Every point must be on Rust's navmesh. + private string RunicNpc_SetRoute(string name, JObject route) + { + if (name == null || !NameRule.IsMatch(name)) + return "name: 1–40 of a-z, 0-9, _ and -"; + Route data; + try + { + data = route?.ToObject(); + } + catch (Exception e) + { + return "the route could not be read: " + e.Message; + } + + string error = ValidateRoute(data); + if (error != null) + return error; + _routeFile.Routes[name] = data; + SaveRoutes(); + return null; + } + + private bool RunicNpc_RemoveRoute(string name) + { + if (name == null || !_routeFile.Routes.Remove(name)) + return false; + SaveRoutes(); + return true; + } + + private bool RunicNpc_IsRunicNpc(BaseEntity entity) => entity is RunicNpcPlayer; + + private string RunicNpc_ProfileOf(BaseEntity entity) => (entity as RunicNpcPlayer)?.ProfileName; + + /// The cost warning (D227) for the NPCs this server plans, plus . + private string RunicNpc_CostWarning(int adding) => CostWarning(PlannedTotal() + Math.Max(0, adding)); + + #endregion + + #region Console + [ConsoleCommand("rnpc.status")] private void CmdStatus(ConsoleSystem.Arg arg) { @@ -98,26 +1955,55 @@ namespace Oxide.Plugins var fired = new List(); var silent = new List(); - foreach (string hook in ExpectedHooks) { long count; _hookCounts.TryGetValue(hook, out count); - if (count > 0L) fired.Add($"{hook}={count}"); else silent.Add(hook); } - string firedText = fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)"; - string silentText = silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)"; + string nl = Environment.NewLine; + var sb = new StringBuilder(); + sb.Append($"RunicNPC {Version} api={ApiVersion} hooks={ExpectedHooks.Length} fired={fired.Count} silent={silent.Count}").Append(nl); + sb.Append($"fired: {(fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)")}").Append(nl); + sb.Append($"silent: {(silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)")}").Append(nl); + sb.Append($"framework={Framework()} navmesh={(NavmeshReady() ? "ready" : "building")} " + + $"swap fields: npc={_npcFields?.Length ?? 0}/{SwapNpcFields.Length} brain={_brainFields?.Length ?? 0}/{SwapBrainFields.Length} " + + $"missing={(_fieldsMissing.Count > 0 ? string.Join(",", _fieldsMissing) : "-")} added={(_fieldsAdded.Count > 0 ? string.Join(",", _fieldsAdded) : "-")}").Append(nl); - arg.ReplyWith( - $"RunicNPC {Version} api={ApiVersion} hooks={ExpectedHooks.Length} " + - $"fired={fired.Count} silent={silent.Count}" + Environment.NewLine + - $"fired: {firedText}" + Environment.NewLine + - $"silent: {silentText}"); + IEnumerable brains = _npcs.Values.Select(n => n.RnBrain).Where(b => b != null).ToList(); + sb.Append($"npcs={_npcs.Count} awake={brains.Count(b => b.Rest == RunicNpcBrain.RestState.Awake)} " + + $"goingHome={brains.Count(b => b.Rest == RunicNpcBrain.RestState.GoingHome)} asleep={brains.Count(b => b.Rest == RunicNpcBrain.RestState.Asleep)} " + + $"owners: {string.Join(" ", _npcs.Values.GroupBy(n => n.Owner).Select(g => $"{g.Key}={g.Count()}").ToArray())}").Append(nl); + sb.Append($"profiles={_profiles.Count} managed={_profileFile.Managed} refused: " + + $"{(_refused.Count > 0 ? string.Join("; ", _refused.Select(kv => kv.Key + ": " + kv.Value).ToArray()) : "-")}").Append(nl); + sb.Append($"placements={_placements.Count} queue={_queue.Count} waiting: " + + $"{string.Join("; ", _placements.Values.Where(ps => ps.Waiting != null).Select(ps => ps.Id + ": " + ps.Waiting).DefaultIfEmpty("-").ToArray())}").Append(nl); + sb.Append($"routes={_routeFile.Routes.Count}").Append(nl); + CapsConfig caps = _config.Caps; + sb.Append($"caps: total={caps.Total} perOwner={caps.PerOwner} perProfile={caps.PerProfile} spawnsPerSecond={caps.SpawnsPerSecond} (0 = none)").Append(nl); + sb.Append(CostWarning(PlannedTotal())); + arg.ReplyWith(sb.ToString()); } + + /// Re-reads profiles and routes after a hand edit of their files (standalone, D221). + [ConsoleCommand("rnpc.reload")] + private void CmdReload(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + _profileFile = ReadData(DataProfiles); + if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); + _routeFile = ReadData(DataRoutes); + if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary(); + RevalidateProfiles(); + TickPlacements(); + arg.ReplyWith($"RunicNPC: {_profiles.Count} profile(s) in use, {_refused.Count} refused, {_routeFile.Routes.Count} route(s)."); + } + + #endregion } } diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index 7d35559..0a4a2c1 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -179,7 +179,11 @@ function check(source, toml) { } const methods = readMethods(source) - const hooks = methods.filter((x) => HOOK_NAME.test(x.name)) + // An `override` is never a hook: a hook is a plugin method the framework finds by name, and the + // plugin class overrides nothing shaped like one. What does override `On*` methods is the NPC's + // own classes (RunicNpcPlayer.OnDied overrides Rust's ScientistNPC), and Rust, not the + // framework, calls those. + const hooks = methods.filter((x) => HOOK_NAME.test(x.name) && !/\boverride\b/.test(x.returns)) const hookNames = new Set(hooks.map((x) => x.name)) // 1. Every hook the plugin implements is one `rnpc.status` can report on. diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js index f86785f..c7628cb 100644 --- a/scripts/checkPlugin.test.js +++ b/scripts/checkPlugin.test.js @@ -126,6 +126,25 @@ test('a method that is not shaped like a hook is left alone', () => { assert.ok(HOOK_NAME.test('CanBeTargeted')) }) +test('an override shaped like a hook belongs to the NPC classes, not the framework, and is left alone', () => { + const methods = ` private void OnServerInitialized() + { + } + + public class RunicNpcPlayer : ScientistNPC + { + public override void OnDied(HitInfo info) + { + } + + protected override void OnStateChanged() + { + } + }` + + assert.deepEqual(check(source({ methods }), toml()), []) +}) + // ── The API ──────────────────────────────────────────────────────────────── test('a public API call without [HookMethod] is caught, because Call cannot reach it', () => { @@ -287,12 +306,13 @@ test('the parser actually reads the real plugin, rather than quietly matching no // Raise these floors as the plugin grows; they are what stops a regex that // matches nothing from passing every case above. - assert.ok(methods.length >= 5, `only found ${methods.length} methods in the real plugin`) + assert.ok(methods.length >= 40, `only found ${methods.length} methods in the real plugin`) assert.ok(names.has('OnServerInitialized'), 'OnServerInitialized was not found by the method parser') assert.ok(names.has('CmdStatus'), 'CmdStatus (under an attribute) was not found by the method parser') const api = methods.filter((m) => API_NAME.test(m.name)).map((m) => m.name) assert.ok(api.includes('RunicNpc_ApiVersion'), 'RunicNpc_ApiVersion was not found as an API call') + assert.ok(api.includes('RunicNpc_Spawn'), 'RunicNpc_Spawn was not found as an API call') assert.ok(readExpectedHooks(real).length >= 1) assert.deepEqual(readRequires(real), ['Kits']) diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs new file mode 100644 index 0000000..a533d7c --- /dev/null +++ b/tools/RunicNpcTest.cs @@ -0,0 +1,802 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Oxide.Core; +using Oxide.Core.Plugins; +using UnityEngine; +using UnityEngine.AI; + +namespace Oxide.Plugins +{ + /// + /// RunicNPC's stage 2 test harness (docs/runicnpc/PLAN.md §9, stage 2). NEVER SHIPPED: it lives + /// in tools/ and is loaded on a rig by hand. It drives RunicNPC only through its API and hooks, + /// as any other plugin would, and prints one line per assertion: + /// + /// [rnt] <group>.<check>: PASS|FAIL <detail> + /// + /// Every line is also kept in data/RunicNpcTest.json, read back over the panel. + /// + /// + /// rnt.run <group> runs one group: api, hooks, move, + /// sentry, sleep, place, or all for each in turn. + /// rnt.run place leaves two placements behind for the restart check, and + /// rnt.after checks, after a reload or restart, that every placement is back and nothing + /// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and + /// rnt.clear removes everything the harness made. + /// + /// + /// + /// It needs the hidden Kits kit rnhrevolver (stage 1's: a revolver, pistol ammo and + /// burlap). It REPLACES RunicNPC's profiles with its own test set, which marks the server + /// managed: back up data/RunicNPC/ first and restore it afterwards. + /// + /// + /// + /// Targets and wake-ups are stand-ins, stage 1's fake players: player.prefab with + /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. + /// + /// + [Info("RunicNpcTest", "Runic Gateway", "0.2.0")] + [Description("RunicNPC stage 2 tests. A developer tool: never ship it.")] + internal class RunicNpcTest : RustPlugin + { + [PluginReference] private Plugin RunicNPC; + + private const string Kit = "rnhrevolver"; + private const string Owner = "plugin:RunicNpcTest"; + private const string PlayerPrefab = "assets/prefabs/player/player.prefab"; + private const ulong StandInId = 11400000101UL; + + private readonly List _lines = new List(); + private readonly HashSet _standIns = new HashSet(); + private readonly List _built = new List(); + private Coroutine _running; + private int _pass, _fail; + + // What RunicNPC's hooks told us. + private readonly List _spawned = new List(); + private readonly List _died = new List(); + private readonly List _health = new List(); + private readonly List _despawned = new List(); + private readonly Dictionary _lastContributors = new Dictionary(); + private int _hitsOnStandIns; + + private static readonly MethodInfo LifeStoryStart = + typeof(BasePlayer).GetMethod("LifeStoryStart", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + // ---- output ---- + + private void Check(string name, bool ok, string detail = "") + { + if (ok) _pass++; else _fail++; + string line = $"{name}: {(ok ? "PASS" : "FAIL")} {detail}"; + _lines.Add(line); + Puts("[rnt] " + line); + Interface.Oxide.DataFileSystem.WriteObject(Name, _lines); + } + + private void Note(string line) + { + _lines.Add("# " + line); + Puts("[rnt] # " + line); + Interface.Oxide.DataFileSystem.WriteObject(Name, _lines); + } + + // ---- lifecycle ---- + + private void Unload() + { + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + ClearWorld(); + } + + private void ClearWorld() + { + foreach (BasePlayer p in _standIns.ToList()) + if (p != null && !p.IsDestroyed) p.Kill(); + _standIns.Clear(); + foreach (BaseEntity e in _built) + if (e != null && !e.IsDestroyed) e.Kill(); + _built.Clear(); + } + + // ---- RunicNPC's API, typed ---- + + private T Api(string call, params object[] args) + { + object r = RunicNPC?.Call(call, args); + return r is T ? (T)r : default(T); + } + + private BasePlayer Spawn(Vector3 at, string profile, JObject overrides = null, string owner = Owner) => + Api("RunicNpc_Spawn", at, profile, owner, overrides); + + private List> List(string owner) => + Api>>("RunicNpc_List", owner) ?? new List>(); + + private Dictionary Describe(BasePlayer npc) => + npc == null ? null : List(null).FirstOrDefault(d => (ulong)d["netId"] == npc.net.ID.Value); + + private static Vector3 V(object o) + { + var d = (Dictionary)o; + return new Vector3(Convert.ToSingle(d["x"]), Convert.ToSingle(d["y"]), Convert.ToSingle(d["z"])); + } + + private static float Flat(Vector3 a, Vector3 b) => new Vector2(a.x - b.x, a.z - b.z).magnitude; + + // ---- places ---- + + private static bool SampleNavmesh(Vector3 p, float r, out NavMeshHit hit) => + Rust.Ai.Gen2.RustNavMeshHelpers.SamplePosition(p, out hit, r, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas); + + private static MonumentInfo[] _monuments; + + /// The map's monuments. (TerrainMeta.Path.Monuments is internal in Rust's own assembly.) + private static MonumentInfo[] Monuments() => _monuments ?? (_monuments = UnityEngine.Object.FindObjectsOfType()); + + /// A flat, dry spot on the navmesh far from every monument; fixed seed, so reruns land in the same field. + private static bool FindField(float clear, int seed, out Vector3 at) + { + var rng = new System.Random(seed); + float half = World.Size / 2f - 300f; + 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 || TerrainMeta.HeightMap.GetSlope(p) > 12f) continue; + if (Monuments().Any(m => Vector3.Distance(m.transform.position, p) < clear)) continue; + NavMeshHit hit; + if (!SampleNavmesh(p, 2f, out hit)) continue; + // The whole 35 m around it must be walkable too, for the wander and route tests. + bool open = true; + for (int k = 0; k < 8 && open; k++) + { + float a = k * Mathf.PI / 4f; + NavMeshHit h2; + open = SampleNavmesh(hit.position + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * 35f, 2f, out h2); + } + + if (!open) continue; + at = hit.position; + return true; + } + + at = Vector3.zero; + return false; + } + + /// A monument with an AI zone, where Rust's own roam has move points to follow. + private static bool FindMonument(out Vector3 at, out string name) + { + foreach (MonumentInfo m in Monuments().OrderBy(m => m.name.Contains("military") ? 0 : 1)) + { + if (m == null) continue; + AIInformationZone zone = AIInformationZone.GetForPoint(m.transform.position, false); + if (zone == null) continue; + NavMeshHit hit; + if (!SampleNavmesh(m.transform.position, 20f, out hit)) continue; + at = hit.position; + name = System.IO.Path.GetFileNameWithoutExtension(m.name); + return true; + } + + at = Vector3.zero; + name = null; + return false; + } + + private BasePlayer StandIn(Vector3 at, int index) + { + BasePlayer p = GameManager.server.CreateEntity(PlayerPrefab, at) as BasePlayer; + if (p == null) return null; + ulong id = StandInId + (ulong)index; + p.userID = id; + p.UserIDString = id.ToString(); + p.displayName = "RntStandIn" + index; + p.enableSaving = false; + p.Spawn(); + p.EndSleeping(); + LifeStoryStart?.Invoke(p, null); + _standIns.Add(p); + return p; + } + + private void RemoveStandIns() + { + foreach (BasePlayer p in _standIns.ToList()) + if (p != null && !p.IsDestroyed) p.Kill(); + _standIns.Clear(); + } + + // ---- hooks: RunicNPC's, counted; and the stand-ins made invulnerable ---- + + private void OnRunicNpcSpawned(BasePlayer npc, string profile, string owner) => _spawned.Add($"{profile}/{owner}"); + + private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) + { + _died.Add($"{profile}/{owner}"); + _lastContributors.Clear(); + foreach (KeyValuePair kv in contributors) _lastContributors[kv.Key] = kv.Value; + } + + private void OnRunicNpcHealth(BasePlayer npc, string profile, float fraction) => _health.Add($"{profile}@{fraction}"); + + private void OnRunicNpcDespawned(BasePlayer npc, string owner) => _despawned.Add(owner); + + private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info) + { + BasePlayer p = victim as BasePlayer; + if (p == null || !_standIns.Contains(p) || info == null) return; + if (info.Initiator is BasePlayer && ((BasePlayer)info.Initiator).IsNpc) _hitsOnStandIns++; + info.damageTypes.ScaleAll(0f); + } + + // ---- the profiles and routes the tests use ---- + + private static JObject Prof(string movement = "wander", float radius = 15f, string role = "roamer", string kit = Kit, string name = "TRoam") => + JObject.FromObject(new + { + names = new[] { name }, + kits = new[] { kit }, + prefab = "scientistnpc_roam", + role, + movement = new { mode = movement, radius }, + health = 250f, + damageDealt = 1f, + damageTaken = new { head = 2f, body = 0.5f, legs = 1f }, + aimCone = 2f, + ranges = new { sense = 30f, loseTarget = 40f, chase = 40f, attack = 30f }, + visionCone = -0.8f, + sleepDistance = 160f, + healthThresholds = new[] { 0.5f } + }); + + private Dictionary SetTestProfiles(bool withEach = true) + { + var all = new JObject + { + ["t_roam"] = Prof(), + ["t_sentry"] = Prof(role: "sentry", name: "TSentry"), + ["t_mon"] = Prof("monument", name: "TMon"), + ["t_route"] = Prof("route:t_loop", name: "TRoute"), + ["t_bad"] = Prof(kit: "rnt_nokit", name: "TBad") + }; + JObject leash = Prof(radius: 4f, name: "TLeash"); + leash["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 10f, attack = 4f }); + all["t_leash"] = leash; + if (withEach) + all["t_each"] = Prof(name: "TEach"); + return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "no answer" }; + } + + private string SetLoop(Vector3 centre) + { + var points = new JArray(); + foreach (Vector3 o in new[] { new Vector3(12, 0, 12), new Vector3(-12, 0, 12), new Vector3(-12, 0, -12), new Vector3(12, 0, -12) }) + { + NavMeshHit hit; + SampleNavmesh(centre + o, 3f, out hit); + points.Add(JObject.FromObject(new { x = hit.position.x, y = hit.position.y, z = hit.position.z })); + } + + return Api("RunicNpc_SetRoute", "t_loop", JObject.FromObject(new { points, loop = true })); + } + + // ---- commands ---- + + [ConsoleCommand("rnt.run")] + private void CmdRun(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + string group = arg.GetString(0, "all"); + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + _running = ServerMgr.Instance.StartCoroutine(Run(group)); + arg.ReplyWith($"rnt: running {group}"); + } + + [ConsoleCommand("rnt.after")] + private void CmdAfter(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + After(); + } + + /// Spawns n plugin-owned NPCs, for the check that a plugin's NPCs die with it. + [ConsoleCommand("rnt.spawn")] + private void CmdSpawn(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 field; + if (!FindField(300f, 2024, out field)) return; + int n = 0; + for (int i = 0; i < arg.GetInt(0, 3); i++) + if (Spawn(field + new Vector3(i * 2f, 0f, -30f), "t_roam") != null) n++; + arg.ReplyWith($"rnt: spawned {n} as {Owner}"); + } + + [ConsoleCommand("rnt.clear")] + private void CmdClear(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + ClearWorld(); + Api("RunicNpc_DespawnOwner", Owner); + foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2" }) + Api("RunicNpc_RemovePlacement", id); + Api("RunicNpc_RemoveRoute", "t_loop"); + arg.ReplyWith("rnt: cleared"); + } + + private IEnumerator Run(string group) + { + _pass = _fail = 0; + Note($"run {group} on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}"); + + Vector3 field; + if (!FindField(300f, 2024, out field)) + { + Check("setup.field", false, "no open field on the navmesh"); + yield break; + } + + Dictionary refused = SetTestProfiles(); + Check("setup.profiles", refused.Count == 1 && refused.ContainsKey("t_bad"), "refused: " + string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray())); + string route = SetLoop(field + new Vector3(60f, 0f, 0f)); + Check("setup.route", route == null, route ?? "t_loop set"); + + string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "place" } : new[] { group }; + foreach (string g in groups) + { + IEnumerator inner = g == "api" ? Api(field) + : g == "hooks" ? Hooks(field) + : g == "move" ? Move(field) + : g == "sentry" ? Sentry(field) + : g == "sleep" ? Sleep(field) + : g == "place" ? Place(field) + : null; + if (inner == null) + { + Check("setup.group", false, "no group " + g); + continue; + } + + while (inner.MoveNext()) yield return inner.Current; + Api("RunicNpc_DespawnOwner", Owner); + RemoveStandIns(); + yield return new WaitForSeconds(1f); + } + + Note($"done {group}: {_pass} pass, {_fail} fail"); + _running = null; + } + + // ---- api: every call's answer ---- + + private IEnumerator Api(Vector3 field) + { + Check("api.version", Api("RunicNpc_ApiVersion") == 2, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + JObject profiles = Api("RunicNpc_Profiles"); + Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); + Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]); + string warning = Api("RunicNpc_CostWarning", 10); + Check("api.costWarning", warning != null && warning.Contains("NPC(s)"), warning); + + BasePlayer npc = Spawn(field, "t_roam"); + Check("api.spawn", npc != null, npc != null ? npc.GetType().Name : "null"); + if (npc == null) yield break; + yield return new WaitForSeconds(2f); + + Check("api.isRunicNpc", Api("RunicNpc_IsRunicNpc", npc), ""); + Check("api.profileOf", Api("RunicNpc_ProfileOf", npc) == "t_roam", Api("RunicNpc_ProfileOf", npc)); + Check("api.name", npc.displayName == "TRoam", npc.displayName); + Check("api.health", Mathf.Approximately(npc.health, 250f) && Mathf.Approximately(npc.MaxHealth(), 250f), $"{npc.health}/{npc.MaxHealth()}"); + Check("api.notSaved", !npc.enableSaving, "enableSaving=" + npc.enableSaving); + Check("api.isNpc", npc.IsNpc, "IsNpc=" + npc.IsNpc); + HeldEntity held = npc.GetHeldEntity(); + Check("api.kit", held != null && held.ShortPrefabName.Contains("revolver"), held != null ? held.ShortPrefabName : "nothing held"); + Dictionary d = Describe(npc); + Check("api.list", d != null && (string)d["owner"] == Owner && (string)d["movement"] == "wander", d != null ? $"{d["owner"]} {d["movement"]} {d["state"]}" : "not listed"); + + BasePlayer ov = Spawn(field + new Vector3(4, 0, 0), "t_roam", JObject.FromObject(new { health = 500f, names = new[] { "Ov" } })); + yield return new WaitForSeconds(0.5f); + Check("api.overrides", ov != null && ov.displayName == "Ov" && Mathf.Approximately(ov.MaxHealth(), 500f), ov != null ? $"{ov.displayName} {ov.MaxHealth()}" : "null"); + + Check("api.refuse.placementOwner", Spawn(field, "t_roam", null, "placement:x") == null, ""); + Check("api.refuse.badOwner", Spawn(field, "t_roam", null, "nobody") == null, ""); + Check("api.refuse.unknownProfile", Spawn(field, "nope") == null, ""); + Check("api.refuse.refusedProfile", Spawn(field, "t_bad") == null, ""); + Check("api.refuse.badOverride", Spawn(field, "t_roam", JObject.FromObject(new { health = -5f })) == null, ""); + Check("api.refuse.offMesh", Spawn(field + Vector3.up * 40f, "t_roam") == null, "40 m in the air"); + + int listed = List(Owner).Count; + Check("api.listOwner", listed == 2, "listed " + listed); + Check("api.despawn", Api("RunicNpc_Despawn", ov.net.ID.Value) == 1, ""); + Check("api.despawnOwner", Api("RunicNpc_DespawnOwner", Owner) == 1, ""); + Check("api.gone", List(Owner).Count == 0, "listed " + List(Owner).Count); + } + + // ---- hooks: spawned, health threshold with contributors, damage by body part, died, despawned ---- + + private IEnumerator Hooks(Vector3 field) + { + _spawned.Clear(); _died.Clear(); _health.Clear(); _despawned.Clear(); + BasePlayer shooter = StandIn(field + new Vector3(0, 0, 60), 0); + BasePlayer npc = Spawn(field, "t_roam"); + yield return new WaitForSeconds(1f); + Check("hooks.spawned", _spawned.Contains("t_roam/" + Owner), string.Join(",", _spawned.ToArray())); + + // 100 bullet damage with no bone: body, scaled 0.5 → 50 (then Rust's own protection). + float before = npc.health; + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 100f)); + float body = before - npc.health; + Check("hooks.damageTaken.body", body > 5f && body <= 50.5f, $"took {body:0.0} of 100 (body scale 0.5)"); + + // To below half: the 0.5 threshold fires, once. + while (npc.health / npc.MaxHealth() > 0.45f) + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 60f)); + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 1f)); + Check("hooks.health", _health.Count == 1 && _health[0] == "t_roam@0.5", string.Join(",", _health.ToArray())); + + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f)); + yield return new WaitForSeconds(1f); + Check("hooks.died", _died.Contains("t_roam/" + Owner), string.Join(",", _died.ToArray())); + Check("hooks.contributors", _lastContributors.ContainsKey(StandInId) && _lastContributors[StandInId] > 240f && _lastContributors[StandInId] <= 250.5f, + string.Join(",", _lastContributors.Select(kv => kv.Key + "=" + kv.Value.ToString("0")).ToArray())); + Check("hooks.diedNotDespawned", _despawned.Count == 0, "despawned: " + string.Join(",", _despawned.ToArray())); + Check("hooks.unregisteredOnDeath", List(Owner).Count == 0, "listed " + List(Owner).Count); + + BasePlayer second = Spawn(field, "t_roam"); + yield return new WaitForSeconds(0.5f); + Api("RunicNpc_Despawn", second.net.ID.Value); + Check("hooks.despawned", _despawned.Count == 1 && _despawned[0] == Owner, string.Join(",", _despawned.ToArray())); + } + + // ---- move: wander stays within its radius, a route visits every point, a monument roams, a leash holds ---- + + private IEnumerator Move(Vector3 field) + { + // A stand-in 100 m off keeps them awake (inside 160 m) without being sensed (30 m). + StandIn(field + new Vector3(0, 0, 100), 1); + + BasePlayer wander = Spawn(field, "t_roam"); + BasePlayer route = Spawn(field + new Vector3(60, 0, 0), "t_route"); + Vector3 monument; + string monumentName; + BasePlayer mon = null; + if (FindMonument(out monument, out monumentName)) + { + StandIn(monument + new Vector3(0, 0, 100), 2); + mon = Spawn(monument, "t_mon"); + } + + Vector3 home = wander.transform.position; + Vector3 monHome = mon != null ? mon.transform.position : Vector3.zero; + float wanderMax = 0f, wanderMoved = 0f, monMax = 0f; + Route loop = JObject.FromObject(Api("RunicNpc_Routes")["t_loop"]).ToObject(); + var reached = new bool[loop.points.Count]; + var order = new List(); + + for (int t = 0; t < 90; t++) + { + yield return new WaitForSeconds(1f); + if (wander != null && !wander.IsDestroyed) + { + float d = Flat(wander.transform.position, home); + wanderMax = Mathf.Max(wanderMax, d); + wanderMoved = Mathf.Max(wanderMoved, d); + } + + if (route != null && !route.IsDestroyed) + for (int i = 0; i < loop.points.Count; i++) + if (Flat(route.transform.position, loop.points[i].V) < 2.5f) + { + reached[i] = true; + if (order.Count == 0 || order[order.Count - 1] != i) order.Add(i); + } + + if (mon != null && !mon.IsDestroyed) + monMax = Mathf.Max(monMax, Flat(mon.transform.position, monHome)); + } + + Check("move.wander.moves", wanderMoved > 2f, $"furthest {wanderMoved:0.0} m from its spot"); + Check("move.wander.radius", wanderMax <= 15f + 4f, $"furthest {wanderMax:0.0} m, radius 15"); + Check("move.route.everyPoint", reached.All(x => x), "reached " + string.Join("", reached.Select(x => x ? "1" : "0").ToArray()) + " order " + string.Join(">", order.Select(x => x.ToString()).ToArray())); + bool inOrder = true; + for (int i = 1; i < order.Count; i++) + if (order[i] != (order[i - 1] + 1) % loop.points.Count) inOrder = false; + Check("move.route.inOrder", order.Count >= 3 && inOrder, string.Join(">", order.Select(x => x.ToString()).ToArray())); + if (mon != null) + Check("move.monument.roams", monMax > 5f, $"{monumentName}: furthest {monMax:0.0} m (Rust's own roam)"); + else + Check("move.monument.roams", false, "no monument with an AI zone"); + + Api("RunicNpc_DespawnOwner", Owner); + RemoveStandIns(); + + // The leash: chase 10 m from home. A stand-in 25 m off draws it out; past 10 m it must turn back. + BasePlayer leash = Spawn(field, "t_leash"); + yield return new WaitForSeconds(1f); + Vector3 leashHome = leash.transform.position; + StandIn(leashHome + new Vector3(25, 0, 0), 3); + float leashMax = 0f; + for (int t = 0; t < 40; t++) + { + yield return new WaitForSeconds(1f); + if (leash == null || leash.IsDestroyed) break; + leashMax = Mathf.Max(leashMax, Flat(leash.transform.position, leashHome)); + } + + Dictionary d2 = Describe(leash); + int leashes = d2 != null ? Convert.ToInt32(d2["leashes"]) : -1; + Check("move.chase.pursues", leashMax >= 6f, $"went {leashMax:0.0} m toward a target 25 m off (attack 4, chase 10)"); + Check("move.chase.bounded", leashMax <= 10f + 4f, $"furthest {leashMax:0.0} m, chase 10"); + Check("move.leash.givesUp", leashes >= 1, $"gave up {leashes} time(s) on a target it could not reach"); + } + + // ---- sentry: holds its spot, shoots, and may stand off the navmesh ---- + + private IEnumerator Sentry(Vector3 field) + { + BasePlayer sentry = Spawn(field, "t_sentry"); + yield return new WaitForSeconds(1f); + Vector3 spot = sentry.transform.position; + _hitsOnStandIns = 0; + StandIn(spot + new Vector3(12, 0, 0), 4); + float moved = 0f; + for (int t = 0; t < 40; t++) + { + yield return new WaitForSeconds(1f); + moved = Mathf.Max(moved, Flat(sentry.transform.position, spot)); + } + + Check("sentry.holds", moved < 1f, $"moved {moved:0.00} m in 40 s with a target 12 m off"); + Check("sentry.shoots", _hitsOnStandIns > 0, $"{_hitsOnStandIns} hit(s) on the stand-in"); + Dictionary d = Describe(sentry); + Note("sentry state: " + (d != null ? d["state"] : "?")); + Api("RunicNpc_DespawnOwner", Owner); + RemoveStandIns(); + + // A player-built floor 4 m up, then a spot truly off the navmesh. + BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", field + new Vector3(0, 4f, 20f)); + if (floor == null) + { + Check("sentry.offMesh.floor", false, "the floor prefab made nothing"); + yield break; + } + + try + { + floor.enableSaving = false; + // An unsupported floor collapses at once under Rust's stability; grounded, it stays up. + StabilityEntity stable = floor as StabilityEntity; + if (stable != null) stable.grounded = true; + floor.Spawn(); + } + catch (Exception e) + { + Check("sentry.offMesh.floor", false, e.ToString()); + yield break; + } + + _built.Add(floor); + + // Stage 1 read a new floor as off the navmesh, sampling in the frame it spawned. How long + // does Rust's navmesh take to cover it? + Vector3 top = floor.transform.position + Vector3.up * 0.1f; + float spawnedAt = Time.realtimeSinceStartup; + bool covered = false; + int frames = 0; + while (!covered && Time.realtimeSinceStartup - spawnedAt < 10f) + { + NavMeshHit onTop; + covered = SampleNavmesh(top, 1f, out onTop) && Mathf.Abs(onTop.position.y - top.y) < 0.6f; + if (!covered) + { + frames++; + yield return null; + } + } + + if (floor == null || floor.IsDestroyed) + { + Check("sentry.floor", false, "the floor did not stay up"); + yield break; + } + + Check("sentry.floor.meshCovers", covered, covered + ? $"the navmesh covered a new floor 4 m up after {Time.realtimeSinceStartup - spawnedAt:0.00} s ({frames} frames)" + : "not covered within 10 s"); + BasePlayer roamer = Spawn(top, "t_roam"); + Check("sentry.floor.roamerStandsOnIt", !covered || (roamer != null && Mathf.Abs(roamer.transform.position.y - top.y) < 0.6f), + roamer != null ? $"a roamer placed at y={roamer.transform.position.y:0.00}, floor {top.y:0.00}" : "refused"); + if (roamer != null) Api("RunicNpc_Despawn", roamer.net.ID.Value); + + // Truly off the navmesh: 40 m up. A roamer is refused; a sentry is placed and stays. + Vector3 air = field + new Vector3(0f, 40f, -20f); + Check("sentry.offMesh.roamerRefused", Spawn(air, "t_roam") == null, "40 m up"); + BasePlayer up = Spawn(air, "t_sentry"); + Check("sentry.offMesh.sentryPlaced", up != null, ""); + if (up != null) + { + StandIn(field + new Vector3(0, 0, 100), 5); + yield return new WaitForSeconds(20f); + bool there = up != null && !up.IsDestroyed && Vector3.Distance(up.transform.position, air) < 1.5f; + Check("sentry.offMesh.stays", there, up != null && !up.IsDestroyed ? $"{Vector3.Distance(up.transform.position, air):0.00} m from its spot after 20 s" : "gone"); + } + } + + // ---- sleep: walk home past 160 m of any player, then sleep; wake when one comes back ---- + + private IEnumerator Sleep(Vector3 field) + { + BasePlayer keeper = StandIn(field + new Vector3(0, 0, 100), 6); + BasePlayer npc = Spawn(field, "t_roam", JObject.FromObject(new { movement = new { mode = "wander", radius = 30f } })); + yield return new WaitForSeconds(1f); + Vector3 home = npc.transform.position; + + // Let it wander off its spot. + float away = 0f; + for (int t = 0; t < 60 && away < 8f; t++) + { + yield return new WaitForSeconds(1f); + away = Flat(npc.transform.position, home); + } + + Check("sleep.awayFirst", away >= 3f, $"{away:0.0} m from home before the players leave"); + keeper.Kill(); + _standIns.Remove(keeper); + + string rest = ""; + bool sawGoingHome = false; + float maxStep = 0f; + Vector3 last = npc.transform.position; + for (int t = 0; t < 90; t++) + { + yield return new WaitForSeconds(1f); + maxStep = Mathf.Max(maxStep, Flat(npc.transform.position, last)); + last = npc.transform.position; + rest = (string)Describe(npc)["rest"]; + if (rest == "GoingHome") sawGoingHome = true; + if (rest == "Asleep") break; + } + + float fromHome = Flat(npc.transform.position, home); + Check("sleep.walksHome", sawGoingHome || away < 3f, "saw GoingHome=" + sawGoingHome); + Check("sleep.asleep", rest == "Asleep", "rest=" + rest); + Check("sleep.atHome", fromHome <= 3f, $"{fromHome:0.0} m from home"); + Check("sleep.walkedNotTeleported", maxStep < 8f, $"largest step {maxStep:0.0} m per second"); + + // Still for 10 s asleep. + Vector3 sleptAt = npc.transform.position; + yield return new WaitForSeconds(10f); + Check("sleep.still", Flat(npc.transform.position, sleptAt) < 0.5f, ""); + + StandIn(field + new Vector3(0, 0, 100), 7); + yield return new WaitForSeconds(4f); + rest = (string)Describe(npc)["rest"]; + Check("sleep.wakes", rest == "Awake", "rest=" + rest); + } + + // ---- place: each and group respawn, a missing profile waits, a missing route waits ---- + + private IEnumerator Place(Vector3 field) + { + Vector3 a = field + new Vector3(-40, 0, 0); + Vector3 b = field + new Vector3(-40, 0, 30); + StandIn(field + new Vector3(-40, 0, 110), 8); + Check("place.set.each", Api("RunicNpc_SetPlacement", "t_each", Placement("t_each", a, 3, 20f, "each")) == null, ""); + Check("place.set.group", Api("RunicNpc_SetPlacement", "t_group", Placement("t_roam", b, 3, 20f, "group")) == null, ""); + yield return new WaitForSeconds(4f); + + List> each = List("placement:t_each"); + List> group = List("placement:t_group"); + Check("place.spawned", each.Count == 3 && group.Count == 3, $"each {each.Count}, group {group.Count}"); + + // each: kill one; the other two stay, and it alone returns after ~20 s. + var survivors = new HashSet(each.Skip(1).Select(x => (ulong)x["netId"])); + Kill((ulong)each[0]["netId"]); + yield return new WaitForSeconds(2f); + Check("place.each.down", List("placement:t_each").Count == 2, "" + List("placement:t_each").Count); + yield return new WaitForSeconds(22f); + List> after = List("placement:t_each"); + Check("place.each.back", after.Count == 3 && survivors.All(id => after.Any(x => (ulong)x["netId"] == id)), $"{after.Count} alive, survivors kept={survivors.All(id => after.Any(x => (ulong)x["netId"] == id))}"); + + // group: kill one; nothing returns while two live. Kill the rest; all three return together. + Kill((ulong)group[0]["netId"]); + yield return new WaitForSeconds(24f); + Check("place.group.waitsForAll", List("placement:t_group").Count == 2, "" + List("placement:t_group").Count); + foreach (Dictionary x in List("placement:t_group")) Kill((ulong)x["netId"]); + yield return new WaitForSeconds(10f); + Check("place.group.notEarly", List("placement:t_group").Count == 0, "" + List("placement:t_group").Count); + yield return new WaitForSeconds(14f); + Check("place.group.back", List("placement:t_group").Count == 3, "" + List("placement:t_group").Count); + + // D237: remove t_each's profile. Its NPCs despawn and the placement waits; restore it and they return. + SetTestProfiles(withEach: false); + yield return new WaitForSeconds(2f); + Dictionary row = Row("t_each"); + Check("place.missingProfile.waits", List("placement:t_each").Count == 0 && row != null && row["waiting"] != null, row != null ? "" + row["waiting"] : "no row"); + SetTestProfiles(); + yield return new WaitForSeconds(4f); + Check("place.missingProfile.returns", List("placement:t_each").Count == 3, "" + List("placement:t_each").Count); + + JObject waitRoute = Placement("t_roam", a, 1, 20f, "each"); + waitRoute["movement"] = JObject.FromObject(new { mode = "route:t_nope", radius = 0f }); + Check("place.set.routeOverride", Api("RunicNpc_SetPlacement", "t_waitroute", waitRoute) == null, ""); + yield return new WaitForSeconds(2f); + row = Row("t_waitroute"); + Check("place.missingRoute.waits", row != null && ("" + row["waiting"]).Contains("route"), row != null ? "" + row["waiting"] : "no row"); + Api("RunicNpc_RemovePlacement", "t_waitroute"); + + Check("place.remove", Api("RunicNpc_RemovePlacement", "t_group") && List("placement:t_group").Count == 0, ""); + Check("place.refuse.badRespawnMode", Api("RunicNpc_SetPlacement", "t_x", Placement("t_roam", a, 1, 20f, "sometimes")) != null, ""); + + // Leave two for rnt.after, across a reload or a restart. + Api("RunicNpc_RemovePlacement", "t_each"); + Api("RunicNpc_SetPlacement", "t_keep1", Placement("t_roam", a, 2, 30f, "each")); + Api("RunicNpc_SetPlacement", "t_keep2", Placement("t_sentry", b, 1, 30f, "each")); + Note("left placements t_keep1 (2) and t_keep2 (1) for rnt.after"); + } + + private static JObject Placement(string profile, Vector3 at, int count, float respawn, string mode) => + JObject.FromObject(new { profile, position = new { x = at.x, y = at.y, z = at.z }, yaw = 0f, count, respawn, respawnMode = mode }); + + private Dictionary Row(string id) => + (Api>>("RunicNpc_Placements") ?? new List>()).FirstOrDefault(r => (string)r["id"] == id); + + private static void Kill(ulong netId) + { + BasePlayer p = BaseNetworkable.serverEntities.Find(new NetworkableId(netId)) as BasePlayer; + if (p != null && !p.IsDead()) p.Hurt(new HitInfo(null, p, Rust.DamageType.Bullet, 10000f)); + } + + /// + /// After a reload or restart: every placement is back to its count, every RunicNPC entity in + /// the world is one RunicNPC lists, and no plain scientist stands at a placement's spot (the + /// saved-subclass symptom stage 1 found). + /// + private void After() + { + _pass = _fail = 0; + List> rows = Api>>("RunicNpc_Placements") ?? new List>(); + foreach (Dictionary r in rows) + { + JObject p = (JObject)r["placement"]; + Check($"after.placement.{r["id"]}", Convert.ToInt32(r["alive"]) == (int)p["count"], $"{r["alive"]}/{p["count"]} alive, waiting={r["waiting"] ?? "-"}"); + } + + var listed = new HashSet(List(null).Select(d => (ulong)d["netId"])); + List world = BaseNetworkable.serverEntities.OfType().Where(p => p.GetType().Name == "RunicNpcPlayer").ToList(); + Check("after.worldMatchesRegistry", world.Count == listed.Count && world.All(p => listed.Contains(p.net.ID.Value)), $"world {world.Count}, listed {listed.Count}"); + + int strays = 0; + foreach (Dictionary r in rows) + { + Vector3 at = V(((JObject)r["placement"])["position"].ToObject>()); + strays += BaseNetworkable.serverEntities.OfType().Count(s => s.GetType() == typeof(ScientistNPC) && Vector3.Distance(s.transform.position, at) < 25f); + } + + Check("after.noSavedScientists", strays == 0, strays + " plain scientist(s) at placement spots"); + Check("after.pluginOwnedGone", List(Owner).Count == 0, List(Owner).Count + " left for " + Owner); + Note($"after: {_pass} pass, {_fail} fail"); + } + + /// A route, as RunicNpc_Routes returns it. + private sealed class Route + { + public List

points = new List

(); + public bool loop; + } + + private sealed class P + { + public float x, y, z; + public Vector3 V => new Vector3(x, y, z); + } + } +} diff --git a/tools/fieldlist/FieldList.csproj b/tools/fieldlist/FieldList.csproj new file mode 100644 index 0000000..71d72d1 --- /dev/null +++ b/tools/fieldlist/FieldList.csproj @@ -0,0 +1,18 @@ + + + + + Exe + net9.0 + enable + enable + + + + + + + diff --git a/tools/fieldlist/Program.cs b/tools/fieldlist/Program.cs new file mode 100644 index 0000000..a76a5df --- /dev/null +++ b/tools/fieldlist/Program.cs @@ -0,0 +1,98 @@ +// Writes the list of fields RunicNPC's swap copies from Rust's scientist to ours (D232). +// +// node tools/managed.js carbon

(once per Rust update) +// dotnet run --project tools/fieldlist -- [plugin/RunicNPC.cs] +// +// must hold the CARBON rig's managed assemblies: Oxide's patcher makes Rust's private fields +// public, so its Assembly-CSharp.dll no longer says what Rust itself serialises. +// +// The rule is stage 1's, which is Unity's own for a component's authored data: every instance +// field from the component's type down to (not including) MonoBehaviour that is public and not +// [NonSerialized], or carries [SerializeField]; never readonly, const or a delegate. Applied here to +// the unmodified assembly, it picks the same fields on both frameworks. The result replaces the +// block between the two marker lines in the plugin, and the plugin resolves each name at load. + +using System.Reflection; +using System.Text; + +if (args.Length < 1) +{ + Console.Error.WriteLine("usage: dotnet run --project tools/fieldlist -- [plugin/RunicNPC.cs]"); + return 2; +} + +string managed = args[0]; +string plugin = args.Length > 1 ? args[1] : Path.Combine("plugin", "RunicNPC.cs"); + +string[] dlls = Directory.GetFiles(managed, "*.dll"); +var resolver = new PathAssemblyResolver(dlls); +using var context = new MetadataLoadContext(resolver, "mscorlib"); +Assembly rust = context.LoadFromAssemblyPath(Path.Combine(managed, "Assembly-CSharp.dll")); + +List Fields(string typeName) +{ + Type type = rust.GetType(typeName, throwOnError: true)!; + var names = new List(); + for (Type? t = type; t != null && t.FullName != "UnityEngine.MonoBehaviour"; t = t.BaseType) + { + foreach (FieldInfo f in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + { + if (f.IsInitOnly || f.IsLiteral) + continue; + bool serialised = (f.IsPublic && (f.Attributes & FieldAttributes.NotSerialized) == 0) || + f.GetCustomAttributesData().Any(a => a.AttributeType.FullName == "UnityEngine.SerializeField"); + if (!serialised || IsDelegate(f.FieldType)) + continue; + names.Add($"{t.Name}.{f.Name}"); + } + } + return names; +} + +static bool IsDelegate(Type t) +{ + for (Type? b = t; b != null; b = b.BaseType) + if (b.FullName == "System.Delegate") + return true; + return false; +} + +List npc = Fields("ScientistNPC"); +List brain = Fields("ScientistBrain"); + +Guid mvid = rust.ManifestModule.ModuleVersionId; + +const string Open = " // "; +const string Close = " // "; + +var block = new StringBuilder(); +block.AppendLine(Open); +block.AppendLine($" // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module {mvid}."); +block.AppendLine(" // Do not edit by hand: regenerate after a Rust update (D232)."); +Append(block, "SwapNpcFields", npc); +Append(block, "SwapBrainFields", brain); +block.Append(Close); + +static void Append(StringBuilder sb, string name, List fields) +{ + sb.AppendLine($" private static readonly string[] {name} ="); + sb.AppendLine(" {"); + foreach (string f in fields) + sb.AppendLine($" \"{f}\","); + sb.AppendLine(" };"); +} + +string text = File.ReadAllText(plugin); +string nl = text.Contains("\r\n") ? "\r\n" : "\n"; +int start = text.IndexOf(Open, StringComparison.Ordinal); +int end = text.IndexOf(Close, StringComparison.Ordinal); +if (start < 0 || end < start) +{ + Console.Error.WriteLine($"{plugin}: the marker lines '{Open.Trim()}' and '{Close.Trim()}' were not found."); + return 1; +} + +string replacement = block.ToString().Replace("\r\n", "\n").Replace("\n", nl); +File.WriteAllText(plugin, text[..start] + replacement + text[(end + Close.Length)..]); +Console.WriteLine($"{plugin}: {npc.Count} NPC fields, {brain.Count} brain fields (Assembly-CSharp module {mvid})."); +return 0; diff --git a/tools/managed.js b/tools/managed.js new file mode 100644 index 0000000..ab490eb --- /dev/null +++ b/tools/managed.js @@ -0,0 +1,49 @@ +// Downloads a rig's managed assemblies (RustDedicated_Data/Managed/*.dll) into a local directory, +// for tools/fieldlist, which reads Rust's own field declarations from them (D232). +// +// node tools/managed.js +// +// Use the CARBON rig. Oxide's patcher rewrites Assembly-CSharp.dll and makes private fields public, +// so the Oxide rig's copy no longer says which fields Rust itself serialises. Carbon leaves the +// assembly as Facepunch shipped it. + +const fs = require('fs') +const path = require('path') +const { load, serverId, unmsys } = require('./panel') + +const config = load() +const [rig, out] = process.argv.slice(2).map(unmsys) +if (!rig || !out) { + console.error('usage: node tools/managed.js ') + process.exit(2) +} + +const base = `${config.panel}/api/client/servers/${serverId(config, rig)}` +const headers = { Authorization: 'Bearer ' + config.key, Accept: 'application/json' } +const dir = '/RustDedicated_Data/Managed' + +async function main() { + const list = await fetch(`${base}/files/list?directory=${encodeURIComponent(dir)}`, { headers }) + if (!list.ok) throw new Error(`list ${list.status}: ${await list.text()}`) + const files = (await list.json()).data + .map((f) => f.attributes) + .filter((f) => f.is_file && f.name.endsWith('.dll')) + + fs.mkdirSync(out, { recursive: true }) + let bytes = 0 + for (const f of files) { + const target = path.join(out, f.name) + if (fs.existsSync(target) && fs.statSync(target).size === f.size) continue + const link = await fetch(`${base}/files/download?file=${encodeURIComponent(`${dir}/${f.name}`)}`, { headers }) + if (!link.ok) throw new Error(`${f.name}: ${link.status} ${await link.text()}`) + const body = await fetch((await link.json()).attributes.url) + fs.writeFileSync(target, Buffer.from(await body.arrayBuffer())) + bytes += f.size + } + console.log(`${files.length} assemblies in ${out} (${(bytes / 1e6).toFixed(1)} MB downloaded)`) +} + +main().catch((e) => { + console.error(e.message || e) + process.exit(1) +}) -- 2.49.1 From 0a57d783ca900e2822ea008316cb3a8d7e715bda Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 03:17:43 -0500 Subject: [PATCH 03/24] feat: stage 3, the /rnpc commands in game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat command /rnpc and the same verbs as rnpc. in a console (PLAN.md §5), behind runicnpc.place and runicnpc.admin: - place / here with key=value options in any order (D242); placements made in game are named - and renamable (D241). - remove, rename, near, info (the NPC you look at), profiles, tp, respawn, clear, and rnpc.profile create|show|set|delete for a standalone server (refused while the site manages profiles, D221). - path record / point / undo / save [loop|back] / cancel / list / delete (D240). A point an NPC cannot walk to from the last one is refused, as is a loop that cannot close: Rust's path query says so. - A roamer may stand on a player-built floor, with a warning (D239). If the floor is destroyed under a live NPC, Rust leaves it standing in the air, so the brain puts it on the nearest navmesh within 2 s; a placement whose spot is off the mesh respawns on the nearest. - The API's SetPlacement now refuses a roamer off the navmesh, as the command does; placements report a note while they fall back. - Pos.V and Profile.IsSentry no longer leak into the JSON files. The harness gains the cmd and floor groups. Both rigs: 134/134 in one rnt.run all, rnpc reload 5/5, server restart 5/5. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 27 +- plugin/RunicNPC.cs | 1138 ++++++++++++++++++++++++++++++++++++++++- tools/RunicNpcTest.cs | 316 +++++++++++- 3 files changed, 1460 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f03544c..4124548 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,8 @@ works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicG website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is [`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). -> **Status: stage 2.** The NPC, its profiles, placements and routes, and the API other plugins call. -> The in-game `/rnpc` commands arrive in stage 3; until then profiles are edited in -> `data/RunicNPC/profiles.json` (then `rnpc.reload`), and placements and routes are made through the API. +> **Status: stage 3.** The NPC, its profiles, placements and routes, the API other plugins call, and +> the `/rnpc` commands admins use in game. Runic Gateway's website takes over the profiles in stage 4. ## Requirements @@ -44,6 +43,26 @@ Answers at the server console and over RCON: the version, the API version, and w plugin's hooks have fired. A hook that never fires is the first sign a Rust or framework update has renamed it, because neither framework reports a hook that matches nothing. +## In game + +`/rnpc` in chat, and the same verbs as `rnpc.` in a console (F1, the server console or RCON). +`/rnpc` alone lists the ones you may use. Grant `runicnpc.place` to place and manage NPCs, and +`runicnpc.admin` for everything (it includes `runicnpc.place`); the server console has both. + +``` +/rnpc place bandit count=3 respawn=300 mode=group move=route:gate + > Placed bandit-1: 3 × 'bandit' (roamer, route:gate), respawn 300 s, group. +/rnpc path record gate then walk, and at each point: /rnpc path point +/rnpc path save loop (or back, to walk it back and forth) +/rnpc near · /rnpc info · /rnpc rename bandit-1 gateguards · /rnpc remove +``` + +`place` uses the spot you look at, `here` the spot you stand on; the server console gives +`at=x,y,z`. A roamer must stand on Rust's navmesh; only a sentry may stand off it. Every placement +answers with what the server's NPCs cost. On a standalone server, profiles are made from a console +with `rnpc.profile create|set|delete`. The full list is in +[PLAN.md §5](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). + ## For other plugins Every call is prefixed `RunicNpc_` and reached through `Call`: @@ -66,7 +85,7 @@ The API version moves when a call or a raised hook changes shape, not on every r | `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. | | `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). | -| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | +| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2 and 3 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | ## After a Rust update: the swap's field list diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index 201f7c5..c893ace 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -20,10 +20,9 @@ namespace Oxide.Plugins /// RunicNPC — Runic Gateway's own NPC plugin for Rust, on Oxide and Carbon. /// /// - /// This is the stage 2 plugin (docs/runicnpc/PLAN.md §9): the NPC, its profiles, placements - /// and owners, and the API other plugins call. The in-game commands are stage 3's; until then - /// profiles live in data/RunicNPC/profiles.json, and placements and routes are made - /// through the API. + /// This is the stage 3 plugin (docs/runicnpc/PLAN.md §9): the NPC, its profiles, placements + /// and owners, the API other plugins call (stage 2), and the commands admins use in game + /// (stage 3, §5): /rnpc in chat and rnpc.<verb> in a console. /// /// /// @@ -157,7 +156,7 @@ namespace Oxide.Plugins [JsonProperty("y")] public float Y; [JsonProperty("z")] public float Z; - public Vector3 V => new Vector3(X, Y, Z); + [JsonIgnore] public Vector3 V => new Vector3(X, Y, Z); public static Pos Of(Vector3 v) => new Pos { X = v.x, Y = v.y, Z = v.z }; } @@ -201,7 +200,7 @@ namespace Oxide.Plugins [JsonProperty("sleepDistance")] public float SleepDistance = 160f; [JsonProperty("healthThresholds")] public List HealthThresholds = new List(); - public bool IsSentry => Role == "sentry"; + [JsonIgnore] public bool IsSentry => Role == "sentry"; } private sealed class ProfileFile @@ -890,6 +889,8 @@ namespace Oxide.Plugins { _nextCheck = now + 2f; _playerNear = npc.Profile.SleepDistance <= 0f || PlayerWithin(npc.transform.position, npc.Profile.SleepDistance); + if (!Sentry && !Navigator.IsOnNavMeshLink) + Reground(npc); } switch (Rest) @@ -942,6 +943,30 @@ namespace Oxide.Plugins base.Think(delta); } + /// + /// D239: the floor under a roamer was destroyed. Rust leaves the NPC standing in the air + /// where the floor was (stage 3 measured 15 s and counting), so it is put on the nearest + /// navmesh, as Rust's own navigator warps, and that spot is its home until it respawns. + /// + private void Reground(RunicNpcPlayer npc) + { + Vector3 at = npc.transform.position; + if (OffMeshReason(at) == null) + return; + NavMeshHit hit; + if (Navigator.Agent == null || !SampleNavmesh(at, Fallback, out hit)) + return; + + Navigator.Stop(); + Navigator.Agent.Warp(hit.position); + npc.transform.position = hit.position; + npc.Home = hit.position; + Regrounded++; + _self?.NoteRegrounded(npc, at, hit.position); + } + + public int Regrounded; + /// Past its chase range from home: forget the target and head back. public void Leash(RunicNpcPlayer npc) { @@ -1258,6 +1283,9 @@ namespace Oxide.Plugins public Slot[] Slots; public string Waiting; public string LastError; + + /// Set while its spot is off the navmesh and its NPCs stand on the nearest (D239). + public string Note; } private readonly Dictionary _placements = new Dictionary(); @@ -1438,6 +1466,15 @@ namespace Oxide.Plugins Interface.CallHook("OnRunicNpcDied", npc, npc.ProfileName, npc.Owner, info, contributors); } + private void NoteRegrounded(RunicNpcPlayer npc, Vector3 from, Vector3 to) + { + string text = $"its ground went from under {npc.RnName} ({from.y - to.y:0.0} m), who now stands on the nearest navmesh until it respawns"; + PlacementState ps; + if (npc.PlacementId != null && _placements.TryGetValue(npc.PlacementId, out ps)) + ps.Note = text; + Puts($"{npc.Owner}: {text}."); + } + private void CheckThresholds(RunicNpcPlayer npc) { float fraction = npc.health / npc.MaxHealth(); @@ -1630,10 +1667,28 @@ namespace Oxide.Plugins Vector3 at = spot; if (slot > 0) { + // A scattered spot that leaves a small floor takes the placement's own spot instead. Vector2 offset = UnityEngine.Random.insideUnitCircle * Scatter; at = spot + new Vector3(offset.x, 0f, offset.y); + if (!p.IsSentry && OffMeshReason(at) != null) + at = spot; } + // D239: the spot was on the navmesh when it was placed. If the world has changed under it (a + // player-built floor destroyed), the NPC stands on the nearest navmesh until the spot returns. + string off = p.IsSentry ? null : OffMeshReason(at); + NavMeshHit nearest; + if (off != null && SampleNavmesh(at, Fallback, out nearest)) + { + string note = $"its spot is off the navmesh now ({off}), so its NPCs stand on the nearest, {Meters(at, nearest.position)} away"; + if (ps.Note != note) + PrintWarning($"Placement '{ps.Id}': {note}."); + ps.Note = note; + at = nearest.position; + } + else if (off == null && slot == 0) + ps.Note = null; + string error; RunicNpcPlayer npc = Spawn(data.Profile, p, at, data.Yaw, "placement:" + ps.Id, data.Movement, ps.Id, slot, out error); if (npc == null) @@ -1683,6 +1738,7 @@ namespace Oxide.Plugins _self = this; foreach (string hook in ExpectedHooks) _hookCounts[hook] = 0L; + RegisterVerbs(); } private void OnServerInitialized() @@ -1816,7 +1872,8 @@ namespace Oxide.Plugins ["movement"] = n.MoveKind == "route" ? "route:" + n.RouteName : n.MoveKind, ["rest"] = brain != null ? brain.Rest.ToString() : "?", ["state"] = brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none", - ["leashes"] = brain != null ? brain.Leashes : 0 + ["leashes"] = brain != null ? brain.Leashes : 0, + ["regrounded"] = brain != null ? brain.Regrounded : 0 }; } @@ -1857,7 +1914,8 @@ namespace Oxide.Plugins ["placement"] = JObject.FromObject(ps.Data), ["alive"] = ps.Slots.Count(s => s.Npc != null), ["waiting"] = ps.Waiting, - ["lastError"] = ps.LastError + ["lastError"] = ps.LastError, + ["note"] = ps.Note }).ToList(); /// Adds or replaces a placement (D222). Null on success; otherwise why not. @@ -1875,10 +1933,28 @@ namespace Oxide.Plugins return "the placement could not be read: " + e.Message; } + return SetPlacement(id, data); + } + + /// + /// Adds or replaces a placement, for the API and rnpc place alike. A roamer's spot must + /// be on Rust's navmesh when it is set (D219); if the world changes under it later, its NPCs + /// stand on the nearest navmesh instead (D239, ). + /// + private string SetPlacement(string id, Placement data) + { string error = ValidatePlacement(data); if (error != null) return error; + Profile profile; + if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !profile.IsSentry) + { + string off = OffMeshReason(data.Position.V); + if (off != null) + return $"position: {off}, and only a sentry may stand off Rust's navmesh (D219)"; + } + PlacementState old; if (_placements.TryGetValue(id, out old)) Despawn(old.Slots.Select(s => s.Npc).Where(n => n != null)); @@ -1920,11 +1996,17 @@ namespace Oxide.Plugins return "the route could not be read: " + e.Message; } + return SetRoute(name, data); + } + + private string SetRoute(string name, Route data) + { string error = ValidateRoute(data); if (error != null) return error; _routeFile.Routes[name] = data; SaveRoutes(); + TickPlacements(); return null; } @@ -1945,6 +2027,1046 @@ namespace Oxide.Plugins #endregion + #region In game: the commands (PLAN.md §5, stage 3) + + // `/rnpc ` in chat and `rnpc.` in a console run the same code. Each verb sits behind a + // permission; the server console has them all. `runicnpc.admin` includes `runicnpc.place`. + + private const string PermPlace = "runicnpc.place"; + private const string PermAdmin = "runicnpc.admin"; + + /// How far an admin's look reaches, for place, info and remove. + private const float LookReach = 100f; + + /// How far below or around a spot the navmesh is looked for when saying why it is off it. + private const float Fallback = 20f; + + private static readonly int GroundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default", "Tree"); + private static readonly int BuiltMask = LayerMask.GetMask("Construction", "Deployed"); + + /// Who ran a command, and where the answer goes. + private sealed class Caller + { + /// Null for the server console. + public BasePlayer Player; + public bool Chat; + public readonly List Lines = new List(); + + public string Key => Player != null ? Player.UserIDString : "server"; + + public void Say(string line) => Lines.Add(line); + } + + private sealed class Verb + { + public string Permission; + public string Usage; + public Action Run; + public bool ConsoleOnly; + } + + private Dictionary _verbs; + + /// A route being recorded, per admin (D240). Kept in memory only: a reload discards it. + private sealed class Recording + { + public string Name; + public readonly List Points = new List(); + } + + private readonly Dictionary _recordings = new Dictionary(); + + private void RegisterVerbs() + { + permission.RegisterPermission(PermPlace, this); + permission.RegisterPermission(PermAdmin, this); + _verbs = new Dictionary + { + ["place"] = new Verb { Permission = PermPlace, Usage = "place [count= respawn= mode=each|group move=wander|monument|route: radius=]", Run = (c, a) => CmdPlace(c, a, false) }, + ["here"] = new Verb { Permission = PermPlace, Usage = "here [the same options]", Run = (c, a) => CmdPlace(c, a, true) }, + ["remove"] = new Verb { Permission = PermPlace, Usage = "remove [placement]", Run = CmdRemove }, + ["rename"] = new Verb { Permission = PermPlace, Usage = "rename ", Run = CmdRename }, + ["near"] = new Verb { Permission = PermPlace, Usage = "near [radius]", Run = CmdNear }, + ["info"] = new Verb { Permission = PermPlace, Usage = "info", Run = CmdInfo }, + ["profiles"] = new Verb { Permission = PermPlace, Usage = "profiles", Run = CmdProfiles }, + ["path"] = new Verb { Permission = PermPlace, Usage = "path record | point | undo | save [loop|back] | cancel | list | delete ", Run = CmdPath }, + ["tp"] = new Verb { Permission = PermAdmin, Usage = "tp ", Run = CmdTp }, + ["respawn"] = new Verb { Permission = PermAdmin, Usage = "respawn ", Run = CmdRespawn }, + ["clear"] = new Verb { Permission = PermAdmin, Usage = "clear ", Run = CmdClear }, + ["profile"] = new Verb { Permission = PermAdmin, Usage = "profile list | show | create [from=] | set | delete ", Run = CmdProfile, ConsoleOnly = true } + }; + } + + private bool Allowed(Caller c, string perm) => + c.Player == null || permission.UserHasPermission(c.Player.UserIDString, PermAdmin) || + (perm == PermPlace && permission.UserHasPermission(c.Player.UserIDString, PermPlace)); + + private void Dispatch(Caller c, string verb, string[] args) + { + Verb v; + if (verb == null || verb == "help" || !_verbs.TryGetValue(verb, out v)) + { + if (verb != null && verb != "help") + c.Say($"There is no '{verb}'."); + string prefix = c.Chat ? "/rnpc " : "rnpc."; + List mine = _verbs.Where(kv => Allowed(c, kv.Value.Permission) && !(c.Chat && kv.Value.ConsoleOnly)) + .Select(kv => prefix + kv.Value.Usage).ToList(); + if (mine.Count == 0) + c.Say("You have no RunicNPC permissions."); + else + c.Lines.AddRange(mine); + return; + } + + if (!Allowed(c, v.Permission)) + { + c.Say($"You need the {v.Permission} permission."); + return; + } + + if (c.Chat && v.ConsoleOnly) + { + c.Say($"'{verb}' is a console command: open the console (F1) and type rnpc.{verb}."); + return; + } + + if (!_ready) + { + c.Say("RunicNPC is still starting."); + return; + } + + try + { + v.Run(c, args); + } + catch (Exception e) + { + c.Say($"'{verb}' failed: {e.Message}"); + PrintError($"rnpc {verb} {string.Join(" ", args)}: {e}"); + } + } + + [ChatCommand("rnpc")] + private void CmdChat(BasePlayer player, string command, string[] args) + { + var c = new Caller { Player = player, Chat = true }; + Dispatch(c, args.Length > 0 ? args[0].ToLowerInvariant() : null, args.Skip(1).ToArray()); + if (c.Lines.Count > 0) + player.ChatMessage(string.Join("\n", c.Lines.ToArray())); + } + + private void RunConsole(ConsoleSystem.Arg arg, string verb) + { + BasePlayer player = arg.Player(); + if (arg.Connection != null && player == null) + return; + var c = new Caller { Player = player }; + // Rust's Args element type has changed between versions (string, then StringView), so + // read each through GetString, which both have. + int count = arg.Args?.Length ?? 0; + Dispatch(c, verb, Enumerable.Range(0, count).Select(i => arg.GetString(i)).ToArray()); + arg.ReplyWith(string.Join(Environment.NewLine, c.Lines.ToArray())); + } + + [ConsoleCommand("rnpc.place")] private void CcPlace(ConsoleSystem.Arg arg) => RunConsole(arg, "place"); + [ConsoleCommand("rnpc.here")] private void CcHere(ConsoleSystem.Arg arg) => RunConsole(arg, "here"); + [ConsoleCommand("rnpc.remove")] private void CcRemove(ConsoleSystem.Arg arg) => RunConsole(arg, "remove"); + [ConsoleCommand("rnpc.rename")] private void CcRename(ConsoleSystem.Arg arg) => RunConsole(arg, "rename"); + [ConsoleCommand("rnpc.near")] private void CcNear(ConsoleSystem.Arg arg) => RunConsole(arg, "near"); + [ConsoleCommand("rnpc.info")] private void CcInfo(ConsoleSystem.Arg arg) => RunConsole(arg, "info"); + [ConsoleCommand("rnpc.profiles")] private void CcProfiles(ConsoleSystem.Arg arg) => RunConsole(arg, "profiles"); + [ConsoleCommand("rnpc.path")] private void CcPath(ConsoleSystem.Arg arg) => RunConsole(arg, "path"); + [ConsoleCommand("rnpc.tp")] private void CcTp(ConsoleSystem.Arg arg) => RunConsole(arg, "tp"); + [ConsoleCommand("rnpc.respawn")] private void CcRespawn(ConsoleSystem.Arg arg) => RunConsole(arg, "respawn"); + [ConsoleCommand("rnpc.clear")] private void CcClear(ConsoleSystem.Arg arg) => RunConsole(arg, "clear"); + [ConsoleCommand("rnpc.profile")] private void CcProfile(ConsoleSystem.Arg arg) => RunConsole(arg, "profile"); + [ConsoleCommand("rnpc.help")] private void CcHelp(ConsoleSystem.Arg arg) => RunConsole(arg, "help"); + + // ---- where an admin stands and looks ---- + + /// The ground, a floor or a roof the admin is looking at, within . + private static bool LookSpot(BasePlayer player, out Vector3 spot) + { + RaycastHit hit; + if (Physics.Raycast(player.eyes.HeadRay(), out hit, LookReach, GroundMask, QueryTriggerInteraction.Ignore)) + { + spot = hit.point; + return true; + } + + spot = Vector3.zero; + return false; + } + + /// + /// Our NPC the admin is looking at: the nearest whose body is within a metre of the line of + /// sight. It reads positions, not colliders, so it finds an NPC behind a bush too. + /// + private RunicNpcPlayer LookNpc(BasePlayer player) + { + Ray ray = player.eyes.HeadRay(); + RunicNpcPlayer best = null; + float bestAlong = float.MaxValue; + foreach (RunicNpcPlayer npc in _npcs.Values) + { + if (npc == null || npc.IsDestroyed) + continue; + Vector3 body = npc.transform.position + Vector3.up * 1f; + Vector3 to = body - ray.origin; + float along = Vector3.Dot(to, ray.direction); + if (along <= 0f || along > LookReach) + continue; + float off = (to - ray.direction * along).magnitude; + if (off <= 1.2f && along < bestAlong) + { + best = npc; + bestAlong = along; + } + } + + return best; + } + + /// Null when a roamer may stand here (on Rust's navmesh, within 2 m up or down); otherwise why not. + private static string OffMeshReason(Vector3 spot) + { + NavMeshHit hit; + if (SampleNavmesh(spot, Scatter, out hit) && Mathf.Abs(hit.position.y - spot.y) <= MaxDrop) + return null; + if (!SampleNavmesh(spot, Fallback, out hit)) + { + float above = spot.y - TerrainMeta.HeightMap.GetHeight(spot); + return above > MaxDrop ? $"it is {above:0.0} m above the ground, with no navmesh within {Fallback:0} m" : $"there is no navmesh within {Fallback:0} m"; + } + + float dy = spot.y - hit.position.y; + if (dy > MaxDrop) return $"it is {dy:0.0} m above the navmesh"; + if (dy < -MaxDrop) return $"it is {-dy:0.0} m below the navmesh"; + return $"it is {new Vector2(hit.position.x - spot.x, hit.position.z - spot.z).magnitude:0.0} m from the navmesh"; + } + + private static Rust.Ai.Gen2.RustNavMeshPath _path; + + /// + /// Whether an NPC can walk from one point to the other on Rust's navmesh. A route leg that + /// fails this leaves its NPC standing at the last point it reached (stage 3: a route recorded + /// up a hill a player walked, and the NPC never left its first point). + /// + private static bool Walkable(Vector3 from, Vector3 to) + { + if (_path == null) + _path = new Rust.Ai.Gen2.RustNavMeshPath(); + return Rust.Ai.Gen2.RustNavMeshHelpers.CalculatePath(from, to, Rust.Ai.Gen2.RustNavMeshHelpers.AllAreas, _path) && + _path.status == NavMeshPathStatus.PathComplete; + } + + /// D239: a spot on something players built, which can be destroyed under the NPC. + private static bool IsPlayerBuilt(Vector3 spot) + { + RaycastHit hit; + if (!Physics.Raycast(spot + Vector3.up * 0.5f, Vector3.down, out hit, 2.5f, BuiltMask, QueryTriggerInteraction.Ignore)) + return false; + BaseEntity entity = hit.GetEntity(); + return entity != null && (entity is StabilityEntity || entity.OwnerID != 0UL); + } + + private static string Meters(Vector3 a, Vector3 b) => $"{Vector3.Distance(a, b):0} m"; + + private static bool TryFloat(string s, out float f) => + float.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out f) && !float.IsNaN(f) && !float.IsInfinity(f); + + // ---- place and here (D239, D241, D242) ---- + + private sealed class PlaceOptions + { + public int? Count; + public float? Respawn; + public string Mode; + public string Move; + public float? Radius; + public Vector3? At; + public float? Yaw; + } + + /// + /// D242's key=value options, in any order. The server console has no position, so it + /// alone may also give at=x,y,z and yaw=. + /// + private static string ParseOptions(Caller c, string[] args, int from, out PlaceOptions o) + { + o = new PlaceOptions(); + var seen = new HashSet(); + for (int i = from; i < args.Length; i++) + { + int eq = args[i].IndexOf('='); + if (eq <= 0) + return $"'{args[i]}' is not key=value. Options: count= respawn= mode= move= radius="; + string key = args[i].Substring(0, eq).ToLowerInvariant(); + string value = args[i].Substring(eq + 1); + if (!seen.Add(key)) + return $"{key}= is given twice"; + + float f; + switch (key) + { + case "count": + int n; + if (!int.TryParse(value, out n) || n < 1) return "count= must be a whole number, at least 1"; + o.Count = n; + break; + case "respawn": + if (!TryFloat(value, out f) || f < 1f) return "respawn= is in seconds, at least 1"; + o.Respawn = f; + break; + case "mode": + if (value != "each" && value != "group") return "mode= is each or group"; + o.Mode = value; + break; + case "move": + string kind, route; + if (!ParseMode(value, out kind, out route)) return "move= is wander, monument or route:"; + o.Move = value; + break; + case "radius": + if (!TryFloat(value, out f) || f <= 0f) return "radius= is in metres, above 0"; + o.Radius = f; + break; + case "at": + string[] xyz = value.Split(','); + float x, y, z; + if (c.Player != null) return "at= is for the server console; in game, look at the spot or stand on it"; + if (xyz.Length != 3 || !TryFloat(xyz[0], out x) || !TryFloat(xyz[1], out y) || !TryFloat(xyz[2], out z)) + return "at= is x,y,z"; + o.At = new Vector3(x, y, z); + break; + case "yaw": + if (c.Player != null) return "yaw= is for the server console"; + if (!TryFloat(value, out f)) return "yaw= is in degrees"; + o.Yaw = f; + break; + default: + return $"there is no option {key}=. Options: count= respawn= mode= move= radius="; + } + } + + return null; + } + + /// D241: the profile's name and the first free number. + private string NextPlacementId(string profile) + { + string stem = profile.Length > 34 ? profile.Substring(0, 34) : profile; + for (int n = 1; ; n++) + { + string id = $"{stem}-{n}"; + if (!_placementFile.Placements.ContainsKey(id) && !_placements.ContainsKey(id)) + return id; + } + } + + private void CmdPlace(Caller c, string[] args, bool here) + { + if (args.Length < 1) + { + c.Say($"Usage: {_verbs[here ? "here" : "place"].Usage}"); + return; + } + + string name = args[0]; + Profile p; + if (!_profiles.TryGetValue(name, out p)) + { + c.Say(_refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them."); + return; + } + + PlaceOptions o; + string error = ParseOptions(c, args, 1, out o); + if (error != null) + { + c.Say(error); + return; + } + + Vector3 spot; + float yaw; + if (o.At.HasValue) + { + spot = o.At.Value; + yaw = o.Yaw ?? 0f; + } + else if (c.Player == null) + { + c.Say("From the server console, give the spot as at=x,y,z."); + return; + } + else if (here) + { + spot = c.Player.transform.position; + yaw = c.Player.eyes.rotation.eulerAngles.y; + } + else + { + if (!LookSpot(c.Player, out spot)) + { + c.Say($"Look at the ground, a floor or a roof within {LookReach:0} m."); + return; + } + + // It faces the admin who placed it. + Vector3 back = c.Player.transform.position - spot; + yaw = back.sqrMagnitude > 0.01f ? Quaternion.LookRotation(new Vector3(back.x, 0f, back.z)).eulerAngles.y : 0f; + } + + Movement movement = null; + if (o.Move != null || o.Radius.HasValue) + { + if (p.IsSentry) + { + c.Say($"'{name}' is a sentry and never moves: drop move= and radius=."); + return; + } + + movement = new Movement { Mode = o.Move ?? p.Movement.Mode, Radius = o.Radius ?? p.Movement.Radius }; + error = ValidateMovement(movement); + if (error != null) + { + c.Say(error); + return; + } + } + + string kind, route; + ParseMode((movement ?? p.Movement).Mode, out kind, out route); + if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) + { + c.Say($"There is no route '{route}'. Record it first: rnpc path record {route}"); + return; + } + + if (!NavmeshReady()) + { + c.Say("The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly."); + return; + } + + bool built = false; + if (!p.IsSentry) + { + string off = OffMeshReason(spot); + if (off != null) + { + c.Say($"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219)."); + return; + } + + built = IsPlayerBuilt(spot); + } + + var data = new Placement + { + Profile = name, + Position = Pos.Of(spot), + Yaw = yaw, + Count = o.Count ?? 1, + Respawn = o.Respawn ?? 300f, + RespawnMode = o.Mode ?? "each", + Movement = movement + }; + + string id = NextPlacementId(name); + error = SetPlacement(id, data); + if (error != null) + { + c.Say("Not placed: " + error); + return; + } + + Movement m = movement ?? p.Movement; + c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " + + $"respawn {data.Respawn:0} s, {data.RespawnMode}. Rename it with rnpc rename {id} ."); + if (built) + c.Say("This spot is on a player-built structure. If it is destroyed, the NPC falls back to the nearest navmesh."); + c.Say(CostWarning(PlannedTotal())); + } + + // ---- remove, rename, near, info, profiles ---- + + /// A placement named in the command, or that of the NPC the admin is looking at. + private PlacementState NamedOrLooked(Caller c, string[] args, int at) + { + PlacementState ps; + if (args.Length > at) + { + if (_placements.TryGetValue(args[at], out ps)) + return ps; + c.Say($"There is no placement '{args[at]}'. rnpc near lists those around you."); + return null; + } + + if (c.Player == null) + { + c.Say("Name the placement."); + return null; + } + + RunicNpcPlayer npc = LookNpc(c.Player); + if (npc == null) + { + c.Say("Look at one of RunicNPC's NPCs, or name the placement."); + return null; + } + + if (npc.PlacementId == null || !_placements.TryGetValue(npc.PlacementId, out ps)) + { + c.Say($"{npc.RnName} is not from a placement: it belongs to {npc.Owner}. rnpc clear {npc.Owner} removes that owner's NPCs."); + return null; + } + + return ps; + } + + private void CmdRemove(Caller c, string[] args) + { + PlacementState ps = NamedOrLooked(c, args, 0); + if (ps == null) + return; + int alive = ps.Slots.Count(s => s.Npc != null); + RunicNpc_RemovePlacement(ps.Id); + c.Say($"Removed {ps.Id} and its {alive} NPC(s). {CostWarning(PlannedTotal())}"); + } + + private void CmdRename(Caller c, string[] args) + { + if (args.Length != 2) + { + c.Say("Usage: rnpc rename "); + return; + } + + string error = RenamePlacement(args[0], args[1]); + c.Say(error ?? $"Renamed {args[0]} to {args[1]}."); + } + + private string RenamePlacement(string from, string to) + { + PlacementState ps; + if (!_placements.TryGetValue(from, out ps)) + return $"There is no placement '{from}'."; + if (!NameRule.IsMatch(to)) + return "A name is 1–40 of a-z, 0-9, _ and -."; + if (_placements.ContainsKey(to) || _placementFile.Placements.ContainsKey(to)) + return $"'{to}' is taken."; + + _placements.Remove(from); + _placementFile.Placements.Remove(from); + ps.Id = to; + _placements[to] = ps; + _placementFile.Placements[to] = ps.Data; + foreach (Slot slot in ps.Slots) + { + if (slot.Npc == null) + continue; + slot.Npc.PlacementId = to; + slot.Npc.Owner = "placement:" + to; + } + + _placementsDirty = true; + Puts($"Placement '{from}' renamed to '{to}'."); + return null; + } + + private void CmdNear(Caller c, string[] args) + { + float radius = 50f; + if (args.Length > 0 && (!TryFloat(args[0], out radius) || radius <= 0f)) + { + c.Say("Usage: rnpc near [radius in metres]"); + return; + } + + if (c.Player == null) + { + c.Say("From the server console there is no 'near': rnpc.status counts everything."); + return; + } + + Vector3 here = c.Player.transform.position; + List places = _placements.Values.Where(ps => Vector3.Distance(ps.Data.Position.V, here) <= radius) + .OrderBy(ps => Vector3.Distance(ps.Data.Position.V, here)).ToList(); + List loose = _npcs.Values.Where(n => n.PlacementId == null && Vector3.Distance(n.transform.position, here) <= radius) + .OrderBy(n => Vector3.Distance(n.transform.position, here)).ToList(); + if (places.Count == 0 && loose.Count == 0) + { + c.Say($"Nothing of RunicNPC's within {radius:0} m."); + return; + } + + foreach (PlacementState ps in places) + { + Placement d = ps.Data; + string move = d.Movement != null ? $" · move {d.Movement.Mode}" : ""; + string state = ps.Waiting != null ? $" · waiting: {ps.Waiting}" : ps.LastError != null ? $" · {ps.LastError}" : ps.Note != null ? $" · {ps.Note}" : ""; + c.Say($"{ps.Id} · {d.Count} × {d.Profile} ({ps.Slots.Count(s => s.Npc != null)} alive) · {Meters(d.Position.V, here)} · respawn {d.Respawn:0} s {d.RespawnMode}{move}{state}"); + } + + foreach (RunicNpcPlayer n in loose) + c.Say($"{n.RnName} ({n.ProfileName}) · {n.Owner} · {Meters(n.transform.position, here)}"); + } + + private void CmdInfo(Caller c, string[] args) + { + if (c.Player == null) + { + c.Say("Info is for the NPC you look at, in game."); + return; + } + + RunicNpcPlayer npc = LookNpc(c.Player); + if (npc == null) + { + c.Say("Look at one of RunicNPC's NPCs."); + return; + } + + RunicNpcBrain brain = npc.RnBrain; + BaseEntity target = npc.GetBestTarget(); + string targetText = target == null ? "none" + : $"{(target is BasePlayer ? ((BasePlayer)target).displayName : target.ShortPrefabName)} {Meters(target.transform.position, npc.transform.position)}"; + string move = npc.Profile.IsSentry ? "holds its spot" : npc.MoveKind == "route" ? "route:" + npc.RouteName : npc.MoveKind == "wander" ? $"wander {npc.MoveRadius:0} m" : npc.MoveKind; + c.Say($"{npc.RnName} · profile {npc.ProfileName} ({npc.Profile.Role}) · {(npc.PlacementId != null ? "placement " + npc.PlacementId : npc.Owner)}"); + c.Say($"health {npc.health:0}/{npc.MaxHealth():0} · state {(brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none")} · " + + $"{(brain != null ? brain.Rest.ToString().ToLowerInvariant() : "?")} · {move} · kit {npc.Kit}"); + c.Say($"target {targetText} · {Meters(npc.transform.position, npc.Home)} from home"); + } + + private void CmdProfiles(Caller c, string[] args) + { + if (_profiles.Count == 0 && _refused.Count == 0) + { + c.Say(_profileFile.Managed ? "This server has no profiles yet; its website manages them." : "This server has no profiles yet. rnpc.profile create makes one."); + return; + } + + c.Say(_profileFile.Managed ? "Profiles (managed by this server's website):" : "Profiles:"); + foreach (KeyValuePair kv in _profiles.OrderBy(kv => kv.Key)) + { + Profile p = kv.Value; + c.Say($"{kv.Key} · {p.Role}{(p.IsSentry ? "" : " " + p.Movement.Mode)} · {p.Health:0} hp · kits {string.Join(", ", p.Kits.ToArray())}"); + } + + foreach (KeyValuePair kv in _refused.OrderBy(kv => kv.Key)) + c.Say($"{kv.Key} · refused: {kv.Value}"); + } + + // ---- tp, respawn, clear ---- + + private void CmdTp(Caller c, string[] args) + { + if (c.Player == null) + { + c.Say("Teleporting is for a player."); + return; + } + + PlacementState ps; + if (args.Length != 1 || !_placements.TryGetValue(args[0], out ps)) + { + c.Say(args.Length == 1 ? $"There is no placement '{args[0]}'." : "Usage: rnpc tp "); + return; + } + + c.Player.Teleport(ps.Data.Position.V + Vector3.up * 0.2f); + c.Say($"At {ps.Id}."); + } + + /// A placement's NPCs, despawned and spawned fresh now, without waiting for the delay. + private int RespawnNow(PlacementState ps) + { + Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); + foreach (Slot s in ps.Slots) + s.DiedAt = float.NegativeInfinity; + ps.LastError = null; + return ps.Slots.Length; + } + + private void CmdRespawn(Caller c, string[] args) + { + if (args.Length != 1) + { + c.Say("Usage: rnpc respawn "); + return; + } + + List which; + if (args[0] == "all") + which = _placements.Values.ToList(); + else + { + PlacementState ps; + if (!_placements.TryGetValue(args[0], out ps)) + { + c.Say($"There is no placement '{args[0]}'."); + return; + } + + which = new List { ps }; + } + + int n = which.Sum(RespawnNow); + TickPlacements(); + List waiting = which.Where(ps => ps.Waiting != null).Select(ps => $"{ps.Id} waits: {ps.Waiting}").ToList(); + c.Say($"Respawning {n} NPC(s) from {which.Count} placement(s)."); + c.Lines.AddRange(waiting); + } + + private void CmdClear(Caller c, string[] args) + { + if (args.Length != 1 || !(OwnerRule.IsMatch(args[0]) || args[0].StartsWith("placement:"))) + { + c.Say("Usage: rnpc clear , e.g. run:, plugin: or placement:"); + return; + } + + int n = Despawn(_npcs.Values.Where(x => x.Owner == args[0])); + c.Say(args[0].StartsWith("placement:") + ? $"Removed {n} NPC(s) of {args[0]}; the placement stays and respawns them after its delay (rnpc remove deletes it)." + : $"Removed {n} NPC(s) of {args[0]}."); + } + + // ---- path: recording a route point by point (D240) ---- + + private void CmdPath(Caller c, string[] args) + { + string sub = args.Length > 0 ? args[0].ToLowerInvariant() : null; + Recording rec; + _recordings.TryGetValue(c.Key, out rec); + + switch (sub) + { + case "record": + if (args.Length != 2 || !NameRule.IsMatch(args[1])) + { + c.Say("Usage: rnpc path record ; a name is 1–40 of a-z, 0-9, _ and -."); + return; + } + + _recordings[c.Key] = new Recording { Name = args[1] }; + c.Say($"Recording '{args[1]}'{(_routeFile.Routes.ContainsKey(args[1]) ? " (saving replaces the route of that name)" : "")}. " + + "Walk, and at each point type rnpc path point."); + return; + + case "point": + if (rec == null) + { + c.Say("Nothing is being recorded: rnpc path record first."); + return; + } + + Vector3 at; + if (c.Player != null && args.Length == 1) + at = c.Player.transform.position; + else + { + PlaceOptions o = null; + string error = args.Length == 2 ? ParseOptions(c, args, 1, out o) : "from the server console, give the point as at=x,y,z"; + if (error == null && (o == null || !o.At.HasValue)) error = "give the point as at=x,y,z"; + if (error != null) + { + c.Say(error); + return; + } + + at = o.At.Value; + } + + string off = OffMeshReason(at); + if (off != null) + { + c.Say($"Refused: {off}."); + return; + } + + NavMeshHit hit; + SampleNavmesh(at, Scatter, out hit); + if (rec.Points.Count > 0 && !Walkable(rec.Points[rec.Points.Count - 1], hit.position)) + { + c.Say($"Refused: an NPC cannot walk here from point {rec.Points.Count}; Rust's navmesh finds no path " + + "(a player can climb what an NPC cannot). Add a point between them, or choose another spot."); + return; + } + + rec.Points.Add(hit.position); + string gap = rec.Points.Count > 1 ? $", {Meters(rec.Points[rec.Points.Count - 2], hit.position)} from the last" : ""; + c.Say($"Point {rec.Points.Count} at ({hit.position.x:0}, {hit.position.y:0}, {hit.position.z:0}){gap}."); + return; + + case "undo": + if (rec == null || rec.Points.Count == 0) + { + c.Say("There is no point to undo."); + return; + } + + rec.Points.RemoveAt(rec.Points.Count - 1); + c.Say($"Dropped the last point; {rec.Points.Count} left."); + return; + + case "save": + if (rec == null) + { + c.Say("Nothing is being recorded."); + return; + } + + string how = args.Length > 1 ? args[1].ToLowerInvariant() : "loop"; + if (how != "loop" && how != "back") + { + c.Say("Usage: rnpc path save [loop|back]: loop returns from the last point to the first; back walks it back and forth."); + return; + } + + if (rec.Points.Count < 2) + { + c.Say($"A route needs at least two points; '{rec.Name}' has {rec.Points.Count}."); + return; + } + + if (how == "loop" && !Walkable(rec.Points[rec.Points.Count - 1], rec.Points[0])) + { + c.Say($"An NPC cannot walk from the last point back to the first, so '{rec.Name}' cannot loop. " + + "Add points that close the loop, or save it as back (back and forth)."); + return; + } + + var route = new Route { Loop = how == "loop", Points = rec.Points.Select(Pos.Of).ToList() }; + string refused = SetRoute(rec.Name, route); + if (refused != null) + { + c.Say("Not saved: " + refused); + return; + } + + _recordings.Remove(c.Key); + int users = _placements.Values.Count(ps => ps.Data.Movement != null && ps.Data.Movement.Mode == "route:" + rec.Name); + c.Say($"Saved '{rec.Name}': {route.Points.Count} points, {(route.Loop ? "loops" : "back and forth")}. " + + $"Place a patrol on it: rnpc place move=route:{rec.Name}{(users > 0 ? $" ({users} placement(s) already use it)" : "")}"); + return; + + case "cancel": + c.Say(_recordings.Remove(c.Key) ? $"Discarded '{rec.Name}'." : "Nothing is being recorded."); + return; + + case "list": + if (_routeFile.Routes.Count == 0) + c.Say("No routes yet: rnpc path record ."); + foreach (KeyValuePair kv in _routeFile.Routes.OrderBy(kv => kv.Key)) + { + int n = _placements.Values.Count(ps => ps.Data.Movement != null && ps.Data.Movement.Mode == "route:" + kv.Key) + + _placements.Values.Count(ps => ps.Data.Movement == null && _profiles.ContainsKey(ps.Data.Profile) && _profiles[ps.Data.Profile].Movement.Mode == "route:" + kv.Key); + string where = c.Player != null ? $" · first point {Meters(kv.Value.Points[0].V, c.Player.transform.position)}" : ""; + c.Say($"{kv.Key} · {kv.Value.Points.Count} points · {(kv.Value.Loop ? "loop" : "back and forth")} · {n} placement(s){where}"); + } + + if (rec != null) + c.Say($"Recording: '{rec.Name}', {rec.Points.Count} point(s), not saved."); + return; + + case "delete": + if (args.Length != 2) + { + c.Say("Usage: rnpc path delete "); + return; + } + + if (!RunicNpc_RemoveRoute(args[1])) + { + c.Say($"There is no route '{args[1]}'."); + return; + } + + TickPlacements(); + int waiting = _placements.Values.Count(ps => ps.Waiting != null && ps.Waiting.Contains($"route '{args[1]}'")); + c.Say($"Deleted '{args[1]}'.{(waiting > 0 ? $" {waiting} placement(s) on it now wait for a route of that name." : "")}"); + return; + + default: + c.Say("Usage: rnpc " + _verbs["path"].Usage); + return; + } + } + + // ---- profile: standalone edits from a console (D221) ---- + + private void CmdProfile(Caller c, string[] args) + { + string sub = args.Length > 0 ? args[0].ToLowerInvariant() : null; + if (sub == "list") + { + CmdProfiles(c, args); + return; + } + + if (sub == null || args.Length < 2) + { + c.Say("Usage: rnpc." + _verbs["profile"].Usage); + return; + } + + string name = args[1]; + Profile existing; + _profileFile.Profiles.TryGetValue(name, out existing); + if (sub == "show") + { + c.Say(existing == null ? $"There is no profile '{name}'." : JsonConvert.SerializeObject(existing, Formatting.Indented)); + if (existing != null && _refused.ContainsKey(name)) + c.Say($"It is refused: {_refused[name]}"); + return; + } + + if (_profileFile.Managed) + { + c.Say("This server's profiles are managed by its website (D221): edit them there."); + return; + } + + switch (sub) + { + case "create": + if (!NameRule.IsMatch(name)) + { + c.Say("A name is 1–40 of a-z, 0-9, _ and -."); + return; + } + + if (existing != null) + { + c.Say($"'{name}' exists: rnpc.profile set {name} changes it."); + return; + } + + Profile made = new Profile { Names = new List { "Scientist" } }; + if (args.Length > 2) + { + Profile from; + if (!args[2].StartsWith("from=") || !_profileFile.Profiles.TryGetValue(args[2].Substring(5), out from)) + { + c.Say(args[2].StartsWith("from=") ? $"There is no profile '{args[2].Substring(5)}'." : "Usage: rnpc.profile create [from=]"); + return; + } + + made = JObject.FromObject(from).ToObject(); + } + + _profileFile.Profiles[name] = made; + break; + + case "set": + if (existing == null) + { + c.Say($"There is no profile '{name}'."); + return; + } + + if (args.Length < 4) + { + c.Say("Usage: rnpc.profile set , e.g. health 200, ranges.sense 40, kits a,b, names Bob,Old Jim"); + return; + } + + string error = SetProfileField(name, existing, args[2], string.Join(" ", args.Skip(3).ToArray())); + if (error != null) + { + c.Say(error); + return; + } + + break; + + case "delete": + if (existing == null) + { + c.Say($"There is no profile '{name}'."); + return; + } + + _profileFile.Profiles.Remove(name); + break; + + default: + c.Say("Usage: rnpc." + _verbs["profile"].Usage); + return; + } + + SaveProfiles(); + RevalidateProfiles(); + TickPlacements(); + if (sub == "delete") + { + int waiting = _placements.Values.Count(ps => ps.Data.Profile == name); + c.Say($"Deleted '{name}'.{(waiting > 0 ? $" Its {waiting} placement(s) wait for a profile of that name (D237)." : "")}"); + } + else + c.Say(_refused.ContainsKey(name) ? $"Saved '{name}', but it cannot spawn yet: {_refused[name]}" : $"Saved '{name}'; it is in use."); + } + + /// One field of a profile, by its JSON path (health, ranges.sense, kits). + private string SetProfileField(string name, Profile profile, string field, string value) + { + JObject o = JObject.FromObject(profile); + JToken target = o.SelectToken(field); + if (target == null || target is JObject) + return target == null + ? $"A profile has no field '{field}'. Fields: {string.Join(", ", Flatten(o).ToArray())}" + : $"'{field}' has fields of its own: set them one at a time, e.g. {field}.{((JObject)target).Properties().First().Name}"; + + JToken replacement; + float f; + if (target is JArray) + { + string[] parts = value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray(); + if (field == "healthThresholds") + { + var numbers = new JArray(); + foreach (string s in parts) + { + if (!TryFloat(s, out f)) return $"'{s}' is not a number"; + numbers.Add(f); + } + + replacement = numbers; + } + else + replacement = new JArray(parts.Cast().ToArray()); + } + else if (target.Type == JTokenType.Float || target.Type == JTokenType.Integer) + { + if (!TryFloat(value, out f)) return $"'{field}' is a number"; + replacement = new JValue(f); + } + else + replacement = new JValue(value); + + target.Replace(replacement); + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + + private static IEnumerable Flatten(JObject o, string prefix = "") + { + foreach (JProperty prop in o.Properties()) + { + if (prop.Value is JObject) + foreach (string inner in Flatten((JObject)prop.Value, prefix + prop.Name + ".")) + yield return inner; + else + yield return prefix + prop.Name; + } + } + + #endregion + #region Console [ConsoleCommand("rnpc.status")] diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index a533d7c..bc635b1 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -13,9 +13,10 @@ using UnityEngine.AI; namespace Oxide.Plugins { /// - /// RunicNPC's stage 2 test harness (docs/runicnpc/PLAN.md §9, stage 2). NEVER SHIPPED: it lives - /// in tools/ and is loaded on a rig by hand. It drives RunicNPC only through its API and hooks, - /// as any other plugin would, and prints one line per assertion: + /// RunicNPC's test harness (docs/runicnpc/PLAN.md §9, stages 2 and 3). NEVER SHIPPED: it lives + /// in tools/ and is loaded on a rig by hand. It drives RunicNPC through its API, its hooks and its + /// console commands (the chat command through a stand-in player), and prints one line per + /// assertion: /// /// [rnt] <group>.<check>: PASS|FAIL <detail> /// @@ -23,7 +24,7 @@ namespace Oxide.Plugins /// /// /// rnt.run <group> runs one group: api, hooks, move, - /// sentry, sleep, place, or all for each in turn. + /// sentry, sleep, cmd (stage 3's commands), place, or all for each in turn. /// rnt.run place leaves two placements behind for the restart check, and /// rnt.after checks, after a reload or restart, that every placement is back and nothing /// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and @@ -41,8 +42,8 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [Info("RunicNpcTest", "Runic Gateway", "0.2.0")] - [Description("RunicNPC stage 2 tests. A developer tool: never ship it.")] + [Info("RunicNpcTest", "Runic Gateway", "0.3.0")] + [Description("RunicNPC stage 2 and 3 tests. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { [PluginReference] private Plugin RunicNPC; @@ -326,9 +327,10 @@ namespace Oxide.Plugins if (arg.Connection != null && !arg.IsAdmin) return; ClearWorld(); Api("RunicNpc_DespawnOwner", Owner); - foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2" }) + foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2", "t_roam-1", "t_roam-2", "t_gate", "t_sentry-1" }) Api("RunicNpc_RemovePlacement", id); Api("RunicNpc_RemoveRoute", "t_loop"); + Api("RunicNpc_RemoveRoute", "t_rec"); arg.ReplyWith("rnt: cleared"); } @@ -349,7 +351,7 @@ namespace Oxide.Plugins string route = SetLoop(field + new Vector3(60f, 0f, 0f)); Check("setup.route", route == null, route ?? "t_loop set"); - string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "place" } : new[] { group }; + string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "place" } : new[] { group }; foreach (string g in groups) { IEnumerator inner = g == "api" ? Api(field) @@ -357,6 +359,8 @@ namespace Oxide.Plugins : g == "move" ? Move(field) : g == "sentry" ? Sentry(field) : g == "sleep" ? Sleep(field) + : g == "cmd" ? Cmd(field) + : g == "floor" ? Floor(field) : g == "place" ? Place(field) : null; if (inner == null) @@ -367,7 +371,7 @@ namespace Oxide.Plugins while (inner.MoveNext()) yield return inner.Current; Api("RunicNpc_DespawnOwner", Owner); - RemoveStandIns(); + ClearWorld(); yield return new WaitForSeconds(1f); } @@ -743,6 +747,300 @@ namespace Oxide.Plugins Note("left placements t_keep1 (2) and t_keep2 (1) for rnt.after"); } + // ---- cmd: the stage 3 commands, through the server console and the chat path ---- + + private static string F(float f) => f.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture); + + private static string At(Vector3 v) => $"at={F(v.x)},{F(v.y)},{F(v.z)}"; + + /// Runs a RunicNPC console command as the server console and returns its answer. + private string Rnpc(string command) + { + string reply = ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), command) ?? ""; + Note($"> {command} => {reply.Replace(Environment.NewLine, " | ")}"); + return reply; + } + + private JObject PlacementData(string id) + { + Dictionary row = Row(id); + return row != null ? (JObject)row["placement"] : null; + } + + private IEnumerator Cmd(Vector3 field) + { + Vector3 a = OnMesh(field + new Vector3(40, 0, -40)); + Vector3 b = OnMesh(field + new Vector3(40, 0, -10)); + Vector3 air = a + Vector3.up * 40f; + + // A stand-in 60-130 m from every spot keeps the NPCs awake (D235) without being sensed. + StandIn(OnMesh(field + new Vector3(60, 0, 90)), 21); + + Check("cmd.help", Rnpc("rnpc.help").Contains("rnpc.path record"), ""); + Check("cmd.status", Rnpc("rnpc.status").Contains("placements="), ""); + + // D242 options and D241 names. + string r = Rnpc($"rnpc.place t_roam {At(a)} count=2 respawn=45 mode=group move=wander radius=8"); + JObject p1 = PlacementData("t_roam-1"); + Check("cmd.place.named", r.Contains("Placed t_roam-1") && p1 != null, ""); + Check("cmd.place.options", p1 != null && (int)p1["count"] == 2 && (float)p1["respawn"] == 45f && (string)p1["respawnMode"] == "group" && + (string)p1["movement"]?["mode"] == "wander" && (float)p1["movement"]?["radius"] == 8f, "" + p1?.ToString(Formatting.None)); + Check("cmd.place.costWarning", r.Contains("NPC(s) on this server"), ""); + r = Rnpc($"rnpc.place t_roam {At(b)} respawn=20"); + Check("cmd.place.nextName", r.Contains("Placed t_roam-2") && PlacementData("t_roam-2") != null, ""); + JObject p2 = PlacementData("t_roam-2"); + Check("cmd.place.defaults", p2 != null && (int)p2["count"] == 1 && (string)p2["respawnMode"] == "each" && p2["movement"] == null, "" + p2?.ToString(Formatting.None)); + + // Refusals, each with its reason; none may leave a placement behind. + int before = (Api>>("RunicNpc_Placements") ?? new List>()).Count; + Check("cmd.refuse.unknownProfile", Rnpc($"rnpc.place nope {At(a)}").Contains("no profile"), ""); + Check("cmd.refuse.refusedProfile", Rnpc($"rnpc.place t_bad {At(a)}").Contains("refused"), ""); + Check("cmd.refuse.unknownOption", Rnpc($"rnpc.place t_roam {At(a)} speed=3").Contains("no option speed="), ""); + Check("cmd.refuse.notKeyValue", Rnpc($"rnpc.place t_roam {At(a)} 3").Contains("not key=value"), ""); + Check("cmd.refuse.count", Rnpc($"rnpc.place t_roam {At(a)} count=0").Contains("count="), ""); + Check("cmd.refuse.twice", Rnpc($"rnpc.place t_roam {At(a)} count=1 count=2").Contains("twice"), ""); + Check("cmd.refuse.mode", Rnpc($"rnpc.place t_roam {At(a)} mode=sometimes").Contains("each or group"), ""); + Check("cmd.refuse.missingRoute", Rnpc($"rnpc.place t_roam {At(a)} move=route:t_none").Contains("no route 't_none'"), ""); + Check("cmd.refuse.sentryMoves", Rnpc($"rnpc.place t_sentry {At(a)} move=wander").Contains("sentry"), ""); + Check("cmd.refuse.offMesh", Rnpc($"rnpc.place t_roam {At(air)}").Contains("m above the"), ""); + Check("cmd.refuse.noSpot", Rnpc("rnpc.place t_roam").Contains("at=x,y,z"), ""); + Check("cmd.refuse.leftNothing", (Api>>("RunicNpc_Placements") ?? new List>()).Count == before, ""); + Check("cmd.sentryOffMesh", Rnpc($"rnpc.place t_sentry {At(air)}").Contains("Placed t_sentry-1"), "a sentry may stand 40 m up"); + Rnpc("rnpc.remove t_sentry-1"); + Check("cmd.refuse.api.offMesh", Api("RunicNpc_SetPlacement", "t_air", Placement("t_roam", air, 1, 20f, "each")) != null, "the API refuses a roamer off the navmesh too"); + + yield return new WaitForSeconds(4f); + Check("cmd.place.spawned", List("placement:t_roam-1").Count == 2 && List("placement:t_roam-2").Count == 1, + $"t_roam-1 {List("placement:t_roam-1").Count}, t_roam-2 {List("placement:t_roam-2").Count}"); + + // Rename (D241): the NPC keeps living and changes owner with it. + ulong renamedNpc = List("placement:t_roam-2").Select(d => (ulong)d["netId"]).FirstOrDefault(); + r = Rnpc("rnpc.rename t_roam-2 t_gate"); + List> gate = List("placement:t_gate"); + Check("cmd.rename", r.Contains("Renamed") && PlacementData("t_gate") != null && PlacementData("t_roam-2") == null, ""); + Check("cmd.rename.keepsNpc", gate.Count == 1 && (ulong)gate[0]["netId"] == renamedNpc && (string)gate[0]["placement"] == "t_gate", ""); + Check("cmd.rename.refuseTaken", Rnpc("rnpc.rename t_gate t_roam-1").Contains("taken"), ""); + Check("cmd.rename.refuseBadName", Rnpc("rnpc.rename t_gate Bad!Name").Contains("1–40"), ""); + Check("cmd.place.nameReused", Rnpc($"rnpc.place t_roam {At(b)}").Contains("Placed t_roam-2"), "the freed number is taken again"); + Rnpc("rnpc.remove t_roam-2"); + + // Recording a route (D240), from the console with at=. + // The hill route a first run recorded (points 12-17 m up): the patrol never left its first point. + // Recorded again, to note what the reachability check now answers. + Rnpc("rnpc.path record t_hill"); + foreach (Vector3 h in new[] { new Vector3(70, 0, -40), new Vector3(90, 0, -40), new Vector3(90, 0, -20) }) + Rnpc($"rnpc.path point {At(OnMesh(field + h))}"); + Rnpc("rnpc.path cancel"); + + Check("cmd.path.pointFirst", Rnpc($"rnpc.path point {At(a)}").Contains("Nothing is being recorded"), ""); + Check("cmd.path.record", Rnpc("rnpc.path record t_rec").Contains("Recording 't_rec'"), ""); + Vector3[] pts = { OnMesh(field + new Vector3(-25, 0, -25)), OnMesh(field + new Vector3(-5, 0, -25)), OnMesh(field + new Vector3(-5, 0, -5)), OnMesh(field + new Vector3(-25, 0, -5)) }; + Note("route points y: " + string.Join(", ", pts.Select(v => v.y.ToString("0.0")).ToArray())); + Check("cmd.path.point1", Rnpc($"rnpc.path point {At(pts[0])}").Contains("Point 1"), ""); + Check("cmd.path.saveTooFew", Rnpc("rnpc.path save").Contains("at least two"), ""); + Check("cmd.path.pointOffMesh", Rnpc($"rnpc.path point {At(pts[0] + Vector3.up * 30f)}").Contains("Refused"), ""); + Rnpc($"rnpc.path point {At(pts[1])}"); + Rnpc($"rnpc.path point {At(pts[3])}"); + Check("cmd.path.undo", Rnpc("rnpc.path undo").Contains("2 left"), ""); + Rnpc($"rnpc.path point {At(pts[2])}"); + Check("cmd.path.saveBadHow", Rnpc("rnpc.path save sideways").Contains("loop|back"), ""); + r = Rnpc("rnpc.path save back"); + JObject routes = Api("RunicNpc_Routes"); + JToken rec = routes?["t_rec"]; + Check("cmd.path.saved", r.Contains("Saved 't_rec': 3 points") && rec != null && ((JArray)rec["points"]).Count == 3 && !(bool)rec["loop"], "" + rec?.ToString(Formatting.None)); + Check("cmd.path.list", Rnpc("rnpc.path list").Contains("t_rec · 3 points"), ""); + + // A patrol on it. + r = Rnpc($"rnpc.place t_roam {At(pts[0])} move=route:t_rec respawn=20"); + Check("cmd.patrol.placed", r.Contains("Placed t_roam-2"), ""); + for (int s = 0; s < 4; s++) + { + yield return new WaitForSeconds(3f); + List> seen = List("placement:t_roam-2"); + Note(seen.Count == 1 ? $"patrol +{3 * (s + 1)} s: {Flat(V(seen[0]["position"]), pts[0]):0.0} m from point 0, state={seen[0]["state"]} rest={seen[0]["rest"]}" : "patrol: " + seen.Count + " listed"); + } + List> patrol = List("placement:t_roam-2"); + Check("cmd.patrol.walks", patrol.Count == 1 && (string)patrol[0]["movement"] == "route:t_rec" && Flat(V(patrol[0]["position"]), pts[0]) > 3f, + patrol.Count == 1 ? $"{patrol[0]["movement"]}, {Flat(V(patrol[0]["position"]), pts[0]):0.0} m from the first point" : "not spawned"); + r = Rnpc("rnpc.path delete t_rec"); + yield return new WaitForSeconds(2f); + Dictionary row = Row("t_roam-2"); + Check("cmd.path.deleteWaits", r.Contains("1 placement(s) on it now wait") && row != null && ("" + row["waiting"]).Contains("t_rec") && List("placement:t_roam-2").Count == 0, + row != null ? "" + row["waiting"] : "no row"); + Rnpc("rnpc.remove t_roam-2"); + + // respawn, clear, remove. + HashSet old = new HashSet(List("placement:t_roam-1").Select(d => (ulong)d["netId"])); + r = Rnpc("rnpc.respawn t_roam-1"); + yield return new WaitForSeconds(3f); + List> fresh = List("placement:t_roam-1"); + Check("cmd.respawn", r.Contains("Respawning 2") && fresh.Count == 2 && fresh.All(d => !old.Contains((ulong)d["netId"])), $"{fresh.Count} alive, fresh={fresh.All(d => !old.Contains((ulong)d["netId"]))}"); + r = Rnpc("rnpc.clear placement:t_gate"); + Check("cmd.clear", r.Contains("Removed 1") && List("placement:t_gate").Count == 0 && PlacementData("t_gate") != null, "the NPC goes, the placement stays"); + Check("cmd.clear.refuseBadOwner", Rnpc("rnpc.clear nobody").Contains("Usage"), ""); + r = Rnpc("rnpc.remove t_gate"); + Check("cmd.remove", r.Contains("Removed t_gate") && PlacementData("t_gate") == null, ""); + Check("cmd.remove.unknown", Rnpc("rnpc.remove t_gate").Contains("no placement"), ""); + Check("cmd.near.console", Rnpc("rnpc.near").Contains("server console"), ""); + Check("cmd.info.console", Rnpc("rnpc.info").Contains("in game"), ""); + + // The chat path, as a stand-in player: nothing without a permission, then place where it stands. + BasePlayer admin = StandIn(b, 20); + string uid = admin.UserIDString; + RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "here", "t_roam" }); + Check("cmd.chat.needsPermission", PlacementData("t_roam-2") == null, ""); + permission.GrantUserPermission(uid, "runicnpc.place", null); + RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "here", "t_roam", "count=1" }); + JObject chatPlaced = PlacementData("t_roam-2"); + Check("cmd.chat.here", chatPlaced != null && Flat(V(chatPlaced["position"].ToObject>()), b) < 1f, "" + chatPlaced?.ToString(Formatting.None)); + RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "respawn", "t_roam-2" }); + Check("cmd.chat.adminNeedsAdmin", Row("t_roam-2") != null, "respawn is refused with runicnpc.place alone (no error)"); + RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "remove", "t_roam-2" }); + Check("cmd.chat.remove", PlacementData("t_roam-2") == null, ""); + permission.RevokeUserPermission(uid, "runicnpc.place"); + admin.Kill(); + _standIns.Remove(admin); + + // Standalone profile edits (D221): refused while managed, then allowed once the file says it is not. + Check("cmd.profile.managed", Rnpc("rnpc.profile create t_new").Contains("managed"), ""); + Check("cmd.profile.show", Rnpc("rnpc.profile show t_roam").Contains("\"names\""), ""); + JObject file = Api("RunicNpc_Profiles"); + file.Remove("refused"); + file["managed"] = false; + Interface.Oxide.DataFileSystem.WriteObject("RunicNPC/profiles", file); + Rnpc("rnpc.reload"); + Check("cmd.profile.create", Rnpc("rnpc.profile create t_new from=t_roam").Contains("in use"), ""); + Rnpc("rnpc.profile set t_new health 321"); + Rnpc("rnpc.profile set t_new names Old Jim,Bob"); + Rnpc("rnpc.profile set t_new ranges.sense 44"); + JToken made = Api("RunicNpc_Profiles")?["profiles"]?["t_new"]; + Check("cmd.profile.set", made != null && (float)made["health"] == 321f && (float)made["ranges"]["sense"] == 44f && + made["names"].ToObject().SequenceEqual(new[] { "Old Jim", "Bob" }), "" + made?.ToString(Formatting.None)); + Check("cmd.profile.set.noField", Rnpc("rnpc.profile set t_new nope 1").Contains("no field"), ""); + Check("cmd.profile.set.object", Rnpc("rnpc.profile set t_new ranges 1").Contains("fields of its own"), ""); + Check("cmd.profile.set.refusedKit", Rnpc("rnpc.profile set t_new kits rnt_nokit").Contains("cannot spawn yet"), ""); + Check("cmd.profile.delete", Rnpc("rnpc.profile delete t_new").Contains("Deleted") && Api("RunicNpc_Profiles")?["profiles"]?["t_new"] == null, ""); + SetTestProfiles(); + + IEnumerator floorTest = Floor(field); + while (floorTest.MoveNext()) yield return floorTest.Current; + } + + /// + /// D239: a roamer placed on a player-built floor is told so. The floor is then destroyed under + /// a live NPC (what happens to it is measured, not asserted), and after its death the placement + /// falls back to the nearest navmesh until the floor returns. + /// + private IEnumerator Floor(Vector3 field) + { + // Over dry land: a first run put it over water (terrain −0.25 m), and Rust's navmesh never + // covered it there in 10 s. + Vector3 at = Vector3.zero; + foreach (Vector3 o in new[] { new Vector3(0, 0, 20), new Vector3(-20, 0, 20), new Vector3(20, 0, 0), new Vector3(0, 0, -20), new Vector3(-20, 0, -20) }) + { + Vector3 ground = OnMesh(field + o); + if (ground.y > TerrainMeta.WaterMap.GetHeight(ground) + 1f) + { + at = ground + Vector3.up * 4f; + break; + } + } + + BaseEntity floor = at == Vector3.zero ? null : BuildFloor(at); + if (floor == null) + { + Check("cmd.floor.built", false, "no floor"); + yield break; + } + + Vector3 top = floor.transform.position + Vector3.up * 0.1f; + float t0 = Time.realtimeSinceStartup; + NavMeshHit onTop; + bool covered = false; + while (!(covered = SampleNavmesh(top, 1f, out onTop) && Mathf.Abs(onTop.position.y - top.y) < 0.6f) && Time.realtimeSinceStartup - t0 < 10f) + yield return null; + Note($"floor at y={top.y:0.00} over terrain {TerrainMeta.HeightMap.GetHeight(top):0.00}, owner {floor.OwnerID}: " + + (covered ? $"covered after {Time.realtimeSinceStartup - t0:0.00} s" : "NOT covered within 10 s")); + + // A route leg an NPC cannot walk is refused: the floor has no stairs, so its top is on the + // navmesh but not reachable from the ground beside it. + Rnpc("rnpc.path record t_up"); + Rnpc($"rnpc.path point {At(OnMesh(top + new Vector3(8f, 0f, 0f)))}"); + Check("cmd.path.unreachableRefused", Rnpc($"rnpc.path point {At(top)}").Contains("cannot walk here"), "ground to the top of a floor with no stairs"); + Rnpc("rnpc.path cancel"); + + string r = Rnpc($"rnpc.place t_roam {At(top)} respawn=5 move=wander radius=2"); + string id = System.Text.RegularExpressions.Regex.Match(r, @"Placed (\S+):").Groups[1].Value; + Check("cmd.floor.warns", r.Contains("player-built structure"), id); + StandIn(OnMesh(field + new Vector3(60, 0, 90)), 22); + yield return new WaitForSeconds(3f); + List> on = List("placement:" + id); + Check("cmd.floor.standsOnIt", on.Count == 1 && Mathf.Abs(V(on[0]["position"]).y - top.y) < 0.8f, on.Count == 1 ? $"y={V(on[0]["position"]).y:0.00}, floor {top.y:0.00}" : "not spawned"); + + // D239 under a live NPC: Rust leaves it standing in the air; RunicNPC puts it on the nearest + // navmesh within its 2 s check. + ulong liveId = on.Count == 1 ? (ulong)on[0]["netId"] : 0UL; + floor.Kill(); + _built.Remove(floor); + float downAfter = -1f; + for (int s = 1; s <= 10; s++) + { + yield return new WaitForSeconds(1f); + on = List("placement:" + id); + if (on.Count == 1 && downAfter < 0f && V(on[0]["position"]).y < top.y - 2f) + downAfter = s; + if (s == 1 || s == downAfter || s == 10) + Note(on.Count == 1 + ? $"floor gone +{s} s: y={V(on[0]["position"]).y:0.00} (floor was {top.y:0.00}) state={on[0]["state"]} health={Convert.ToSingle(on[0]["health"]):0} regrounded={on[0]["regrounded"]}" + : $"floor gone +{s} s: {on.Count} NPC(s) listed"); + } + + on = List("placement:" + id); + Check("cmd.floor.liveRegrounds", on.Count == 1 && (ulong)on[0]["netId"] == liveId && downAfter > 0f && downAfter <= 4f && Row(id)?["note"] != null, + downAfter > 0f ? $"on the ground {downAfter:0} s after the floor went, the same NPC; note: {Row(id)?["note"]}" : "still in the air after 10 s"); + + foreach (Dictionary d in List("placement:" + id)) + Kill((ulong)d["netId"]); + yield return new WaitForSeconds(9f); + on = List("placement:" + id); + Dictionary row = Row(id); + Check("cmd.floor.fallsBack", on.Count == 1 && V(on[0]["position"]).y < top.y - 2f && row != null && row["note"] != null, + on.Count == 1 ? $"respawned at y={V(on[0]["position"]).y:0.00}; note: {row?["note"]}" : $"{on.Count} alive; lastError={row?["lastError"]}"); + + // The floor is rebuilt: the next respawn is back on it, and the note clears. + floor = BuildFloor(at); + yield return new WaitForSeconds(1f); + Rnpc("rnpc.respawn " + id); + yield return new WaitForSeconds(3f); + on = List("placement:" + id); + row = Row(id); + Check("cmd.floor.returns", on.Count == 1 && Mathf.Abs(V(on[0]["position"]).y - top.y) < 0.8f && row?["note"] == null, + on.Count == 1 ? $"y={V(on[0]["position"]).y:0.00}; note: {row?["note"] ?? "-"}" : "not spawned"); + Rnpc("rnpc.remove " + id); + Rnpc("rnpc.remove t_roam-1"); + } + + private BaseEntity BuildFloor(Vector3 at) + { + BaseEntity floor = GameManager.server.CreateEntity("assets/prefabs/building core/floor/floor.prefab", at); + if (floor == null) return null; + floor.enableSaving = false; + // An unsupported floor collapses at once under Rust's stability; grounded, it stays up. + StabilityEntity stable = floor as StabilityEntity; + if (stable != null) stable.grounded = true; + floor.OwnerID = StandInId; + floor.Spawn(); + _built.Add(floor); + return floor; + } + + private static Vector3 OnMesh(Vector3 p) + { + p.y = TerrainMeta.HeightMap.GetHeight(p); + NavMeshHit hit; + return SampleNavmesh(p, 3f, out hit) ? hit.position : p; + } + private static JObject Placement(string profile, Vector3 at, int count, float respawn, string mode) => JObject.FromObject(new { profile, position = new { x = at.x, y = at.y, z = at.z }, yaw = 0f, count, respawn, respawnMode = mode }); -- 2.49.1 From fe20ca41af7cce90e4ab18c6b0a405c8565491c6 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 04:01:30 -0500 Subject: [PATCH 04/24] feat: API 3, placements from a website (stage 4, D249) RunicNpc_AddPlacement creates a placement and names it as /rnpc place does (D241, D246). A position without y is a map point: it is put on the ground there (terrain and rock, never a building or a tree, D245), then checked against the navmesh like an in-game placement. It answers the id, the grounded position, whether the spot is player-built, and the cost warning, or the in-game refusal sentence. RunicNpc_RenamePlacement and RunicNpc_RespawnPlacement do what rnpc rename and rnpc respawn do. OnRunicNpcPlacementChanged(id, change, previous) is raised on every set, remove and rename, from the API or in game, so the bridge can tell the site at once. rnpc place and here now share CreatePlacement with the API, so both give the same refusals. The test harness gains an api3 group; all 157 checks pass on the Oxide and Carbon rigs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 12 +-- plugin.toml | 6 +- plugin/RunicNPC.cs | 174 ++++++++++++++++++++++++++++++++++-------- tools/RunicNpcTest.cs | 116 ++++++++++++++++++++++++++-- 4 files changed, 264 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 4124548..a2acee8 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicG website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is [`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). -> **Status: stage 3.** The NPC, its profiles, placements and routes, the API other plugins call, and -> the `/rnpc` commands admins use in game. Runic Gateway's website takes over the profiles in stage 4. +> **Status: stage 4.** The NPC, its profiles, placements and routes, the API other plugins call, and +> the `/rnpc` commands admins use in game. On a Runic Gateway server, the website takes over the +> profiles and lists, edits and creates placements through the bridge (API 3). ## Requirements @@ -73,9 +74,10 @@ Every call is prefixed `RunicNpc_` and reached through `Call`: int api = RunicNPC?.Call("RunicNpc_ApiVersion") ?? 0; ``` -The API is version 2, documented in +The API is version 3, documented in [`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md): -spawning and removing NPCs by owner, profiles, placements, routes, the cost warning, and the hooks it raises. +spawning and removing NPCs by owner, profiles, placements (created and named as in game, from a map point), +routes, the cost warning, and the hooks it raises. The API version moves when a call or a raised hook changes shape, not on every release. ## Repository layout @@ -85,7 +87,7 @@ The API version moves when a call or a raised hook changes shape, not on every r | `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. | | `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). | -| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2 and 3 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | +| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2, 3 and 4 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | ## After a Rust update: the swap's field list diff --git a/plugin.toml b/plugin.toml index 062206a..82d231e 100644 --- a/plugin.toml +++ b/plugin.toml @@ -23,8 +23,10 @@ # lets a bundle check the pair BEFORE an operator installs it. # # 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else. -# Current: 2 — stage 2's whole API, PLAN.md §4, documented in docs/runicnpc/API.md. -api = 2 +# 2 was stage 2's whole API, PLAN.md §4. +# Current: 3 — stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the +# OnRunicNpcPlacementChanged hook, documented in docs/runicnpc/API.md. +api = 3 # ── Framework floors ───────────────────────────────────────────────────────── # diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index c893ace..d40df64 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -58,7 +58,7 @@ namespace Oxide.Plugins /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// - private const int ApiVersion = 2; + private const int ApiVersion = 3; /// /// Every hook this plugin implements. rnpc.status reports which have fired, because @@ -1964,6 +1964,7 @@ namespace Oxide.Plugins _placementsDirty = true; Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}"); TickPlacements(); + PlacementChanged(id, "set"); return null; } @@ -1976,9 +1977,96 @@ namespace Oxide.Plugins _placementFile.Placements.Remove(id); _placementsDirty = true; Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); + PlacementChanged(id, "removed"); return true; } + /// + /// API 3 (D249): creates a placement and names it as rnpc place does (D241, D246). A + /// position without y is a point on a map: it is put on the ground there (terrain and + /// rock, never a building, D245) and then checked against the navmesh like any other. + /// Answers {id, position, built, cost}, or {error}. + /// + private Dictionary RunicNpc_AddPlacement(JObject placement) + { + Placement data; + try + { + data = placement?.ToObject(); + } + catch (Exception e) + { + return new Dictionary { ["error"] = "the placement could not be read: " + e.Message }; + } + + if (data != null && data.Position != null && placement["position"]?["y"] == null) + { + Vector3 ground; + string off = GroundAt(data.Position.X, data.Position.Z, out ground); + if (off != null) + return new Dictionary { ["error"] = off }; + data.Position = Pos.Of(ground); + } + + string id; + bool built; + string error = CreatePlacement(data, out id, out built); + if (error != null) + { + PrintWarning($"RunicNpc_AddPlacement('{data?.Profile}') refused: {error}"); + return new Dictionary { ["error"] = error }; + } + + return new Dictionary + { + ["id"] = id, + ["position"] = new Dictionary { ["x"] = data.Position.X, ["y"] = data.Position.Y, ["z"] = data.Position.Z }, + ["built"] = built, + ["cost"] = CostWarning(PlannedTotal()) + }; + } + + /// API 3 (D249): renames a placement, as rnpc rename does. Null on success. + private string RunicNpc_RenamePlacement(string from, string to) => from == null || to == null ? "Name both placements." : RenamePlacement(from, to); + + /// API 3 (D249): respawns a placement's NPCs now, as rnpc respawn. How many, or -1 if there is no such placement. + private int RunicNpc_RespawnPlacement(string id) + { + PlacementState ps; + if (id == null || !_placements.TryGetValue(id, out ps)) + return -1; + int n = RespawnNow(ps); + TickPlacements(); + return n; + } + + /// + /// The ground under a point of the map: terrain or rock, never anything built or a tree (D245: + /// a roof is an in-game placement). Null with the spot, or the reason there is none. + /// + private static string GroundAt(float x, float z, out Vector3 ground) + { + ground = Vector3.zero; + float half = World.Size / 2f; + if (float.IsNaN(x) || float.IsNaN(z) || Mathf.Abs(x) > half || Mathf.Abs(z) > half) + return $"({x:0}, {z:0}) is off the map"; + RaycastHit hit; + if (Physics.Raycast(new Vector3(x, 1000f, z), Vector3.down, out hit, 2000f, MapGroundMask, QueryTriggerInteraction.Ignore)) + ground = hit.point; + else + ground = new Vector3(x, TerrainMeta.HeightMap.GetHeight(new Vector3(x, 0f, z)), z); + if (TerrainMeta.WaterMap != null && TerrainMeta.WaterMap.GetHeight(ground) > ground.y + 0.5f) + return $"({x:0}, {z:0}) is under water"; + return null; + } + + /// + /// API 3 (D249): raised whenever a placement is set, removed or renamed, from the API or in + /// game alike. change is set, removed or renamed (with previous). + /// + private static void PlacementChanged(string id, string change, string previous = null) => + Interface.CallHook("OnRunicNpcPlacementChanged", id, change, previous); + private JObject RunicNpc_Routes() => JObject.FromObject(_routeFile.Routes); /// Adds or replaces a route (D234). Every point must be on Rust's navmesh. @@ -2044,6 +2132,9 @@ namespace Oxide.Plugins private static readonly int GroundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default", "Tree"); private static readonly int BuiltMask = LayerMask.GetMask("Construction", "Deployed"); + /// What a map point is put down on (D245): terrain and rock, never a building or a tree. + private static readonly int MapGroundMask = LayerMask.GetMask("Terrain", "World"); + /// Who ran a command, and where the answer goes. private sealed class Caller { @@ -2365,6 +2456,50 @@ namespace Oxide.Plugins } } + /// + /// Checks, names and sets a NEW placement: rnpc place, rnpc here and + /// RunicNpc_AddPlacement alike, so a placement made from a website is named and refused + /// exactly as one made in game (D241, D246, D249). Null on success, else the sentence to show. + /// + private string CreatePlacement(Placement data, out string id, out bool built) + { + id = null; + built = false; + Profile p; + if (data == null || data.Profile == null || !_profiles.TryGetValue(data.Profile, out p)) + { + string name = data?.Profile; + return name != null && _refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them."; + } + + string error = ValidatePlacement(data); + if (error != null) + return "Not placed: " + error; + + string kind, route; + ParseMode((data.Movement ?? p.Movement).Mode, out kind, out route); + if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) + return $"There is no route '{route}'. Record it first: rnpc path record {route}"; + + if (!NavmeshReady()) + return "The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly."; + + if (!p.IsSentry) + { + string off = OffMeshReason(data.Position.V); + if (off != null) + return $"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219)."; + built = IsPlayerBuilt(data.Position.V); + } + + string next = NextPlacementId(data.Profile); + error = SetPlacement(next, data); + if (error != null) + return "Not placed: " + error; + id = next; + return null; + } + private void CmdPlace(Caller c, string[] args, bool here) { if (args.Length < 1) @@ -2437,33 +2572,6 @@ namespace Oxide.Plugins } } - string kind, route; - ParseMode((movement ?? p.Movement).Mode, out kind, out route); - if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) - { - c.Say($"There is no route '{route}'. Record it first: rnpc path record {route}"); - return; - } - - if (!NavmeshReady()) - { - c.Say("The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly."); - return; - } - - bool built = false; - if (!p.IsSentry) - { - string off = OffMeshReason(spot); - if (off != null) - { - c.Say($"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219)."); - return; - } - - built = IsPlayerBuilt(spot); - } - var data = new Placement { Profile = name, @@ -2475,14 +2583,17 @@ namespace Oxide.Plugins Movement = movement }; - string id = NextPlacementId(name); - error = SetPlacement(id, data); + string id; + bool built; + error = CreatePlacement(data, out id, out built); if (error != null) { - c.Say("Not placed: " + error); + c.Say(error); return; } + string kind, route; + ParseMode((movement ?? p.Movement).Mode, out kind, out route); Movement m = movement ?? p.Movement; c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " + $"respawn {data.Respawn:0} s, {data.RespawnMode}. Rename it with rnpc rename {id} ."); @@ -2574,6 +2685,7 @@ namespace Oxide.Plugins _placementsDirty = true; Puts($"Placement '{from}' renamed to '{to}'."); + PlacementChanged(to, "renamed", from); return null; } diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index bc635b1..3055ff5 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -24,7 +24,7 @@ namespace Oxide.Plugins /// /// /// rnt.run <group> runs one group: api, hooks, move, - /// sentry, sleep, cmd (stage 3's commands), place, or all for each in turn. + /// sentry, sleep, cmd (stage 3's commands), api3 (stage 4's calls), place, or all for each in turn. /// rnt.run place leaves two placements behind for the restart check, and /// rnt.after checks, after a reload or restart, that every placement is back and nothing /// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and @@ -42,8 +42,8 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [Info("RunicNpcTest", "Runic Gateway", "0.3.0")] - [Description("RunicNPC stage 2 and 3 tests. A developer tool: never ship it.")] + [Info("RunicNpcTest", "Runic Gateway", "0.4.0")] + [Description("RunicNPC stage 2, 3 and 4 tests. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { [PluginReference] private Plugin RunicNPC; @@ -64,6 +64,7 @@ namespace Oxide.Plugins private readonly List _died = new List(); private readonly List _health = new List(); private readonly List _despawned = new List(); + private readonly List _changed = new List(); private readonly Dictionary _lastContributors = new Dictionary(); private int _hitsOnStandIns; @@ -230,6 +231,9 @@ namespace Oxide.Plugins private void OnRunicNpcDespawned(BasePlayer npc, string owner) => _despawned.Add(owner); + private void OnRunicNpcPlacementChanged(string id, string change, string previous) => + _changed.Add(previous != null ? $"{change}:{previous}>{id}" : $"{change}:{id}"); + private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info) { BasePlayer p = victim as BasePlayer; @@ -327,7 +331,7 @@ namespace Oxide.Plugins if (arg.Connection != null && !arg.IsAdmin) return; ClearWorld(); Api("RunicNpc_DespawnOwner", Owner); - foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2", "t_roam-1", "t_roam-2", "t_gate", "t_sentry-1" }) + foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2", "t_roam-1", "t_roam-2", "t_gate", "t_sentry-1", "t_site" }) Api("RunicNpc_RemovePlacement", id); Api("RunicNpc_RemoveRoute", "t_loop"); Api("RunicNpc_RemoveRoute", "t_rec"); @@ -351,7 +355,7 @@ namespace Oxide.Plugins string route = SetLoop(field + new Vector3(60f, 0f, 0f)); Check("setup.route", route == null, route ?? "t_loop set"); - string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "place" } : new[] { group }; + string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "place" } : new[] { group }; foreach (string g in groups) { IEnumerator inner = g == "api" ? Api(field) @@ -360,6 +364,7 @@ namespace Oxide.Plugins : g == "sentry" ? Sentry(field) : g == "sleep" ? Sleep(field) : g == "cmd" ? Cmd(field) + : g == "api3" ? Api3(field) : g == "floor" ? Floor(field) : g == "place" ? Place(field) : null; @@ -383,7 +388,7 @@ namespace Oxide.Plugins private IEnumerator Api(Vector3 field) { - Check("api.version", Api("RunicNpc_ApiVersion") == 2, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + Check("api.version", Api("RunicNpc_ApiVersion") == 3, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); JObject profiles = Api("RunicNpc_Profiles"); Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]); @@ -931,6 +936,105 @@ namespace Oxide.Plugins /// a live NPC (what happens to it is measured, not asserted), and after its death the placement /// falls back to the nearest navmesh until the floor returns. /// + // ---- api3: stage 4's calls (D249) — a placement made from a map point, named as in game ---- + + private Dictionary Add(object placement) => + Api>("RunicNpc_AddPlacement", JObject.FromObject(placement)) ?? new Dictionary { ["error"] = "no answer" }; + + private static string Said(Dictionary d) => + d.ContainsKey("error") ? "error: " + d["error"] : "id " + d["id"] + (d.ContainsKey("position") ? " at " + V(d["position"]) : ""); + + private IEnumerator Api3(Vector3 field) + { + _changed.Clear(); + Vector3 a = OnMesh(field + new Vector3(-40, 0, 40)); + StandIn(OnMesh(field + new Vector3(-40, 0, 140)), 31); + + // A map point: x and z only. RunicNPC grounds it and names it like rnpc place (D241, D246). + Dictionary r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, count = 2, respawn = 30f, respawnMode = "group" }); + Check("api3.add.named", !r.ContainsKey("error") && (string)r["id"] == "t_roam-1", Said(r)); + Vector3 put = r.ContainsKey("position") ? V(r["position"]) : Vector3.zero; + float ground = TerrainMeta.HeightMap.GetHeight(a); + Check("api3.add.grounded", r.ContainsKey("position") && Mathf.Abs(put.y - ground) < 1.5f, $"y {put.y:0.00}, terrain {ground:0.00}"); + Check("api3.add.cost", r.ContainsKey("cost") && ((string)r["cost"]).Contains("NPC(s) on this server"), r.ContainsKey("cost") ? (string)r["cost"] : ""); + Check("api3.add.notBuilt", r.ContainsKey("built") && !(bool)r["built"], ""); + JObject data = PlacementData("t_roam-1"); + Check("api3.add.values", data != null && (int)data["count"] == 2 && (string)data["respawnMode"] == "group", "" + data?.ToString(Formatting.None)); + Check("api3.hook.set", _changed.Contains("set:t_roam-1"), string.Join(",", _changed.ToArray())); + + r = Add(new { profile = "t_roam", position = new { x = a.x + 6f, z = a.z } }); + Check("api3.add.nextName", !r.ContainsKey("error") && (string)r["id"] == "t_roam-2", Said(r)); + + // Refusals, each with the in-game reason; none leaves a placement behind. + int before = (Api>>("RunicNpc_Placements") ?? new List>()).Count; + float edge = World.Size / 2f + 50f; + r = Add(new { profile = "t_roam", position = new { x = edge, z = 0f } }); + Check("api3.refuse.offMap", r.ContainsKey("error") && ((string)r["error"]).Contains("off the map"), Said(r)); + r = Add(new { profile = "t_roam", position = new { x = a.x, y = a.y + 40f, z = a.z } }); + Check("api3.refuse.offMesh", r.ContainsKey("error") && ((string)r["error"]).Contains("m above the"), Said(r)); + r = Add(new { profile = "nope", position = new { x = a.x, z = a.z } }); + Check("api3.refuse.unknownProfile", r.ContainsKey("error") && ((string)r["error"]).Contains("no profile"), Said(r)); + r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, count = 0 }); + Check("api3.refuse.count", r.ContainsKey("error") && ((string)r["error"]).Contains("count"), Said(r)); + r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, movement = new { mode = "route:t_none", radius = 0f } }); + Check("api3.refuse.missingRoute", r.ContainsKey("error") && ((string)r["error"]).Contains("no route 't_none'"), Said(r)); + Vector3 sea; + if (FindWater(out sea)) + { + r = Add(new { profile = "t_roam", position = new { x = sea.x, z = sea.z } }); + Check("api3.refuse.water", r.ContainsKey("error") && ((string)r["error"]).Contains("under water"), Said(r) + $" at {sea}"); + } + else + Note("api3.refuse.water skipped: no deep water found"); + Check("api3.refuse.leftNothing", (Api>>("RunicNpc_Placements") ?? new List>()).Count == before, ""); + + yield return new WaitForSeconds(4f); + Check("api3.spawned", List("placement:t_roam-1").Count == 2, "listed " + List("placement:t_roam-1").Count); + + // Rename and respawn, as rnpc rename and rnpc respawn. + _changed.Clear(); + string renamed = Api("RunicNpc_RenamePlacement", "t_roam-2", "t_site"); + Check("api3.rename", renamed == null && PlacementData("t_site") != null && PlacementData("t_roam-2") == null, renamed ?? ""); + Check("api3.hook.renamed", _changed.Contains("renamed:t_roam-2>t_site"), string.Join(",", _changed.ToArray())); + string taken = Api("RunicNpc_RenamePlacement", "t_site", "t_roam-1"); + Check("api3.rename.refuseTaken", taken != null && taken.Contains("taken"), taken ?? "null"); + ulong first = List("placement:t_roam-1").Select(d => (ulong)d["netId"]).FirstOrDefault(); + int respawned = Api("RunicNpc_RespawnPlacement", "t_roam-1"); + Check("api3.respawn", respawned == 2, "respawned " + respawned); + Check("api3.respawn.missing", Api("RunicNpc_RespawnPlacement", "t_nothing") == -1, ""); + yield return new WaitForSeconds(3f); + List> after = List("placement:t_roam-1"); + Check("api3.respawn.fresh", after.Count == 2 && after.All(d => (ulong)d["netId"] != first), $"listed {after.Count}"); + + // The hook for every change made in game too. + _changed.Clear(); + Rnpc($"rnpc.place t_roam {At(OnMesh(field + new Vector3(-30, 0, 40)))}"); + Rnpc("rnpc.remove t_roam-2"); + Check("api3.hook.inGame", _changed.Contains("set:t_roam-2") && _changed.Contains("removed:t_roam-2"), string.Join(",", _changed.ToArray())); + Api("RunicNpc_RemovePlacement", "t_site"); + Check("api3.hook.removed", _changed.Contains("removed:t_site"), string.Join(",", _changed.ToArray())); + Api("RunicNpc_RemovePlacement", "t_roam-1"); + } + + /// A point of open sea at least 5 m deep, for the under-water refusal. + private static bool FindWater(out Vector3 at) + { + var rng = new System.Random(7); + float half = World.Size / 2f - 20f; + for (int i = 0; i < 4000; i++) + { + var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half); + if (TerrainMeta.WaterMap.GetHeight(p) - TerrainMeta.HeightMap.GetHeight(p) > 5f) + { + at = p; + return true; + } + } + + at = Vector3.zero; + return false; + } + private IEnumerator Floor(Vector3 field) { // Over dry land: a first run put it over water (terrain −0.25 m), and Rust's navmesh never -- 2.49.1 From e2c0db2a5de2437c230fad53c903f26bcb87c664 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 04:52:36 -0500 Subject: [PATCH 05/24] ci(release): ask the installer to recompose the Rust bundle (stage 4, D224) RunicNPC is the Rust bundle's third artefact from stage 4, so its release ends as Rust-Plugins' does: a dispatch of the installer's bundle workflow, warned rather than failed when the token cannot reach it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/release.yml | 38 ++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 7d32328..06ecca1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -38,9 +38,12 @@ # sidecar (PLAN.md §4): the bridge calls it in-process. What a consumer pairs # on is the API version other plugins call, from plugin.toml. # -# 4. NO BUNDLE DISPATCH, YET. The installer does not know RunicNPC until stage 4 -# makes it a third artefact of the Rust bundle (D224). That stage adds the -# step Rust-Plugins ends with, which asks RunicGateway/installer to recompose. +# 4. THE BUNDLE IS RECOMPOSED. Since stage 4 RunicNPC is the Rust bundle's third +# artefact (D224): the installer's compose job carries the latest RunicNPC +# release that answers the API the bridge declares (Rust-Plugins' overlay.toml +# `runicnpc_api`). The last step here asks RunicGateway/installer to +# recompose, as Rust-Plugins' release does; the installer's nightly cron covers +# a dispatch that did not arrive. # # The version is stamped into the shipped copy's `[Info(…)]` attribute, so # `oxide.plugins` / `c.plugins` on a server names the release it runs. The @@ -55,7 +58,9 @@ # Prerequisites (Settings → Actions → Secrets on RunicGateway/runicnpc-rust, or # the organisation's): # REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the -# tag and create the release. +# tag and create the release. The final step also dispatches +# RunicGateway/installer's bundle workflow, so the token +# needs write on that repo too; without it that step warns. # REGISTRY_USER — the Gitea username that token belongs to. name: Release plugin @@ -71,6 +76,7 @@ concurrency: env: GITEA_HOST: gitea.whitlocktech.com + INSTALLER_REPO: RunicGateway/installer REPO: RunicGateway/runicnpc-rust ARTIFACT: runicnpc PLUGIN: plugin/RunicNPC.cs @@ -474,3 +480,27 @@ jobs: fi echo " uploaded ${f}" done + + # ── Recompose the installer's bundle manifest (D224) ───────────────── + # DISPATCH, DON'T WAIT, as Rust-Plugins' release does. A dropped dispatch + # costs latency, not correctness: the installer's nightly cron recomputes + # the bundle from whatever the latest releases actually are. + - name: Ask the installer repo to recompose its bundle + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"ref":"main"}' \ + "https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)" + case "$HTTP" in + 20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;; + 403|404) + echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;; + *) + echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;; + esac -- 2.49.1 From 9424dca6d1a8706772496243a4d44fda9c87af8f Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 1 Oct 2026 00:23:47 -0500 Subject: [PATCH 06/24] test(harness): stage 5 spike, NPC targets, turrets, kit items, PVE (D231, D253) 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- tools/RunicNpcHarness.cs | 966 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 964 insertions(+), 2 deletions(-) diff --git a/tools/RunicNpcHarness.cs b/tools/RunicNpcHarness.cs index 8066790..d511902 100644 --- a/tools/RunicNpcHarness.cs +++ b/tools/RunicNpcHarness.cs @@ -28,6 +28,11 @@ namespace Oxide.Plugins /// the name on the death screen, and what the bridge publishes (rnh.dummy). /// /// + /// And stage 5's spike (D231, D253): our own sensing and combat state against NPC targets + /// (rnh.npcfight), its cost (rnh.sense), turrets and traps (rnh.turrets, + /// D259), the kit items Rust's AI uses by itself (rnh.items, D260), and TruePVE or + /// NextGenPVE, whichever is loaded (rnh.pve, D261). + /// /// Every result is a line starting [rnh] in the console and a record in the data file /// RunicNpcHarness.json, read back over the panel. Nothing here is RunicNPC's design: /// it is the measurement that design waits for. @@ -45,8 +50,8 @@ namespace Oxide.Plugins /// other plugin's code. /// /// - [Info("RunicNpcHarness", "Runic Gateway", "0.1.0")] - [Description("RunicNPC stage 1 measurements. A developer tool: never ship it.")] + [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; @@ -93,6 +98,9 @@ namespace Oxide.Plugins { _meterHost = new GameObject("RunicNpcHarness.FrameMeter"); _meter = _meterHost.AddComponent(); + 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()}"); @@ -106,6 +114,9 @@ namespace Oxide.Plugins _spawned.Clear(); if (_meterHost != null) UnityEngine.Object.Destroy(_meterHost); + Scientists.Clear(); + Animals.Clear(); + OursByTag.Clear(); _self = null; } @@ -284,6 +295,100 @@ namespace Oxide.Plugins base.AttackerInfo(info); info.attackerName = displayName; } + + // ---- stage 5: our own target, beside Rust's ---- + + /// This NPC's "profile" for the spike: allies share a tag. + public string RnhTag = ""; + + /// The factions it hunts: scientists, animals, ours:<tag>. Null: none. + public HashSet RnhHostile; + + /// + /// 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. + /// + public bool RnhShotFaction; + + /// When a turret or trap hurts it and it has no target, the turret becomes its target. + public bool RnhShootsBack; + + public BaseCombatEntity RnhNpcTarget; + public int ShotTests, ShotsMarked; + public string LastHurtBy = "-"; + + private BaseCombatEntity _marked; + private BaseCombatEntity.Faction _markedWas; + + /// + /// 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). + /// + 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; + } + + /// A single-shot weapon fires inside ShotTest; a burst weapon fires from TriggerDown. + 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); + } + + /// + /// 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. + /// + 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; + } } /// @@ -323,9 +428,182 @@ namespace Oxide.Plugins 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]; + + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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 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; + } + + /// + /// 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. + /// + 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 Scientists = new HashSet(); + private static readonly HashSet Animals = new HashSet(); + private static readonly Dictionary> OursByTag = new Dictionary>(); + + private static HashSet Registry(string faction) + { + if (faction == "scientists") return Scientists; + if (faction == "animals") return Animals; + HashSet 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 set; + if (!OursByTag.TryGetValue(tag, out set)) OursByTag[tag] = set = new HashSet(); + 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 set; + if (o != null && OursByTag.TryGetValue(o.RnhTag, out set)) set.Remove(o); } // ---- frame meter ---- @@ -458,6 +736,7 @@ namespace Oxide.Plugins { 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++; @@ -474,13 +753,32 @@ namespace Oxide.Plugins 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); + + /// + /// 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. + /// + 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; @@ -1083,6 +1381,670 @@ namespace Oxide.Plugins }); } + // ==== stage 5 (PLAN.md §9, stage 5; D231, D253-D262) ==== + + /// + /// The PVE stand-in's user id. TruePVE and NextGenPVE treat only Steam ids as players + /// (IsSteamId), so this one is in the Steam range, and far beyond any id Steam has issued. + /// + private const ulong PveUserId = 76570997960265728UL; + + /// 0 observe (answer nothing), 1 allow (D261), 2 deny (D261's per-profile opt-out). + private int _pveMode; + private int _pveAsks; + + /// Labelled entities: every hit to or from one is tallied by "attacker>victim". + private readonly Dictionary _labels = new Dictionary(); + private readonly Dictionary _pairs = new Dictionary(); + private readonly Dictionary _diedAt = new Dictionary(); + private readonly HashSet _invulnerable = new HashSet(); + private float _scenarioStart; + + private T Label(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 Pairs() => _pairs.ToDictionary(kv => kv.Key, kv => (object)new Dictionary + { + ["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 Done; + public Func> Extra; + } + + /// + /// 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. + /// + private void RunScenarios(string test, Vector3 field, List list, Dictionary header = null) + { + var results = new List>(); + int index = 0; + Action next = null; + next = () => + { + if (index >= list.Count) + { + var rec = header ?? new Dictionary(); + 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 + { + ["scenario"] = sc.Name, + ["seconds"] = elapsed, + ["pairs"] = Pairs(), + ["diedAt"] = new Dictionary(_diedAt), + }; + if (error != null) r["error"] = error; + if (sc.Extra != null) + { + try { foreach (KeyValuePair 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(); + } + + /// Corpses and dropped backpacks near the field: a fight leaves both. + private static void ClearLeftovers(Vector3 field, float radius) + { + foreach (BaseEntity e in BaseNetworkable.serverEntities.OfType() + .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(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 Stats(RnhNpc o) => o == null || o.IsDestroyed + ? new Dictionary { ["gone"] = true } + : new Dictionary + { + ["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> extra = () => new Dictionary + { + ["ours"] = Stats(o), + ["other"] = b != null ? Stats(b) : null, + ["ally"] = ally != null ? Stats(ally) : null, + ["foe"] = foe == null ? null : new Dictionary + { + ["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 + { + // 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>(); + var all = new List(); + Func grid = i => Ground(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f)); + Func 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 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(hostile); + all.Add(r.Npc); _labels[r.Npc] = "ours." + tag; _invulnerable.Add(r.Npc); + return r.Npc; + }; + + var queue = new Queue>(); + queue.Enqueue(new KeyValuePair("baseline", () => { })); + queue.Enqueue(new KeyValuePair("ours100.hostileToScientists.noneNear", () => + { + for (int i = 0; i < 100; i++) spawn(i, "a", false, new[] { "scientists" }); + })); + queue.Enqueue(new KeyValuePair("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("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("after.clear", killAll)); + + Action next = null; + next = () => + { + if (queue.Count == 0) + { + Record("sense." + Framework(), new Dictionary + { + ["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 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 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 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(); + + 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(); + 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 + { + ["turret"] = turret == null ? null : new Dictionary + { + ["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 + { + ["dead"] = Dead(target), ["health"] = target == null || target.IsDestroyed ? 0 : Math.Round(((BaseCombatEntity)target).health, 0), + }, + ["notes"] = new List(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 Inventory(BasePlayer p) + { + var counts = new Dictionary(); + 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 before = null; + float lastHealth = 0f; + int heals = 0, scripted = 0; + float minHealth = float.MaxValue; + Timer pulse = null; + + Action 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> 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> extra = () => + { + pulse?.Destroy(); + return new Dictionary + { + ["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 + { + // 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 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> + { + // The control: player against player is what a PVE plugin exists to stop. + new KeyValuePair(player2, player), + new KeyValuePair(player, ours), + new KeyValuePair(ours, player), + new KeyValuePair(player, stock), + new KeyValuePair(stock, player), + new KeyValuePair(ours, stock), + new KeyValuePair(stock, ours), + }; + var rows = new List>(); + foreach (int mode in new[] { 0, 1, 2 }) + { + _pveMode = mode; + foreach (KeyValuePair pr in pairs) + { + BaseCombatEntity a = pr.Key, v = pr.Value; + if (Dead(a) || Dead(v)) { rows.Add(new Dictionary { ["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 + { + ["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 { ["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")] -- 2.49.1 From 21d3c477dcb3228be45d71689afdcdc367bd47ee Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 1 Oct 2026 07:20:59 -0500 Subject: [PATCH 07/24] feat: API 4, factions, escort, allies, tether, turrets, kit extras and PVE (stage 5) Stage 5's behaviour (docs runicnpc/PLAN.md, D253-D272): - profiles gain faction, relations (exceptions), alertRadius, turrets, hurtByPlayers, hurtsPlayers and kitUse, and the guard role; the faction table rides in profiles.json, one row per pair, both ways (D254, D268); - our own sensing and fight beside Rust's design: only the factions a profile hunts, within its leash of its anchor (D263), the target made Horror for the length of our own shot, and Rust's attack routine; - shooting back at whoever hurt it (D268), the alert radius (D256), clan, team or player allies that are never targeted and are defended with what they own (D257), escort as an order that walks home when its charge is gone (D269), and a placement's tether through ZoneManager (D272); - turrets: ignore through CanBeTargeted, and always through a Harmony patch on the safe-zone sentries for our always NPCs only (D267); - the kit's extras, each opted into and used up: heal, melee, flamethrower, grenades and rockets (D260, D265); - TruePVE/NextGenPVE answered both ways unless a profile turns a direction off, and the opt-out enforced by RunicNPC itself (D261, D271); - API 4: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether and the OnRunicNpcEscortEnded hook; rnpc follow, rnpc.faction and tether=. Compiled and loaded on both rigs (Oxide and Carbon); the sentry patch applies on both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin.toml | 12 +- plugin/RunicNPC.cs | 1904 ++++++++++++++++++++++++++++++++++- scripts/checkPlugin.js | 12 +- scripts/checkPlugin.test.js | 6 +- 4 files changed, 1895 insertions(+), 39 deletions(-) diff --git a/plugin.toml b/plugin.toml index 82d231e..66c490f 100644 --- a/plugin.toml +++ b/plugin.toml @@ -24,9 +24,11 @@ # # 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else. # 2 was stage 2's whole API, PLAN.md §4. -# Current: 3 — stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the -# OnRunicNpcPlacementChanged hook, documented in docs/runicnpc/API.md. -api = 3 +# 3 was stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the +# OnRunicNpcPlacementChanged hook. +# Current: 4 — stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5 +# fields, a placement's tether, and the OnRunicNpcEscortEnded hook, documented in docs/runicnpc/API.md. +api = 4 # ── Framework floors ───────────────────────────────────────────────────────── # @@ -45,6 +47,6 @@ min_carbon_version = "2.0.259" # `doctor`, which reports a missing one rather than installing it. The PR check # holds this list and the plugin's `// Requires:` lines equal. # -# ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6), and a -# profile that asks for one on a server without it is refused when it is saved. +# ZoneManager is NOT listed: a zone tether is optional (PLAN.md §6). A placement +# or an event step that asks for one on a server without it is refused (D272). requires_plugins = ["Kits"] diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index d40df64..514071e 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -58,7 +58,7 @@ namespace Oxide.Plugins /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// - private const int ApiVersion = 3; + private const int ApiVersion = 4; /// /// Every hook this plugin implements. rnpc.status reports which have fired, because @@ -70,7 +70,11 @@ namespace Oxide.Plugins "OnServerInitialized", "OnServerSave", "OnEntityKill", - "OnPluginUnloaded" + "OnPluginUnloaded", + "OnEntitySpawned", + "OnEntityTakeDamage", + "CanBeTargeted", + "CanEntityTakeDamage" }; private readonly Dictionary _hookCounts = new Dictionary(); @@ -200,7 +204,55 @@ namespace Oxide.Plugins [JsonProperty("sleepDistance")] public float SleepDistance = 160f; [JsonProperty("healthThresholds")] public List HealthThresholds = new List(); + // ---- stage 5 (D254–D261, D268, D271) ---- + + /// Its faction (D254), or null: then its allies are its own profile's NPCs (D256). + [JsonProperty("faction")] public string Faction; + + /// + /// Its own exceptions, which win over the faction table (D254): a faction, scientists, + /// animals or profile:<name>, each hostile, neutral or allied. + /// + [JsonProperty("relations")] public Dictionary Relations = new Dictionary(); + + /// Metres within which its allies join a fight one of them is in (D256); 0 is off. + [JsonProperty("alertRadius")] public float AlertRadius = 40f; + + /// default, ignore or always (D259, D264, D267). + [JsonProperty("turrets")] public string Turrets = "default"; + + /// D271: off makes it unkillable by players, on every server, PVE or not. + [JsonProperty("hurtByPlayers")] public bool HurtByPlayers = true; + + /// D271: off makes it harmless to players; it still fights NPCs and turrets. + [JsonProperty("hurtsPlayers")] public bool HurtsPlayers = true; + + /// The kit's extras it uses (D260); all off by default, and each one used is used up (D265). + [JsonProperty("kitUse")] public KitUse KitUse = new KitUse(); + [JsonIgnore] public bool IsSentry => Role == "sentry"; + + [JsonIgnore] public bool IsGuard => Role == "guard"; + } + + /// D260: the behaviours a kit's extra items allow, each opted into. + public sealed class KitUse + { + [JsonProperty("heal")] public bool Heal; + [JsonProperty("grenades")] public bool Grenades; + [JsonProperty("melee")] public bool Melee; + [JsonProperty("rockets")] public bool Rockets; + [JsonProperty("flamethrower")] public bool Flamethrower; + + [JsonIgnore] public bool Any => Heal || Grenades || Melee || Rockets || Flamethrower; + } + + /// One row of the faction table: a pair, both ways (D268). + public sealed class FactionPair + { + [JsonProperty("a")] public string A; + [JsonProperty("b")] public string B; + [JsonProperty("relation")] public string Relation = "neutral"; } private sealed class ProfileFile @@ -208,6 +260,9 @@ namespace Oxide.Plugins /// True once a site has pushed profiles (D221); in-game edits are then refused. [JsonProperty("managed")] public bool Managed; [JsonProperty("profiles")] public Dictionary Profiles = new Dictionary(); + + /// The faction table (D254), pushed with the profiles; rnpc.faction edits it standalone. + [JsonProperty("factions")] public List Factions = new List(); } /// @@ -229,6 +284,9 @@ namespace Oxide.Plugins /// Overrides the profile's movement for this placement only (D233); null keeps it. [JsonProperty("movement", NullValueHandling = NullValueHandling.Ignore)] public Movement Movement; + + /// A ZoneManager zone its NPCs never leave (D272); null for none. + [JsonProperty("tether", NullValueHandling = NullValueHandling.Ignore)] public string Tether; } private sealed class PlacementFile @@ -281,8 +339,7 @@ namespace Oxide.Plugins private void LoadData() { - _profileFile = ReadData(DataProfiles); - if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); + ReadProfileFile(); _routeFile = ReadData(DataRoutes); if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary(); _placementFile = ReadData(DataPlacements); @@ -295,6 +352,13 @@ namespace Oxide.Plugins SavePlacements(); } + private void ReadProfileFile() + { + _profileFile = ReadData(DataProfiles); + if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); + if (_profileFile.Factions == null) _profileFile.Factions = new List(); + } + private void SaveProfiles() => Interface.Oxide.DataFileSystem.WriteObject(DataProfiles, _profileFile); private void SaveRoutes() => Interface.Oxide.DataFileSystem.WriteObject(DataRoutes, _routeFile); @@ -321,6 +385,17 @@ namespace Oxide.Plugins foreach (KeyValuePair kv in _refused) PrintWarning($"Profile '{kv.Key}' is refused: {kv.Value}"); + + string factions = ValidateFactions(_profileFile.Factions); + if (factions != null) + { + PrintWarning($"The faction table is ignored: {factions}"); + _factionTable.Clear(); + } + else + BuildFactionTable(_profileFile.Factions); + + RebuildHunts(); } /// Null when the profile can spawn; otherwise the reason, for the log and the caller. @@ -336,8 +411,8 @@ namespace Oxide.Plugins return $"kits: this server has no kit '{kit}'"; if (ResolvePrefab(p.Prefab) == null) return $"prefab: '{p.Prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)"; - if (p.Role != "roamer" && p.Role != "sentry") - return $"role: '{p.Role}' is not roamer or sentry"; + if (p.Role != "roamer" && p.Role != "sentry" && p.Role != "guard") + return $"role: '{p.Role}' is not roamer, sentry or guard"; string movement = ValidateMovement(p.Movement); if (movement != null) return movement; if (!(p.Health > 0f)) return "health: must be above 0"; @@ -351,9 +426,75 @@ namespace Oxide.Plugins if (p.SleepDistance < 0f) return "sleepDistance: 0 (never sleeps) or more"; if (p.HealthThresholds == null || p.HealthThresholds.Any(t => !(t > 0f && t < 1f))) return "healthThresholds: fractions between 0 and 1"; + if (!string.IsNullOrEmpty(p.Faction) && (!NameRule.IsMatch(p.Faction) || BuiltInFactions.Contains(p.Faction))) + return $"faction: '{p.Faction}' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)"; + if (p.Relations == null) + return "relations: an object, even an empty one"; + foreach (KeyValuePair kv in p.Relations) + { + if (!IsFactionKey(kv.Key, true)) + return $"relations: '{kv.Key}' is not a faction, scientists, animals or profile:"; + if (!Relations.Contains(kv.Value)) + return $"relations.{kv.Key}: '{kv.Value}' is not hostile, neutral or allied"; + } + + if (!(p.AlertRadius >= 0f)) return "alertRadius: 0 (off) or more"; + if (p.Turrets != "default" && p.Turrets != "ignore" && p.Turrets != "always") + return $"turrets: '{p.Turrets}' is not default, ignore or always"; + if (p.KitUse == null) return "kitUse: an object, even an empty one"; return null; } + // ---- the faction table (D254, D268) ---- + + /// Rust's own two factions (D254). Neither can be a profile's faction. + private static readonly HashSet BuiltInFactions = new HashSet { "scientists", "animals" }; + + private static readonly HashSet Relations = new HashSet { "hostile", "neutral", "allied" }; + + /// A faction name, one of Rust's two, or (where a profile names it) profile:<name>. + private static bool IsFactionKey(string key, bool profileAllowed) + { + if (string.IsNullOrEmpty(key)) return false; + if (BuiltInFactions.Contains(key) || NameRule.IsMatch(key)) return true; + return profileAllowed && key.StartsWith("profile:") && NameRule.IsMatch(key.Substring(8)); + } + + /// Null when the table can be used; otherwise the first problem. + private static string ValidateFactions(List pairs) + { + if (pairs == null) return "it is missing"; + var seen = new HashSet(); + for (int i = 0; i < pairs.Count; i++) + { + FactionPair f = pairs[i]; + if (f == null || !IsFactionKey(f.A, false) || !IsFactionKey(f.B, false)) + return $"factions[{i}]: a and b are faction names (1–40 of a-z, 0-9, _ and -), scientists or animals"; + if (f.A == f.B) + return $"factions[{i}]: a faction is always allied to itself"; + if (BuiltInFactions.Contains(f.A) && BuiltInFactions.Contains(f.B)) + return $"factions[{i}]: both are Rust's own, and RunicNPC does not change how Rust's NPCs treat each other"; + if (!Relations.Contains(f.Relation)) + return $"factions[{i}]: '{f.Relation}' is not hostile, neutral or allied"; + if (!seen.Add(PairKey(f.A, f.B))) + return $"factions[{i}]: {f.A} and {f.B} are given twice (one row is both ways, D268)"; + } + + return null; + } + + /// The faction table, by pair, either way round (D268). + private readonly Dictionary _factionTable = new Dictionary(); + + private static string PairKey(string a, string b) => string.CompareOrdinal(a, b) < 0 ? a + "|" + b : b + "|" + a; + + private void BuildFactionTable(List pairs) + { + _factionTable.Clear(); + foreach (FactionPair f in pairs) + _factionTable[PairKey(f.A, f.B)] = f.Relation; + } + private static string ValidateMovement(Movement m) { if (m == null) return "movement: missing"; @@ -400,9 +541,15 @@ namespace Oxide.Plugins if (movement != null) return movement; } + if (p.Tether != null && !TetherNameRule.IsMatch(p.Tether)) + return $"tether: '{p.Tether}' is not a ZoneManager zone id"; + return null; } + /// ZoneManager's zone ids are free text; RunicNPC asks only that they be one plain word. + private static readonly Regex TetherNameRule = new Regex(@"^[A-Za-z0-9_.:-]{1,64}$"); + private static string ValidateRoute(Route r) { if (r == null || r.Points == null || r.Points.Count < 2) return "points: at least two"; @@ -700,7 +847,7 @@ namespace Oxide.Plugins /// Our NPC. What only a subclass can do (PLAN.md §2): its own name, its name on the /// victim's death screen, its attack range, and damage taken by body part. /// - public class RunicNpcPlayer : ScientistNPC, IAIAttack + public class RunicNpcPlayer : ScientistNPC, IAIAttack, IAISenses { public string RnName = "Scientist"; public string ProfileName; @@ -709,6 +856,7 @@ namespace Oxide.Plugins public string PlacementId; public int Slot = -1; public Vector3 Home; + public float HomeYaw; public string MoveKind = "wander"; public float MoveRadius = 20f; public string RouteName; @@ -745,6 +893,102 @@ namespace Oxide.Plugins public new float EngagementRange() => Profile != null ? Profile.Ranges.Attack : base.EngagementRange(); + // ---- stage 5: whom it fights, beside Rust's own design (D231, D254–D272) ---- + + /// + /// Its own target: an NPC, an animal or a turret. Rust's design still owns every fight with a + /// player; this one is fought by when the design has none. + /// + public BaseCombatEntity NpcTarget; + + /// True while it fights : its roam state stands aside (D262). + public bool Engaged; + + /// Whom it escorts (D258, D269), or null. Its leash and home follow it. + public BaseEntity Escort; + + /// The clan, team or lone player it is allied to (D257, D270); 0 for none. + public long AllyClan; + public ulong AllyTeam; + public ulong AllyPlayer; + + /// The ZoneManager zone it never leaves (D272), or null. + public string Tether; + + public int HuntsVersion = -1; + public HashSet Hunts; + public float NextSense, NextLos, NextMove, NextAlert, NextDefend; + public bool TargetLos; + public float LastHurtAt = float.NegativeInfinity; + public float NextHeal, NextThrow, NextRocket, KitBusyUntil; + public int Heals, Throws, Rockets, MeleeHits, ShotsMarked; + + public bool HasAlly => AllyClan != 0L || AllyTeam != 0UL || AllyPlayer != 0UL; + + /// Where its leash is measured from: the escorted entity while it has one, else its spot. + public Vector3 Anchor => Escort != null && !Escort.IsDestroyed ? Escort.transform.position : Home; + + /// + /// Rust's HumanNPC.IsTarget (players, pets, scarecrows), less anyone it is allied to or + /// escorting (D257, D269). IAISenses is re-implemented so Rust's senses ask here. + /// + public new bool IsTarget(BaseEntity entity) => + base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity)); + + public new bool IsThreat(BaseEntity entity) => IsTarget(entity); + + public new bool IsFriendly(BaseEntity entity) => base.IsFriendly(entity); + + private BaseCombatEntity _marked; + private BaseCombatEntity.Faction _markedWas; + + /// + /// Rust drops every hit an NPC's bullet makes on another NPC unless the victim's faction is + /// Horror (BaseProjectile.ServerUse, stage 5 spike). So its own NPC target alone is + /// made Horror for the length of its own shot, then put back. ShotTest calls TriggerDown + /// inside itself: a mark is made only when none is in place, or the inner call would save + /// Horror as the faction to restore and leave the target Horror for everyone's bullets. + /// + private bool Mark() + { + if (_marked != null) + return false; + BaseCombatEntity t = NpcTarget; + if (!Engaged || t == null || t.IsDestroyed || !t.IsNpc) + return false; + _marked = t; + _markedWas = t.faction; + t.faction = BaseCombatEntity.Faction.Horror; + ShotsMarked++; + return true; + } + + private void Unmark(bool marked) + { + if (!marked) + return; + if (_marked != null && !_marked.IsDestroyed) + _marked.faction = _markedWas; + _marked = null; + } + + /// A single-shot weapon fires inside ShotTest, a burst weapon from TriggerDown. + public override bool ShotTest(float targetDist) + { + if (_self != null && !_self.FuelExtra(this)) + return false; + bool marked = Mark(); + try { return base.ShotTest(targetDist); } + finally { Unmark(marked); } + } + + public override void TriggerDown() + { + bool marked = Mark(); + try { base.TriggerDown(); } + finally { Unmark(marked); } + } + public override void Hurt(HitInfo info) { if (Profile != null && info != null) @@ -758,6 +1002,11 @@ namespace Oxide.Plugins if (!Mathf.Approximately(scale, 1f)) info.damageTypes.ScaleAll(scale); + // D271: "players can hurt it" off. RunicNPC's own rule, so it holds with a PVE plugin + // or without one; NextGenPVE ignores a "deny" answer (stage 5 spike). + BasePlayer by = info.InitiatorPlayer; + if (!Profile.HurtByPlayers && by != null && !by.IsNpc) + info.damageTypes.ScaleAll(0f); } // A contributor is credited with the health it actually took, after Rust's own @@ -772,7 +1021,15 @@ namespace Oxide.Plugins if (attackerId != 0UL && !Died) Credit(attackerId, _healthBefore - health); if (Profile != null && !IsDead()) + { _self?.CheckThresholds(this); + // An unkillable NPC (D271) takes nothing and still answers the attack. + if (info != null && info.Initiator != null) + { + LastHurtAt = Time.time; + _self?.NpcHurt(this, info); + } + } } private ulong _pendingAttacker; @@ -855,15 +1112,59 @@ namespace Oxide.Plugins foreach (AIState type in SentryHolds) if (states.ContainsKey(type)) Replace(new HoldState(type, Aggressive.Contains(type))); + return; } - else if (npc.MoveKind == "wander" || npc.MoveKind == "route") + + // Every other NPC's roam is ours (stage 5): it stands aside while ours fights an NPC + // (D262), follows an escort (D269), and otherwise walks as its role and movement say. + // Rust's own roam and chase both need an AI zone's move points, so outside a monument + // they do nothing (stage 1, Q3; stage 2 found the chase's Error branch): a guard, a + // wanderer and a route walker get ours, and a monument roamer keeps both of Rust's. + BasicAIState inner; + if (npc.Profile != null && npc.Profile.IsGuard) + inner = new RnGuardState(); + else if (npc.MoveKind == "wander") + inner = new WanderState(); + else if (npc.MoveKind == "route") + inner = new RouteState(); + else { - // Rust's own roam and chase both need an AI zone's move points, so outside a - // monument they do nothing (stage 1, Q3; stage 2 found the chase's Error branch). - // A monument roamer keeps both of Rust's. - Replace(npc.MoveKind == "wander" ? (BasicAIState)new WanderState() : new RouteState()); - Replace(new PursueState()); + BasicAIState rust; + inner = states.TryGetValue(AIState.Roam, out rust) ? rust : null; } + + if (inner != null) + Replace(new RnRoamState(inner)); + if (npc.MoveKind != "monument" || (npc.Profile != null && npc.Profile.IsGuard)) + Replace(new PursueState()); + } + + /// Whether Rust's design is fighting a player now; ours then leaves the fight to it. + public bool FightingAPlayer() + { + if (CurrentState == null || !CurrentState.AgrresiveState || Senses?.Memory == null) + return false; + foreach (BaseEntity e in Senses.Memory.Targets) + { + BasePlayer p = e as BasePlayer; + if (p != null && !p.IsNpc && p.IsAlive()) + return true; + } + + return false; + } + + /// Wakes a sleeping NPC at once, for an ally's alarm (D256) or a defence (D257). + public void Rouse() + { + if (Rest == RestState.Awake) + return; + bool asleep = Rest == RestState.Asleep; + Rest = RestState.Awake; + _nextCheck = Time.time + 10f; + _playerNear = true; + if (asleep) + ((IAISleepable)this).WakeAI(); } private void Replace(BasicAIState state) @@ -891,6 +1192,14 @@ namespace Oxide.Plugins _playerNear = npc.Profile.SleepDistance <= 0f || PlayerWithin(npc.transform.position, npc.Profile.SleepDistance); if (!Sentry && !Navigator.IsOnNavMeshLink) Reground(npc); + _self?.CheckEscort(npc); + + // D272: outside its zone (pushed, or the zone moved), it forgets the fight and walks home. + if (!Sentry && npc.Tether != null && _self != null && !_self.InTether(npc, npc.transform.position)) + { + Tethered++; + Leash(npc); + } } switch (Rest) @@ -914,21 +1223,21 @@ namespace Oxide.Plugins } lastThinkTime = now; - if (Flat(npc.transform.position - npc.Home) <= 2f || now >= _homeBy) + if (Flat(npc.transform.position - npc.Anchor) <= 2f || now >= _homeBy) Sleep(); - else if (!Navigator.Moving && !Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal)) + else if (!Navigator.Moving && !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal)) Sleep(); return; default: - if (!_playerNear && (CurrentState == null || !CurrentState.AgrresiveState)) + if (!_playerNear && !npc.Engaged && (CurrentState == null || !CurrentState.AgrresiveState)) { // D235: walk home first, then sleep. Nothing thinks on the way: no player // is within the sleep distance, so there is nothing to sense or fight. Rest = RestState.GoingHome; _homeBy = now + 120f; lastThinkTime = now; - if (Sentry || !Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal)) + if (Sentry || !Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Normal)) Sleep(); return; } @@ -937,12 +1246,28 @@ namespace Oxide.Plugins } if (!Sentry && npc.Profile.Ranges.Chase > 0f && CurrentState != null && CurrentState.AgrresiveState && - Flat(npc.transform.position - npc.Home) > npc.Profile.Ranges.Chase) + Flat(npc.transform.position - npc.Anchor) > npc.Profile.Ranges.Chase) Leash(npc); base.Think(delta); + + // Stage 5: our own fight, after Rust's, so Rust's design still owns every fight with a + // player. It costs nothing for an NPC with no faction settings and no target (D255). + if (_self != null && !npc.IsDead()) + { + long t0 = Stopwatch.GetTimestamp(); + _self.Fight(npc, this, delta); + FightTicks++; + FightMs += (Stopwatch.GetTimestamp() - t0) * 1000.0 / Stopwatch.Frequency; + } } + public int Tethered; + + /// What our own fight layer has cost, for rnpc.status and the cost warning. + public static double FightMs; + public static long FightTicks; + /// /// D239: the floor under a roamer was destroyed. Rust leaves the NPC standing in the air /// where the floor was (stage 3 measured 15 s and counting), so it is put on the nearest @@ -971,6 +1296,8 @@ namespace Oxide.Plugins public void Leash(RunicNpcPlayer npc) { Leashes++; + npc.NpcTarget = null; + npc.Engaged = false; Rust.Ai.SimpleAIMemory memory = Senses.Memory; memory.All.Clear(); memory.Players.Clear(); @@ -979,7 +1306,8 @@ namespace Oxide.Plugins memory.LOS.Clear(); Events.Memory.Entity.Clear(); Senses.DelaySenseUpdate(5f); - Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Fast); + npc.NextSense = Time.time + 5f; + Navigator.SetDestination(npc.Anchor, BaseNavigator.NavigationSpeed.Fast); } private void Sleep() @@ -1017,6 +1345,7 @@ namespace Oxide.Plugins Vector2 offset = UnityEngine.Random.insideUnitCircle * npc.MoveRadius; NavMeshHit hit; if (SampleNavmesh(npc.Home + new Vector3(offset.x, 0f, offset.y), 3f, out hit) && + (npc.Tether == null || _self == null || _self.InTether(npc, hit.position)) && brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Slow)) { _status = StateStatus.Running; @@ -1040,6 +1369,163 @@ namespace Oxide.Plugins } } + /// + /// The roam state of every NPC that is not a sentry (stage 5). It hands the NPC to one of + /// three things, in order: + /// + /// + /// an escort (D269): it keeps within a few metres of whom it escorts; + /// its own fight with an NPC (D231): it stands aside while + /// moves the NPC, and a route walker resumes at the point it was heading to (D262); + /// otherwise its role's own roam: a guard's hold, our wander or route, or Rust's own + /// monument roam. + /// + /// + public class RnRoamState : BaseAIBrain.BasicAIState + { + private readonly BaseAIBrain.BasicAIState _inner; + private bool _innerActive; + private float _nextFollow; + + /// How close an escort keeps, and how far it lets its charge get before it runs. + private const float Keep = 4f; + private const float Run = 12f; + + public RnRoamState(BaseAIBrain.BasicAIState inner) : base(AIState.Roam) + { + _inner = inner; + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _innerActive = false; + _nextFollow = 0f; + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc != null && !Elsewhere(npc)) + EnterInner(brain, entity); + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc == null) + return StateStatus.Error; + + if (Elsewhere(npc)) + { + LeaveInner(brain, entity); + if (npc.Escort != null && !npc.Engaged) + Follow(brain, npc); + return StateStatus.Running; + } + + if (!_innerActive) + EnterInner(brain, entity); + return _inner.StateThink(delta, brain, entity); + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + LeaveInner(brain, entity); + brain.Navigator.ClearFacingDirectionOverride(); + } + + private static bool Elsewhere(RunicNpcPlayer npc) => npc.Engaged || (npc.Escort != null && !npc.Escort.IsDestroyed); + + private void EnterInner(BaseAIBrain brain, BaseEntity entity) + { + _inner.StateEnter(brain, entity); + _innerActive = true; + } + + private void LeaveInner(BaseAIBrain brain, BaseEntity entity) + { + if (!_innerActive) + return; + _inner.StateLeave(brain, entity); + _innerActive = false; + } + + /// D269: keep close to whom it escorts, running when they get away. + private void Follow(BaseAIBrain brain, RunicNpcPlayer npc) + { + if (Time.time < _nextFollow) + return; + _nextFollow = Time.time + 0.5f; + Vector3 there = npc.Escort.transform.position; + float distance = Vector3.Distance(npc.transform.position, there); + if (distance <= Keep + 1f) + { + if (brain.Navigator.Moving) + brain.Navigator.Stop(); + return; + } + + // A spot a little short of the escorted entity, on the side ours comes from, so a squad + // gathers round rather than on top of it. + Vector3 from = (npc.transform.position - there); + from.y = 0f; + Vector3 goal = there + (from.sqrMagnitude > 0.01f ? from.normalized : Vector3.forward) * Keep; + NavMeshHit hit; + if (SampleNavmesh(goal, 3f, out hit) || SampleNavmesh(there, 3f, out hit)) + brain.Navigator.SetDestination(hit.position, distance > Run ? BaseNavigator.NavigationSpeed.Fast : BaseNavigator.NavigationSpeed.Normal); + } + } + + /// + /// A guard's roam (stage 5): it holds its spot facing the way it was placed, and walks back + /// to it after a chase. Its chase range is its leash, as for any roamer. + /// + public class RnGuardState : BaseAIBrain.BasicAIState + { + private float _next; + + public RnGuardState() : base(AIState.Roam) + { + } + + public override void StateEnter(BaseAIBrain brain, BaseEntity entity) + { + base.StateEnter(brain, entity); + _next = 0f; + } + + public override StateStatus StateThink(float delta, BaseAIBrain brain, BaseEntity entity) + { + base.StateThink(delta, brain, entity); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc == null) + return StateStatus.Error; + if (Time.time < _next) + return StateStatus.Running; + _next = Time.time + 1f; + + if (Flat(npc.transform.position - npc.Home) > 2f) + { + if (!brain.Navigator.Moving) + brain.Navigator.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal); + return StateStatus.Running; + } + + if (brain.Navigator.Moving) + brain.Navigator.Stop(); + brain.Navigator.SetFacingDirectionOverride(Quaternion.Euler(0f, npc.HomeYaw, 0f) * Vector3.forward); + return StateStatus.Running; + } + + public override void StateLeave(BaseAIBrain brain, BaseEntity entity) + { + base.StateLeave(brain, entity); + brain.Navigator.Stop(); + brain.Navigator.ClearFacingDirectionOverride(); + } + + private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude; + } + /// D233 route:<name>: walks a recorded route's points in order. public class RouteState : BaseAIBrain.BasicAIState { @@ -1193,11 +1679,19 @@ namespace Oxide.Plugins Vector3 goal = there - (there - here).normalized * stopAt; float chase = npc.Profile.Ranges.Chase; - Vector3 fromHome = goal - npc.Home; + Vector3 anchor = npc.Anchor; + Vector3 fromHome = goal - anchor; fromHome.y = 0f; bool clamped = chase > 0f && fromHome.magnitude > chase; if (clamped) - goal = npc.Home + fromHome.normalized * chase; + goal = anchor + fromHome.normalized * chase; + + // D272: the zone's edge is a leash too. Past it, ours stands and waits, as at the chase range. + if (npc.Tether != null && _self != null && !_self.InTether(npc, goal)) + { + goal = here; + clamped = true; + } if (clamped && distance > npc.Profile.Ranges.Attack) { @@ -1264,6 +1758,1043 @@ namespace Oxide.Plugins #endregion + #region Factions, sensing and the fight (stage 5) + + // Rust's own design still owns every fight with a player. What is ours: whom else an NPC fights + // (D231, D254, D255), its allies' alarm (D256), the clan or team it serves (D257), escorts (D258, + // D269), turrets (D259, D264, D267), the kit's extras (D260), PVE (D261, D271) and the tether (D272). + // An NPC with no faction settings, no ally and no escort costs one dictionary look-up a think. + + /// Rust's scientists (not ours), for the profiles hostile to scientists. + private readonly HashSet _scientists = new HashSet(); + + /// Rust's animals, old and new, for the profiles hostile to animals. + private readonly HashSet _animals = new HashSet(); + + /// Our live NPCs by profile, for the profiles hostile to another profile. + private readonly Dictionary> _byProfile = new Dictionary>(); + + /// Our NPCs with an ally or an escort: the only ones a damage event is checked for (D257, D269). + private readonly HashSet _defenders = new HashSet(); + + /// Moves whenever profiles or the faction table change, so each NPC recomputes whom it hunts. + private int _huntsVersion; + + private static bool IsScientist(BaseEntity e) => e is ScientistNPC && !(e is RunicNpcPlayer); + + private static bool IsAnimal(BaseEntity e) => e is BaseNpc || e is Rust.Ai.Gen2.BaseNPC2; + + private void Track(BaseNetworkable entity) + { + BaseCombatEntity e = entity as BaseCombatEntity; + if (e == null || e is RunicNpcPlayer) + return; + if (IsScientist(e)) + _scientists.Add(e); + else if (IsAnimal(e)) + _animals.Add(e); + } + + private void Untrack(BaseNetworkable entity) + { + BaseCombatEntity e = entity as BaseCombatEntity; + if (e == null) + return; + _scientists.Remove(e); + _animals.Remove(e); + } + + private void TrackWorld() + { + _scientists.Clear(); + _animals.Clear(); + foreach (BaseNetworkable e in BaseNetworkable.serverEntities) + Track(e); + } + + private void RebuildHunts() => _huntsVersion++; + + /// A profile's faction, or profile:<name> when it has none: its allies are then its own (D256). + private static string FactionOf(string profileName, Profile p) => string.IsNullOrEmpty(p.Faction) ? "profile:" + profileName : p.Faction; + + private static string FactionOf(RunicNpcPlayer npc) => FactionOf(npc.ProfileName, npc.Profile); + + /// + /// How a profile treats a faction: its own exceptions first (D254), then its own faction + /// (allied), then the table, both ways (D268); neutral when nothing says otherwise. + /// + private string RelationOf(string profileName, Profile p, string otherFaction, string otherProfile) + { + string r; + if (otherProfile != null && p.Relations.TryGetValue("profile:" + otherProfile, out r)) + return r; + if (p.Relations.TryGetValue(otherFaction, out r)) + return r; + string mine = FactionOf(profileName, p); + if (mine == otherFaction) + return "allied"; + return _factionTable.TryGetValue(PairKey(mine, otherFaction), out r) ? r : "neutral"; + } + + /// How one of ours treats an entity: a relation for NPCs and animals, null for anything else. + private string RelationTo(RunicNpcPlayer npc, BaseEntity e) + { + RunicNpcPlayer other = e as RunicNpcPlayer; + if (other != null) + return other.Profile == null ? null : RelationOf(npc.ProfileName, npc.Profile, FactionOf(other), other.ProfileName); + if (IsScientist(e)) + return RelationOf(npc.ProfileName, npc.Profile, "scientists", null); + if (IsAnimal(e)) + return RelationOf(npc.ProfileName, npc.Profile, "animals", null); + return null; + } + + /// D256: an ally is the same faction, or the same profile when there is no faction. + private static bool SameSide(RunicNpcPlayer a, RunicNpcPlayer b) => + a.Profile != null && b.Profile != null && FactionOf(a) == FactionOf(b); + + /// The registry keys an NPC hunts: scientists, animals and ours:<profile>. Null for none (D255). + private HashSet HuntsOf(RunicNpcPlayer npc) + { + if (npc.HuntsVersion == _huntsVersion) + return npc.Hunts; + npc.HuntsVersion = _huntsVersion; + var hunts = new HashSet(); + if (RelationOf(npc.ProfileName, npc.Profile, "scientists", null) == "hostile") + hunts.Add("scientists"); + if (RelationOf(npc.ProfileName, npc.Profile, "animals", null) == "hostile") + hunts.Add("animals"); + foreach (KeyValuePair kv in _profiles) + if (RelationOf(npc.ProfileName, npc.Profile, FactionOf(kv.Key, kv.Value), kv.Key) == "hostile") + hunts.Add("ours:" + kv.Key); + npc.Hunts = hunts.Count > 0 ? hunts : null; + return npc.Hunts; + } + + private IEnumerable Registry(string key) + { + if (key == "scientists") + return _scientists; + if (key == "animals") + return _animals; + HashSet ours; + return key.StartsWith("ours:") && _byProfile.TryGetValue(key.Substring(5), out ours) ? ours.Cast() : null; + } + + private void Index(RunicNpcPlayer npc, bool add) + { + HashSet set; + if (!_byProfile.TryGetValue(npc.ProfileName, out set)) + { + if (!add) + return; + _byProfile[npc.ProfileName] = set = new HashSet(); + } + + if (add) + set.Add(npc); + else + { + set.Remove(npc); + _defenders.Remove(npc); + } + } + + private void NoteDefender(RunicNpcPlayer npc) + { + if (npc.HasAlly || npc.Escort != null) + _defenders.Add(npc); + else + _defenders.Remove(npc); + } + + /// How far from its anchor ours may fight an NPC (D263): its chase range, or for a sentry its attack range. + private static float LeashOf(RunicNpcPlayer npc) => + npc.Profile.IsSentry ? npc.Profile.Ranges.Attack : npc.Profile.Ranges.Chase > 0f ? npc.Profile.Ranges.Chase : npc.Profile.Ranges.Sense; + + private static float Flat(Vector3 v) => new Vector2(v.x, v.z).magnitude; + + // ---- the fight ---- + + /// LOS tests a sense may spend: the nearest few candidates only (stage 5 spike). + private const int LosBudget = 3; + + /// Rust's own line-of-sight mask for an NPC's shots. + private const int LosMask = 1218519041; + + private static readonly BaseCombatEntity[] Best = new BaseCombatEntity[LosBudget]; + private static readonly float[] BestD2 = new float[LosBudget]; + + /// + /// Our own fight, after Rust's think (stage 5). With no player to fight and a target of ours — + /// sensed (D231, D263), learned from an ally (D256), or whoever hurt it or its charge (D257, + /// D268) — it faces it, closes in within its leash and its zone, and hands Rust's own attack + /// routine the target, so every shot is Rust's. + /// + private void Fight(RunicNpcPlayer npc, RunicNpcBrain brain, float delta) + { + float now = Time.time; + HashSet hunts = HuntsOf(npc); + BaseCombatEntity t = npc.NpcTarget; + Vector3 anchor = npc.Anchor; + float leash = LeashOf(npc); + + if (t != null && (t.IsDestroyed || t.IsDead() || Spares(npc, t) || + Vector3.Distance(npc.transform.position, t.transform.position) > Mathf.Max(npc.Profile.Ranges.LoseTarget, leash) + 10f || + Flat(t.transform.position - anchor) > leash + Mathf.Max(npc.Profile.Ranges.Attack, 10f))) + t = npc.NpcTarget = null; + + if (hunts != null && now >= npc.NextSense && TurretOf(t) == null) + { + npc.NextSense = now + 1f; + long s0 = Stopwatch.GetTimestamp(); + BaseCombatEntity found = Sense(npc, hunts, anchor, leash); + SenseMs += (Stopwatch.GetTimestamp() - s0) * 1000.0 / Stopwatch.Frequency; + SenseRuns++; + if (found != null) + t = npc.NpcTarget = found; + } + + bool player = brain.FightingAPlayer(); + npc.Engaged = t != null && !player; + + if (npc.Profile.KitUse.Any) + KitTick(npc, brain, npc.Engaged ? t : PlayerTarget(brain), now); + + if (!npc.Engaged) + return; + + if (now >= npc.NextLos) + { + npc.NextLos = now + 0.5f; + npc.TargetLos = Los(npc, t); + } + + brain.Navigator.SetFacingDirectionEntity(t); + float distance = Vector3.Distance(npc.transform.position, t.transform.position); + if (!npc.Profile.IsSentry) + { + if (!npc.TargetLos || distance > npc.EngagementRange() * 0.9f) + { + if (now >= npc.NextMove) + { + npc.NextMove = now + 1f; + Vector3 goal = t.transform.position; + Vector3 fromAnchor = goal - anchor; + fromAnchor.y = 0f; + if (fromAnchor.magnitude > leash) + goal = anchor + fromAnchor.normalized * leash; + NavMeshHit hit; + if ((npc.Tether == null || InTether(npc, goal)) && SampleNavmesh(goal, 3f, out hit)) + brain.Navigator.SetDestination(hit.position, BaseNavigator.NavigationSpeed.Fast); + else if (brain.Navigator.Moving) + brain.Navigator.Stop(); + } + } + else if (brain.Navigator.Moving) + brain.Navigator.Stop(); + } + + if (now >= npc.KitBusyUntil) + npc.AttackTick(delta, t, npc.TargetLos); + } + + /// The player Rust's design is fighting, for the kit's extras (D260). + private static BaseEntity PlayerTarget(RunicNpcBrain brain) + { + if (brain.CurrentState == null || !brain.CurrentState.AgrresiveState) + return null; + return brain.Events.Memory.Entity.Get(brain.Events.CurrentInputMemorySlot); + } + + public static double SenseMs; + public static long SenseRuns; + + /// + /// The nearest hostile within its sense range AND its leash around its anchor (D263), from the + /// registry rather than a physics query: distances first, then line of sight on the nearest + /// few only. Stage 5's spike measured this at 1.5–2.5 ms a think in a 100-against-100 fight. + /// + private BaseCombatEntity Sense(RunicNpcPlayer npc, HashSet hunts, Vector3 anchor, float leash) + { + for (int i = 0; i < LosBudget; i++) + { + Best[i] = null; + BestD2[i] = float.MaxValue; + } + + Vector3 at = npc.transform.position; + float sense = npc.Profile.IsSentry ? Mathf.Min(npc.Profile.Ranges.Sense, npc.Profile.Ranges.Attack) : npc.Profile.Ranges.Sense; + float r2 = sense * sense; + float leash2 = leash * leash; + foreach (string key in hunts) + { + IEnumerable set = Registry(key); + if (set == null) + continue; + foreach (BaseCombatEntity e in set) + { + if (e == null || e.IsDestroyed || e == npc || e.IsDead()) + continue; + Vector3 p = e.transform.position; + float d2 = (p - at).sqrMagnitude; + if (d2 > r2 || d2 >= BestD2[LosBudget - 1]) + continue; + Vector3 fromAnchor = p - anchor; + if (fromAnchor.x * fromAnchor.x + fromAnchor.z * fromAnchor.z > leash2) + continue; + int k = LosBudget - 1; + while (k > 0 && BestD2[k - 1] > d2) + { + Best[k] = Best[k - 1]; + BestD2[k] = BestD2[k - 1]; + k--; + } + + Best[k] = e; + BestD2[k] = d2; + } + } + + for (int i = 0; i < LosBudget && Best[i] != null; i++) + if ((npc.Tether == null || InTether(npc, Best[i].transform.position)) && Los(npc, Best[i])) + return Best[i]; + return null; + } + + /// + /// Rust's CanSeeTarget for a player; for anything else (which Rust answers "seen" without + /// a ray), a line from its eyes that counts the target's own collider as seen. + /// + private static bool Los(RunicNpcPlayer 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 turret behind a hit. A flame turret hurts through its fireballs, whose creator is the + /// turret or the fireball that spawned them, so the chain is followed (stage 5 spike). + /// + private static BaseCombatEntity TurretOf(BaseEntity e) + { + for (int i = 0; i < 6 && e is FireBall; i++) + e = e.creatorEntity == e ? null : e.creatorEntity; + return e is AutoTurret || e is FlameTurret || e is GunTrap ? (BaseCombatEntity)e : null; + } + + /// + /// Whom one of ours never targets: whom it escorts (D269), its allied clan's, team's or player's + /// people (D257), and NPCs it is allied to (D254). + /// + private bool Spares(RunicNpcPlayer npc, BaseEntity e) + { + if (e == null) + return false; + if (npc.Escort != null && (e == npc.Escort || e.EqualNetID(npc.Escort))) + return true; + BasePlayer p = e as BasePlayer; + if (p != null && !p.IsNpc) + return npc.HasAlly && IsAllyMember(npc, p); + return npc.Profile != null && (e is RunicNpcPlayer || IsScientist(e) || IsAnimal(e)) && RelationTo(npc, e) == "allied"; + } + + // ---- hurt: shoot back (D259, D268) and raise the alarm (D256) ---- + + /// Called from the NPC's own Hurt, after Rust's. + private void NpcHurt(RunicNpcPlayer npc, HitInfo info) + { + BaseEntity source = info.Initiator; + BaseCombatEntity attacker = TurretOf(source) ?? TurretOf(info.Weapon != null ? info.Weapon.GetParentEntity() : null) ?? source as BaseCombatEntity; + if (attacker == null || attacker == npc || attacker.IsDestroyed || attacker.IsDead()) + return; + + RunicNpcPlayer ours = attacker as RunicNpcPlayer; + if (ours != null && SameSide(npc, ours)) + return; // an ally's fire or grenade (D266): no fight among friends. + + BasePlayer player = attacker as BasePlayer; + bool real = player != null && !player.IsNpc; + if (real && npc.HasAlly && IsAllyMember(npc, player)) + return; + + // D268: whoever shot it is fought back, whatever the table says. Rust's design already learns + // a player (HumanNPC.Hurt); an NPC, an animal or a turret is ours to fight. + if (!real && (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed)) + npc.NpcTarget = attacker; + + Alarm(npc, attacker, real); + } + + /// D256: every ally within the profile's alert radius learns the attacker, seen or not. + private void Alarm(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real) + { + float radius = npc.Profile.AlertRadius; + float now = Time.time; + if (radius <= 0f || now < npc.NextAlert) + return; + npc.NextAlert = now + 1f; + float r2 = radius * radius; + Vector3 at = npc.transform.position; + foreach (RunicNpcPlayer other in _npcs.Values) + { + if (other == npc || other.IsDestroyed || other.IsDead() || other.Profile == null || !SameSide(npc, other)) + continue; + if ((other.transform.position - at).sqrMagnitude > r2) + continue; + Learn(other, attacker, real); + Alarms++; + } + } + + public static long Alarms, Defences; + + /// Tells one of ours about an attacker: Rust's memory for a player, our own target for anything else. + private void Learn(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real) + { + if (Spares(npc, attacker)) + return; + npc.RnBrain?.Rouse(); + if (real) + { + BaseAIBrain brain = npc.Brain; + if (brain?.Senses?.Memory != null) + brain.Senses.Memory.SetKnown(attacker, npc, brain.Senses); + return; + } + + if (npc.NpcTarget == null || npc.NpcTarget.IsDead() || npc.NpcTarget.IsDestroyed) + npc.NpcTarget = attacker; + } + + // ---- allies: a clan, a team or a player (D257, D270) ---- + + private sealed class Members + { + public HashSet Ids = new HashSet(); + public float ReadAt = float.NegativeInfinity; + } + + private readonly Dictionary _members = new Dictionary(); + + /// Whether a player belongs to whom one of ours is allied to. + private bool IsAllyMember(RunicNpcPlayer npc, BasePlayer p) + { + if (npc.AllyPlayer != 0UL && (ulong)p.userID == npc.AllyPlayer) + return true; + if (npc.AllyClan != 0L && p.clanId == npc.AllyClan) + return true; + return npc.AllyTeam != 0UL && p.currentTeam == npc.AllyTeam; + } + + /// + /// Whether a Steam id belongs to whom one of ours is allied to: for what they own (D257), when + /// the owner may be offline. Clan and team rosters are re-read every five seconds at most. + /// + private bool IsAllyMember(RunicNpcPlayer npc, ulong id) + { + if (id == 0UL) + return false; + if (npc.AllyPlayer == id) + return true; + if (npc.AllyClan != 0L && Roster("clan:" + npc.AllyClan).Contains(id)) + return true; + return npc.AllyTeam != 0UL && Roster("team:" + npc.AllyTeam).Contains(id); + } + + private HashSet Roster(string key) + { + Members m; + if (!_members.TryGetValue(key, out m)) + _members[key] = m = new Members(); + float now = Time.realtimeSinceStartup; + if (now - m.ReadAt < 5f) + return m.Ids; + m.ReadAt = now; + m.Ids.Clear(); + if (key.StartsWith("team:")) + { + RelationshipManager.PlayerTeam team = RelationshipManager.ServerInstance != null + ? RelationshipManager.ServerInstance.FindTeam(ulong.Parse(key.Substring(5))) + : null; + if (team?.members != null) + foreach (ulong id in team.members) + m.Ids.Add(id); + } + else + { + IClan clan = ReadClan(long.Parse(key.Substring(5))); + if (clan?.Members != null) + foreach (ClanMember member in clan.Members) + m.Ids.Add(member.SteamId); + } + + return m.Ids; + } + + /// A clan by id from the game's own clans, or null. Never waits: a clan not cached is read only if the read is instant. + private static IClan ReadClan(long clanId) + { + IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null; + if (backend == null) + return null; + IClan clan; + if (backend.TryGet(clanId, out clan) && clan != null) + return clan; + System.Threading.Tasks.ValueTask> read = backend.Get(clanId); + return read.IsCompleted && read.Result.IsSuccess ? read.Result.Value : null; + } + + /// + /// D257 and D269: a damage event against whom ours escort or serve. The attacker is fought by + /// every defender within its leash of the victim; a player, by Rust's own design. + /// + private void Defend(BaseCombatEntity victim, HitInfo info) + { + BaseEntity source = info.Initiator; + BaseCombatEntity attacker = TurretOf(source) ?? source as BaseCombatEntity; + if (attacker == null || attacker == victim || attacker.IsDead()) + return; + BasePlayer victimPlayer = victim as BasePlayer; + BasePlayer attackerPlayer = attacker as BasePlayer; + bool real = attackerPlayer != null && !attackerPlayer.IsNpc; + float now = Time.time; + + foreach (RunicNpcPlayer d in _defenders) + { + if (d == null || d.IsDestroyed || d.IsDead() || d.Profile == null || d == attacker || now < d.NextDefend) + continue; + + bool charge = d.Escort != null && (victim == d.Escort || victim.EqualNetID(d.Escort)); + if (!charge && d.HasAlly) + { + charge = victimPlayer != null && !victimPlayer.IsNpc + ? IsAllyMember(d, victimPlayer) + : victim.OwnerID != 0UL && IsAllyMember(d, victim.OwnerID); + } + + if (!charge) + continue; + + // The attacker is not fought if it is one of the side being defended. + if (real && d.HasAlly && IsAllyMember(d, attackerPlayer)) + continue; + RunicNpcPlayer ours = attacker as RunicNpcPlayer; + if (ours != null && SameSide(d, ours)) + continue; + + float reach = Mathf.Max(LeashOf(d), d.Profile.AlertRadius); + if (Flat(victim.transform.position - d.Anchor) > reach) + continue; + + d.NextDefend = now + 0.5f; + Learn(d, attacker, real); + Defences++; + } + } + + // ---- escort (D258, D269) ---- + + /// + /// Sets whom one of ours escorts, or clears it with null. Null on success, else why not: a + /// sentry never moves, and a dead or disconnected player cannot be escorted. + /// + private string SetEscort(RunicNpcPlayer npc, BaseEntity target) + { + if (target == null) + { + npc.Escort = null; + NoteDefender(npc); + return null; + } + + if (npc.Profile.IsSentry) + return $"{npc.RnName} is a sentry and never moves, so it cannot escort"; + if (target == npc || target.IsDestroyed) + return "there is nothing to escort"; + BasePlayer p = target as BasePlayer; + if (p != null && (!p.IsConnected || p.IsDead()) && !p.IsNpc) + return $"{p.displayName} is not on the server"; + BaseCombatEntity c = target as BaseCombatEntity; + if (c != null && c.IsDead()) + return "it is dead"; + + npc.Escort = target; + npc.NpcTarget = null; + NoteDefender(npc); + npc.RnBrain?.Rouse(); + return null; + } + + /// D269: whom it escorts died or left the server; it walks back to its spot. + private void CheckEscort(RunicNpcPlayer npc) + { + BaseEntity e = npc.Escort; + if (e == null) + return; + BasePlayer p = e as BasePlayer; + BaseCombatEntity c = e as BaseCombatEntity; + bool gone = e.IsDestroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected); + if (!gone) + return; + string who = p != null ? p.displayName : e.ShortPrefabName; + npc.Escort = null; + NoteDefender(npc); + npc.RnBrain?.Navigator?.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal); + Interface.CallHook("OnRunicNpcEscortEnded", npc, npc.ProfileName, npc.Owner); + Puts($"{npc.RnName} ({npc.Owner}) no longer escorts {who}, who is gone; it walks back to its spot."); + } + + // ---- the tether (D272) ---- + + [PluginReference] private Plugin ZoneManager; + + private bool _tetherWarned; + + /// + /// Whether a point is inside one of ours' zone. With ZoneManager gone the NPC is not held, + /// and the log says so once, rather than every NPC freezing where it stands. + /// + private bool InTether(RunicNpcPlayer npc, Vector3 at) + { + if (npc.Tether == null) + return true; + if (ZoneManager == null) + { + if (!_tetherWarned) + PrintWarning("ZoneManager is not loaded, so no NPC is held inside its zone."); + _tetherWarned = true; + return true; + } + + object inside = ZoneManager.Call("IsPositionInZone", npc.Tether, at); + return !(inside is bool) || (bool)inside; + } + + /// Null when a zone can hold NPCs at a point, else why not. + private string TetherRefusal(string zone, Vector3 at) + { + if (zone == null) + return null; + if (ZoneManager == null) + return "tether: ZoneManager is not loaded on this server"; + if (ZoneManager.Call("CheckZoneID", zone) == null) + return $"tether: this server has no zone '{zone}'"; + object inside = ZoneManager.Call("IsPositionInZone", zone, at); + if (inside is bool && !(bool)inside) + return $"tether: the spot is outside the zone '{zone}'"; + return null; + } + + // ---- the kit's extras (D260, D265) ---- + + /// The first item in its inventory whose held entity is a , or null. + private static Item FindHeld(RunicNpcPlayer npc) where T : HeldEntity + { + foreach (ItemContainer container in new[] { npc.inventory.containerBelt, npc.inventory.containerMain }) + { + if (container == null) + continue; + foreach (Item item in container.itemList) + if (item != null && item.GetHeldEntity() is T) + return item; + } + + return null; + } + + /// Puts an item in its hands; only the belt can be held, so it is moved there first. + private static bool Hold(RunicNpcPlayer npc, Item item) + { + if (item == null) + return false; + if (npc.GetActiveItem() == item) + return true; + if (item.parent != npc.inventory.containerBelt && !item.MoveToContainer(npc.inventory.containerBelt)) + return false; + npc.UpdateActiveItem(item.uid); + return true; + } + + /// Back to the main weapon (the belt's first slot, Rust's own rule). + private static void MainWeapon(RunicNpcPlayer npc) + { + Item main = npc.inventory.containerBelt?.GetSlot(0); + if (main != null && npc.GetActiveItem() != main) + npc.EquipWeapon(true); + } + + private static bool IsMain(RunicNpcPlayer npc, Item item) => item != null && npc.inventory.containerBelt?.GetSlot(0) == item; + + /// + /// D260: each extra the profile opts into, when the moment calls for it. Rust's AI uses none of + /// them in open ground (stage 5 spike), so these are ours: heal in a lull, melee close in, + /// the flamethrower within its reach, a grenade at 5–20 m, a rocket at 15–80 m. + /// + private void KitTick(RunicNpcPlayer npc, RunicNpcBrain brain, BaseEntity target, float now) + { + KitUse k = npc.Profile.KitUse; + if (now < npc.KitBusyUntil) + return; + + if (k.Heal && npc.health < npc.MaxHealth() * 0.5f && now - npc.LastHurtAt > 3f && now >= npc.NextHeal) + { + npc.NextHeal = now + (Heal(npc) ? 6f : 15f); + if (now < npc.KitBusyUntil) + return; + } + + if (target == null || target.IsDestroyed) + { + MainWeapon(npc); + return; + } + + float distance = Vector3.Distance(npc.transform.position, target.transform.position); + + if (k.Melee && distance <= 2.5f) + { + Item melee = FindHeld(npc); + if (melee != null && !IsMain(npc, melee) && Hold(npc, melee)) + { + brain.Navigator.SetFacingDirectionEntity(target); + if (npc.MeleeAttack()) + npc.MeleeHits++; + return; + } + } + + if (k.Flamethrower && distance <= 7f) + { + Item flame = FindHeld(npc); + if (flame != null && !IsMain(npc, flame) && HasFuel(npc, flame) && Hold(npc, flame)) + return; // Rust's own attack tick fires what it holds. + } + + if (k.Grenades && distance > 5f && distance < 20f && now >= npc.NextThrow) + { + npc.NextThrow = now + 10f; + Item thrown = FindHeld(npc); + if (thrown != null && (thrown.parent == npc.inventory.containerBelt || thrown.MoveToContainer(npc.inventory.containerBelt)) && + npc.TryUseThrownWeapon(thrown, target, 10f)) + { + // Rust's own throw: aims for 1.5 s, throws, uses one up, and re-equips the main weapon. + npc.Throws++; + npc.KitBusyUntil = now + 2.5f; + return; + } + } + + if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket && npc.TargetLos) + { + npc.NextRocket = now + 10f; + if (FireRocket(npc, target)) + { + npc.KitBusyUntil = now + 1.5f; + return; + } + } + + Item held = npc.GetActiveItem(); + if (held != null && !IsMain(npc, held)) + { + HeldEntity h = held.GetHeldEntity() as HeldEntity; + bool keep = (h is BaseMelee && distance <= 4f) || (h is FlameThrower && distance <= 9f); + if (!keep) + MainWeapon(npc); + } + } + + /// D260: one syringe, medkit or bandage, used up, its health given at once. + private bool Heal(RunicNpcPlayer npc) + { + Item item = FindHeld(npc); + ItemModConsumable consumable = item?.info.GetComponent(); + if (consumable == null) + return false; + float amount = 0f; + foreach (ItemModConsumable.ConsumableEffect effect in consumable.effects) + if (effect.type == MetabolismAttribute.Type.Health || effect.type == MetabolismAttribute.Type.HealthOverTime) + amount += effect.amount; + if (amount <= 0f) + return false; + + Hold(npc, item); + npc.SignalBroadcast(BaseEntity.Signal.Attack); + item.UseItem(1); + npc.Heal(amount); + npc.Heals++; + npc.KitBusyUntil = Time.time + 1.5f; + timer.Once(1.2f, () => + { + if (npc != null && !npc.IsDestroyed && !npc.IsDead()) + MainWeapon(npc); + }); + return true; + } + + /// + /// D260: a rocket from the kit's launcher, one rocket used up. Rust's AI never fires a launcher + /// (stage 5 spike), so the rocket is launched here, from its eyes, on an arc that lands on the + /// target, and credited to the NPC. + /// + private bool FireRocket(RunicNpcPlayer npc, BaseEntity target) + { + Item launcher = FindHeld(npc); + if (launcher == null) + return false; + Item ammo = null; + ItemModProjectile projectile = null; + foreach (ItemContainer container in new[] { npc.inventory.containerMain, npc.inventory.containerBelt }) + { + if (container == null) + continue; + foreach (Item item in container.itemList) + { + ItemModProjectile p = item?.info.GetComponent(); + if (p != null && p.projectileObject != null && p.projectileObject.isValid && item.info.shortname.StartsWith("ammo.rocket")) + { + ammo = item; + projectile = p; + break; + } + } + + if (ammo != null) + break; + } + + if (ammo == null || !Hold(npc, launcher)) + return false; + + Vector3 from = npc.eyes.position + npc.eyes.BodyForward() * 1f; + Vector3 to = target.CenterPoint(); + BaseEntity rocket = GameManager.server.CreateEntity(projectile.projectileObject.resourcePath, from); + ServerProjectile mover = rocket != null ? rocket.GetComponent() : null; + if (mover == null) + { + if (rocket != null) + UnityEngine.Object.Destroy(rocket.gameObject); + return false; + } + + Vector3 direction = Arc(from, to, mover.speed, -Physics.gravity.y * mover.gravityModifier); + npc.SetAimDirection(direction); + rocket.SetCreatorEntity(npc); + rocket.OwnerID = 0UL; + mover.InitializeVelocity(direction * mover.speed); + rocket.SendMessage("SetDamageScale", npc.damageScale, SendMessageOptions.DontRequireReceiver); + rocket.Spawn(); + ammo.UseItem(1); + launcher.GetHeldEntity()?.SignalBroadcast(BaseEntity.Signal.Attack, string.Empty); + npc.Rockets++; + timer.Once(1.2f, () => + { + if (npc != null && !npc.IsDestroyed && !npc.IsDead()) + MainWeapon(npc); + }); + return true; + } + + /// The direction that lands a projectile of this speed and gravity on the point; straight at it when out of reach. + private static Vector3 Arc(Vector3 from, Vector3 to, float speed, float gravity) + { + Vector3 delta = to - from; + Vector3 flat = new Vector3(delta.x, 0f, delta.z); + float x = flat.magnitude; + if (gravity <= 0f || x < 0.5f) + return delta.normalized; + float y = delta.y; + float v2 = speed * speed; + float root = v2 * v2 - gravity * (gravity * x * x + 2f * y * v2); + if (root < 0f) + return delta.normalized; + float angle = Mathf.Atan((v2 - Mathf.Sqrt(root)) / (gravity * x)); + return (flat.normalized * Mathf.Cos(angle) + Vector3.up * Mathf.Sin(angle)).normalized; + } + + private static bool HasFuel(RunicNpcPlayer npc, Item flameItem) + { + FlameThrower f = flameItem.GetHeldEntity() as FlameThrower; + return f != null && (f.ammo > 0 || (f.fuelType != null && npc.inventory.FindItemByItemName(f.fuelType.shortname) != null)); + } + + /// + /// Called from ShotTest. D265: the main weapon is endless, as in Rust. A flamethrower held as an + /// EXTRA burns the kit's fuel: an empty tank is refilled from the inventory, and with none left + /// the NPC goes back to its main weapon. False means "no shot now". + /// + private bool FuelExtra(RunicNpcPlayer npc) + { + Item held = npc.GetActiveItem(); + FlameThrower f = held?.GetHeldEntity() as FlameThrower; + if (f == null || IsMain(npc, held) || f.ammo > 0) + return true; + Item fuel = f.fuelType != null ? npc.inventory.FindItemByItemName(f.fuelType.shortname) : null; + if (fuel == null) + { + MainWeapon(npc); + return false; + } + + int take = Mathf.Min(fuel.amount, f.maxAmmo); + fuel.UseItem(take); + f.ammo = take; + f.SendNetworkUpdateImmediate(); + return true; + } + + // ---- turrets (D259, D264, D267) ---- + + /// + /// turrets: ignore: no auto turret, flame turret or shotgun trap targets it. Answers only + /// for our own NPCs, and null for everything else. + /// + private object CanBeTargeted(BaseCombatEntity entity, MonoBehaviour turret) + { + MarkHook("CanBeTargeted"); + RunicNpcPlayer npc = entity as RunicNpcPlayer; + if (npc?.Profile == null || npc.Profile.Turrets != "ignore") + return null; + return false; + } + + private HarmonyLib.Harmony _harmony; + private const string HarmonyId = "runicnpc.sentries"; + private string _sentryPatch = "not tried"; + + /// + /// D267: Outpost's and Bandit Camp's sentries refuse every scientist in Ignore and + /// IsEntityHostile, before any Oxide or Carbon hook runs, so turrets: always is a + /// Harmony patch on those two. Each answers "hostile" for our always NPCs alone and runs + /// Rust's own method for everything else, Rust's scientists and bandit guards included. + /// + private void PatchSentries() + { + try + { + const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + MethodInfo ignore = typeof(NPCAutoTurret).GetMethod("Ignore", any, null, new[] { typeof(BasePlayer) }, null); + MethodInfo hostile = typeof(NPCAutoTurret).GetMethod("IsEntityHostile", any, null, new[] { typeof(BaseCombatEntity) }, null); + if (ignore == null || hostile == null) + { + _sentryPatch = $"not patched: this Rust has no NPCAutoTurret.{(ignore == null ? "Ignore" : "IsEntityHostile")}"; + PrintWarning($"turrets: always cannot reach the safe-zone sentries ({_sentryPatch})."); + return; + } + + _harmony = new HarmonyLib.Harmony(HarmonyId); + _harmony.Patch(ignore, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.IgnorePrefix)))); + _harmony.Patch(hostile, prefix: new HarmonyLib.HarmonyMethod(typeof(SentryPatch).GetMethod(nameof(SentryPatch.HostilePrefix)))); + _sentryPatch = "patched"; + } + catch (Exception e) + { + _sentryPatch = "failed: " + e.Message; + PrintWarning($"turrets: always cannot reach the safe-zone sentries: Harmony could not patch them ({e.Message})."); + } + } + + private void UnpatchSentries() + { + try + { + _harmony?.UnpatchAll(HarmonyId); + } + catch (Exception e) + { + PrintWarning($"Harmony could not unpatch the sentries: {e.Message}"); + } + + _harmony = null; + } + + /// The two prefixes. Each answers for our always NPCs only; Rust's method runs for anything else. + public static class SentryPatch + { + public static long Answered; + + private static bool Always(BaseEntity e) + { + RunicNpcPlayer npc = e as RunicNpcPlayer; + return npc != null && npc.Profile != null && npc.Profile.Turrets == "always" && !npc.IsDead(); + } + + public static bool IgnorePrefix(BasePlayer __0, ref bool __result) + { + if (!Always(__0)) + return true; + __result = false; + Answered++; + return false; + } + + public static bool HostilePrefix(BaseCombatEntity __0, ref bool __result) + { + if (!Always(__0)) + return true; + __result = true; + Answered++; + return false; + } + } + + // ---- PVE (D261, D271) ---- + + /// + /// TruePVE's and NextGenPVE's question. Ours are "allowed" both ways with players, as Rust's + /// scientists are (D261), unless the profile turns a direction off (D271). Null for anything + /// that is not one of ours facing a player. + /// + private object CanEntityTakeDamage(BaseCombatEntity entity, HitInfo info) + { + MarkHook("CanEntityTakeDamage"); + if (entity == null || info == null) + return null; + RunicNpcPlayer victim = entity as RunicNpcPlayer; + BasePlayer attacker = info.InitiatorPlayer; + if (victim?.Profile != null && attacker != null && !attacker.IsNpc) + return victim.Profile.HurtByPlayers; + RunicNpcPlayer from = info.Initiator as RunicNpcPlayer; + BasePlayer player = entity as BasePlayer; + if (from?.Profile != null && player != null && !player.IsNpc) + return from.Profile.HurtsPlayers; + return null; + } + + /// + /// Every damage event. Two jobs only: a harmless profile's NPC never hurts a player (D271), + /// and whom ours escort or serve are defended (D257, D269). Null otherwise. + /// + private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) + { + MarkHook("OnEntityTakeDamage"); + if (entity == null || info == null) + return null; + RunicNpcPlayer from = info.Initiator as RunicNpcPlayer; + BasePlayer player = entity as BasePlayer; + if (from?.Profile != null && !from.Profile.HurtsPlayers && player != null && !player.IsNpc) + return true; + if (_defenders.Count > 0) + Defend(entity, info); + return null; + } + + private void OnEntitySpawned(BaseNetworkable entity) + { + MarkHook("OnEntitySpawned"); + if (_ready) + Track(entity); + } + + #endregion + #region Registry, owners, spawning /// Every live NPC of ours, by net id. @@ -1396,6 +2927,7 @@ namespace Oxide.Plugins npc.PlacementId = placementId; npc.Slot = slot; npc.Home = position; + npc.HomeYaw = yaw; npc.MoveKind = kind; npc.MoveRadius = movement.Radius; npc.RouteName = route; @@ -1416,6 +2948,7 @@ namespace Oxide.Plugins npc.EquipWeapon(); _npcs[npc.NetId] = npc; + Index(npc, true); Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner); return npc; } @@ -1425,6 +2958,7 @@ namespace Oxide.Plugins { if (!_npcs.Remove(npc.NetId)) return; + Index(npc, false); if (npc.PlacementId != null) { @@ -1532,6 +3066,11 @@ namespace Oxide.Plugins ParseMode(movement.Mode, out kind, out route); if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) return $"route '{route}' is missing"; + + // D272: a zone that has gone holds its placement back, as a missing route does, rather than + // letting NPCs that were meant to stay inside it roam the map. + if (data.Tether != null && (ZoneManager == null || ZoneManager.Call("CheckZoneID", data.Tether) == null)) + return ZoneManager == null ? $"its zone '{data.Tether}' needs ZoneManager, which is not loaded" : $"its zone '{data.Tether}' is missing"; return null; } @@ -1702,6 +3241,7 @@ namespace Oxide.Plugins ps.LastError = null; ps.Slots[slot].Npc = npc; + npc.Tether = data.Tether; } /// The count a placement plans, whether or not its NPCs are alive now. @@ -1726,7 +3266,9 @@ namespace Oxide.Plugins return $"RunicNPC: {total} NPC(s) on this server{estimate}. Measured on a test server, they add about " + $"{Mathf.Max(0.1f, hundreds * 1.5f):0.#} ms to every server frame while idle. If all fight at once they add about " + $"{hundreds * 5f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " + - $"{Mathf.Max(0.3f, hundreds * 2.5f):0.#} s."; + $"{Mathf.Max(0.3f, hundreds * 2.5f):0.#} s. Fighting other NPCs (a faction setting) was measured to cost about " + + $"what fighting players does; on a map whose own AI is busy, each then reacts only every " + + $"{Mathf.Max(0.5f, hundreds * 7f):0.#} s."; } #endregion @@ -1748,6 +3290,8 @@ namespace Oxide.Plugins LoadData(); RevalidateProfiles(); RebuildPlacements(); + TrackWorld(); + PatchSentries(); _ready = true; timer.Every(1f, TickPlacements); Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " + @@ -1768,6 +3312,8 @@ namespace Oxide.Plugins RunicNpcPlayer npc = entity as RunicNpcPlayer; if (npc != null) Unregister(npc); + else + Untrack(entity); } /// A plugin's NPCs die with it (§2: plugin:<name>). @@ -1785,6 +3331,7 @@ namespace Oxide.Plugins private void Unload() { _unloading = true; + UnpatchSentries(); Despawn(_npcs.Values); _npcs.Clear(); if (_placementsDirty) @@ -1873,7 +3420,15 @@ namespace Oxide.Plugins ["rest"] = brain != null ? brain.Rest.ToString() : "?", ["state"] = brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none", ["leashes"] = brain != null ? brain.Leashes : 0, - ["regrounded"] = brain != null ? brain.Regrounded : 0 + ["regrounded"] = brain != null ? brain.Regrounded : 0, + ["faction"] = n.Profile != null ? FactionOf(n) : null, + ["target"] = n.NpcTarget != null && !n.NpcTarget.IsDestroyed ? n.NpcTarget.ShortPrefabName : null, + ["engaged"] = n.Engaged, + ["escort"] = n.Escort != null && !n.Escort.IsDestroyed ? (n.Escort is BasePlayer ? ((BasePlayer)n.Escort).UserIDString : n.Escort.ShortPrefabName) : null, + ["ally"] = n.AllyClan != 0L ? "clan:" + n.AllyClan : n.AllyTeam != 0UL ? "team:" + n.AllyTeam : n.AllyPlayer != 0UL ? "player:" + n.AllyPlayer : null, + ["tether"] = n.Tether, + ["shotsMarked"] = n.ShotsMarked, + ["kitUsed"] = new Dictionary { ["heals"] = n.Heals, ["throws"] = n.Throws, ["rockets"] = n.Rockets, ["meleeHits"] = n.MeleeHits } }; } @@ -1955,6 +3510,10 @@ namespace Oxide.Plugins return $"position: {off}, and only a sentry may stand off Rust's navmesh (D219)"; } + error = TetherRefusal(data.Tether, data.Position.V); + if (error != null) + return error; + PlacementState old; if (_placements.TryGetValue(id, out old)) Despawn(old.Slots.Select(s => s.Npc).Where(n => n != null)); @@ -2113,6 +3672,114 @@ namespace Oxide.Plugins /// The cost warning (D227) for the NPCs this server plans, plus . private string RunicNpc_CostWarning(int adding) => CostWarning(PlannedTotal() + Math.Max(0, adding)); + // ---- API 4 (stage 5) ---- + + /// API 4 (D254): the faction table, one row per pair, both ways (D268). + private JArray RunicNpc_Factions() => JArray.FromObject(_profileFile.Factions); + + /// + /// API 4 (D254): replaces the faction table, as a site does with every profile push, and marks + /// the server managed (D221). Null on success; otherwise why not, and nothing changes. + /// + private string RunicNpc_SetFactions(JArray pairs) + { + List table; + try + { + table = pairs != null ? pairs.ToObject>() : new List(); + } + catch (Exception e) + { + return "the faction table could not be read: " + e.Message; + } + + string error = ValidateFactions(table); + if (error != null) + return error; + _profileFile.Factions = table; + _profileFile.Managed = true; + SaveProfiles(); + RevalidateProfiles(); + return null; + } + + /// + /// API 4 (D258, D269): one of ours escorts an entity, a player or anything else; null stops it. + /// Null on success, else why not. + /// + private string RunicNpc_Escort(ulong netId, BaseEntity target) + { + RunicNpcPlayer npc; + if (!_npcs.TryGetValue(netId, out npc)) + return $"there is no RunicNPC NPC {netId}"; + return SetEscort(npc, target); + } + + /// + /// API 4 (D257, D270): one of ours is allied to a clan (clan, the game's clan id), a team + /// (team), or a player and that player's team (player, a Steam id), or to nobody + /// (none). Null on success, else why not. + /// + private string RunicNpc_Ally(ulong netId, string kind, string id) + { + RunicNpcPlayer npc; + if (!_npcs.TryGetValue(netId, out npc)) + return $"there is no RunicNPC NPC {netId}"; + return SetAlly(npc, kind, id); + } + + /// API 4 (D272): holds one of ours inside a ZoneManager zone, or frees it with null. + private string RunicNpc_Tether(ulong netId, string zone) + { + RunicNpcPlayer npc; + if (!_npcs.TryGetValue(netId, out npc)) + return $"there is no RunicNPC NPC {netId}"; + if (zone != null && npc.Profile.IsSentry) + return $"{npc.RnName} is a sentry and never moves, so it needs no zone"; + string error = TetherRefusal(zone, npc.transform.position); + if (error != null) + return error; + npc.Tether = zone; + return null; + } + + private string SetAlly(RunicNpcPlayer npc, string kind, string id) + { + long clan = 0L; + ulong team = 0UL, player = 0UL; + switch (kind) + { + case "none": + case null: + break; + case "clan": + if (!long.TryParse(id, out clan) || clan == 0L) + return "a clan is the game's clan id"; + if (ReadClan(clan) == null) + return $"this server has no clan {id}"; + break; + case "team": + if (!ulong.TryParse(id, out team) || team == 0UL || RelationshipManager.ServerInstance?.FindTeam(team) == null) + return $"this server has no team {id}"; + break; + case "player": + if (!ulong.TryParse(id, out player) || player == 0UL) + return "a player is a Steam id"; + BasePlayer p = BasePlayer.FindAwakeOrSleepingByID(player); + if (p != null && p.currentTeam != 0UL) + team = p.currentTeam; + break; + default: + return $"'{kind}' is not clan, team, player or none"; + } + + npc.AllyClan = clan; + npc.AllyTeam = team; + npc.AllyPlayer = player; + NoteDefender(npc); + return null; + } + #endregion #region In game: the commands (PLAN.md §5, stage 3) @@ -2184,7 +3851,9 @@ namespace Oxide.Plugins ["tp"] = new Verb { Permission = PermAdmin, Usage = "tp ", Run = CmdTp }, ["respawn"] = new Verb { Permission = PermAdmin, Usage = "respawn ", Run = CmdRespawn }, ["clear"] = new Verb { Permission = PermAdmin, Usage = "clear ", Run = CmdClear }, - ["profile"] = new Verb { Permission = PermAdmin, Usage = "profile list | show | create [from=] | set | delete ", Run = CmdProfile, ConsoleOnly = true } + ["profile"] = new Verb { Permission = PermAdmin, Usage = "profile list | show | create [from=] | set | delete ", Run = CmdProfile, ConsoleOnly = true }, + ["follow"] = new Verb { Permission = PermPlace, Usage = "follow ", Run = CmdFollow }, + ["faction"] = new Verb { Permission = PermAdmin, Usage = "faction list | set hostile|neutral|allied | clear ", Run = CmdFaction, ConsoleOnly = true } }; } @@ -2273,6 +3942,8 @@ namespace Oxide.Plugins [ConsoleCommand("rnpc.clear")] private void CcClear(ConsoleSystem.Arg arg) => RunConsole(arg, "clear"); [ConsoleCommand("rnpc.profile")] private void CcProfile(ConsoleSystem.Arg arg) => RunConsole(arg, "profile"); [ConsoleCommand("rnpc.help")] private void CcHelp(ConsoleSystem.Arg arg) => RunConsole(arg, "help"); + [ConsoleCommand("rnpc.follow")] private void CcFollow(ConsoleSystem.Arg arg) => RunConsole(arg, "follow"); + [ConsoleCommand("rnpc.faction")] private void CcFaction(ConsoleSystem.Arg arg) => RunConsole(arg, "faction"); // ---- where an admin stands and looks ---- @@ -2378,6 +4049,7 @@ namespace Oxide.Plugins public float? Radius; public Vector3? At; public float? Yaw; + public string Tether; } /// @@ -2392,7 +4064,7 @@ namespace Oxide.Plugins { int eq = args[i].IndexOf('='); if (eq <= 0) - return $"'{args[i]}' is not key=value. Options: count= respawn= mode= move= radius="; + return $"'{args[i]}' is not key=value. Options: count= respawn= mode= move= radius= tether="; string key = args[i].Substring(0, eq).ToLowerInvariant(); string value = args[i].Substring(eq + 1); if (!seen.Add(key)) @@ -2436,8 +4108,12 @@ namespace Oxide.Plugins if (!TryFloat(value, out f)) return "yaw= is in degrees"; o.Yaw = f; break; + case "tether": + if (!TetherNameRule.IsMatch(value)) return "tether= is a ZoneManager zone id"; + o.Tether = value; + break; default: - return $"there is no option {key}=. Options: count= respawn= mode= move= radius="; + return $"there is no option {key}=. Options: count= respawn= mode= move= radius= tether="; } } @@ -2580,9 +4256,16 @@ namespace Oxide.Plugins Count = o.Count ?? 1, Respawn = o.Respawn ?? 300f, RespawnMode = o.Mode ?? "each", - Movement = movement + Movement = movement, + Tether = o.Tether }; + if (o.Tether != null && p.IsSentry) + { + c.Say($"'{name}' is a sentry and never moves: drop tether=."); + return; + } + string id; bool built; error = CreatePlacement(data, out id, out built); @@ -2596,7 +4279,7 @@ namespace Oxide.Plugins ParseMode((movement ?? p.Movement).Mode, out kind, out route); Movement m = movement ?? p.Movement; c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " + - $"respawn {data.Respawn:0} s, {data.RespawnMode}. Rename it with rnpc rename {id} ."); + $"respawn {data.Respawn:0} s, {data.RespawnMode}{(data.Tether != null ? $", inside zone {data.Tether}" : "")}. Rename it with rnpc rename {id} ."); if (built) c.Say("This spot is on a player-built structure. If it is destroyed, the NPC falls back to the nearest navmesh."); c.Say(CostWarning(PlannedTotal())); @@ -2751,6 +4434,134 @@ namespace Oxide.Plugins c.Say($"health {npc.health:0}/{npc.MaxHealth():0} · state {(brain?.CurrentState != null ? brain.CurrentState.StateType.ToString() : "none")} · " + $"{(brain != null ? brain.Rest.ToString().ToLowerInvariant() : "?")} · {move} · kit {npc.Kit}"); c.Say($"target {targetText} · {Meters(npc.transform.position, npc.Home)} from home"); + + BaseCombatEntity own = npc.NpcTarget; + HashSet hunts = HuntsOf(npc); + c.Say($"faction {FactionOf(npc)} · hunts {(hunts != null ? string.Join(", ", hunts.ToArray()) : "players only")} · " + + $"fighting {(own != null && !own.IsDestroyed ? $"{own.ShortPrefabName} {Meters(own.transform.position, npc.transform.position)}{(npc.Engaged ? "" : " (waits)")}" : "no NPC")}"); + var orders = new List(); + if (npc.Escort != null && !npc.Escort.IsDestroyed) + orders.Add("escorts " + (npc.Escort is BasePlayer ? ((BasePlayer)npc.Escort).displayName : npc.Escort.ShortPrefabName)); + if (npc.HasAlly) + orders.Add("allied to " + (npc.AllyClan != 0L ? "clan " + npc.AllyClan : npc.AllyTeam != 0UL ? "team " + npc.AllyTeam : "player " + npc.AllyPlayer)); + if (npc.Tether != null) + orders.Add("held in zone " + npc.Tether); + if (orders.Count > 0) + c.Say(string.Join(" · ", orders.ToArray())); + } + + // ---- follow (D258, D269) ---- + + /// + /// rnpc follow <placement> <player|me|off>: the placement's live NPCs escort a + /// player, for trying it out in game (D258). In memory only: a respawn or a reload ends it, and a + /// placement never escorts anyone of its own accord. + /// + private void CmdFollow(Caller c, string[] args) + { + if (args.Length < 2) + { + c.Say("Usage: rnpc " + _verbs["follow"].Usage); + return; + } + + PlacementState ps; + if (!_placements.TryGetValue(args[0], out ps)) + { + c.Say($"There is no placement '{args[0]}'. rnpc near lists those around you."); + return; + } + + BasePlayer target = null; + string who = args[1]; + if (who == "me") + { + if (c.Player == null) + { + c.Say("The server console cannot be followed; name a player."); + return; + } + + target = c.Player; + } + else if (who != "off") + { + target = BasePlayer.Find(who); + if (target == null) + { + c.Say($"No player on the server matches '{who}'."); + return; + } + } + + int n = 0; + foreach (Slot s in ps.Slots) + { + if (s.Npc == null || s.Npc.IsDestroyed) + continue; + string error = SetEscort(s.Npc, target); + if (error != null) + { + c.Say(error + "."); + return; + } + + n++; + } + + c.Say(n == 0 ? $"{ps.Id} has no live NPCs now." : target == null + ? $"{ps.Id}: {n} NPC(s) stop escorting and walk back to their spot." + : $"{ps.Id}: {n} NPC(s) escort {target.displayName}. They walk back to their spot if {target.displayName} dies or leaves; rnpc follow {ps.Id} off ends it."); + } + + // ---- faction (D254, D268) ---- + + /// rnpc.faction: the faction table on a standalone server; refused when a site manages it (D221). + private void CmdFaction(Caller c, string[] args) + { + string sub = args.Length > 0 ? args[0].ToLowerInvariant() : "list"; + if (sub == "list") + { + List table = _profileFile.Factions; + var named = _profiles.Where(kv => !string.IsNullOrEmpty(kv.Value.Faction)).GroupBy(kv => kv.Value.Faction) + .Select(g => $"{g.Key} ({string.Join(", ", g.Select(kv => kv.Key).ToArray())})").ToList(); + c.Say($"Factions in use: {(named.Count > 0 ? string.Join("; ", named.ToArray()) : "none")}. Rust's own: scientists, animals."); + if (table.Count == 0) + c.Say("The table is empty: every pair is neutral, and a profile with no faction settings fights players only (D255)."); + foreach (FactionPair f in table.OrderBy(f => f.A).ThenBy(f => f.B)) + c.Say($"{f.A} ↔ {f.B}: {f.Relation}"); + if (_profileFile.Managed) + c.Say("Managed by this server's website (D221): edit the table there."); + return; + } + + if (_profileFile.Managed) + { + c.Say("This server's faction table is managed by its website (D221): edit it there."); + return; + } + + if ((sub == "set" && args.Length < 4) || (sub == "clear" && args.Length < 3) || (sub != "set" && sub != "clear")) + { + c.Say("Usage: rnpc." + _verbs["faction"].Usage); + return; + } + + string a = args[1], b = args[2]; + var next = _profileFile.Factions.Where(f => PairKey(f.A, f.B) != PairKey(a, b)).ToList(); + if (sub == "set") + next.Add(new FactionPair { A = a, B = b, Relation = args[3].ToLowerInvariant() }); + string error = ValidateFactions(next); + if (error != null) + { + c.Say(Regex.Replace(error, @"^factions\[\d+\]: ", "")); + return; + } + + _profileFile.Factions = next; + SaveProfiles(); + RevalidateProfiles(); + c.Say(sub == "set" ? $"{a} ↔ {b}: {args[3].ToLowerInvariant()}, both ways (D268)." : $"{a} ↔ {b}: back to neutral."); } private void CmdProfiles(Caller c, string[] args) @@ -3127,6 +4938,28 @@ namespace Oxide.Plugins private string SetProfileField(string name, Profile profile, string field, string value) { JObject o = JObject.FromObject(profile); + + // Stage 5: a profile's own exceptions are keyed by faction (D254), so one is set, or removed + // with none, by name: relations.scientists hostile, relations.profile:warden none. + if (field.StartsWith("relations.")) + { + string key = field.Substring(10); + JObject relations = (JObject)o["relations"]; + if (value == "none" || value == "-") + relations.Remove(key); + else + relations[key] = value; + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + + if (field == "faction" && (value == "none" || value == "-")) + { + o["faction"] = null; + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + JToken target = o.SelectToken(field); if (target == null || target is JObject) return target == null @@ -3157,6 +4990,12 @@ namespace Oxide.Plugins if (!TryFloat(value, out f)) return $"'{field}' is a number"; replacement = new JValue(f); } + else if (target.Type == JTokenType.Boolean) + { + string v = value.ToLowerInvariant(); + if (v != "true" && v != "false" && v != "on" && v != "off") return $"'{field}' is true or false"; + replacement = new JValue(v == "true" || v == "on"); + } else replacement = new JValue(value); @@ -3217,6 +5056,12 @@ namespace Oxide.Plugins sb.Append($"placements={_placements.Count} queue={_queue.Count} waiting: " + $"{string.Join("; ", _placements.Values.Where(ps => ps.Waiting != null).Select(ps => ps.Id + ": " + ps.Waiting).DefaultIfEmpty("-").ToArray())}").Append(nl); sb.Append($"routes={_routeFile.Routes.Count}").Append(nl); + double fightMs = RunicNpcBrain.FightTicks > 0 ? RunicNpcBrain.FightMs / RunicNpcBrain.FightTicks : 0.0; + double senseMs = SenseRuns > 0 ? SenseMs / SenseRuns : 0.0; + sb.Append($"stage 5: factions={_profileFile.Factions.Count} pairs, tracked scientists={_scientists.Count} animals={_animals.Count}, " + + $"engaged={_npcs.Values.Count(n => n.Engaged)} defenders={_defenders.Count} alarms={Alarms} defences={Defences}, " + + $"fight {fightMs:0.000} ms/think over {RunicNpcBrain.FightTicks}, sense {senseMs:0.000} ms over {SenseRuns}").Append(nl); + sb.Append($"sentries (turrets: always): {_sentryPatch}, answered {SentryPatch.Answered}; zonemanager={(ZoneManager != null ? "loaded" : "absent")}").Append(nl); CapsConfig caps = _config.Caps; sb.Append($"caps: total={caps.Total} perOwner={caps.PerOwner} perProfile={caps.PerProfile} spawnsPerSecond={caps.SpawnsPerSecond} (0 = none)").Append(nl); sb.Append(CostWarning(PlannedTotal())); @@ -3229,8 +5074,7 @@ namespace Oxide.Plugins { if (arg.Connection != null && !arg.IsAdmin) return; - _profileFile = ReadData(DataProfiles); - if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); + ReadProfileFile(); _routeFile = ReadData(DataRoutes); if (_routeFile.Routes == null) _routeFile.Routes = new Dictionary(); RevalidateProfiles(); diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index 0a4a2c1..9678467 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -69,7 +69,17 @@ const PLUGIN_TOML = path.join(ROOT, 'plugin.toml') * That half cannot be read statically; it is the reviewer's to check, which is * why the reason goes here where the reviewer will see it. */ -const ANSWERS_DELIBERATELY = Object.create(null) +const ANSWERS_DELIBERATELY = Object.assign(Object.create(null), { + // Stage 5 (D259, D264): turrets: ignore. Answers false for RunicNPC's own NPCs whose profile says + // ignore, so no auto turret, flame turret or shotgun trap targets them; null for everything else. + CanBeTargeted: 'turrets: ignore (D259, D264), our own NPCs only', + // Stage 5 (D261, D271): TruePVE's and NextGenPVE's question. Answers only for one of ours facing a + // real player, with the profile's two PVE checkboxes; null for everything else. + CanEntityTakeDamage: 'PVE allowed both ways, or the profile opt-out (D261, D271), our own NPCs only', + // Stage 5 (D271): cancels damage only when one of ours whose profile says it cannot hurt players + // hits a real player. Its other job, defending an ally or escort (D257, D269), never answers. + OnEntityTakeDamage: 'a harmless profile never hurts a player (D271), our own NPCs only', +}) /** Plugins RunicNPC must require, whatever else it requires (D217). */ const REQUIRED_PLUGINS = ['Kits'] diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js index c7628cb..9fc660d 100644 --- a/scripts/checkPlugin.test.js +++ b/scripts/checkPlugin.test.js @@ -100,13 +100,13 @@ test('returning null is not good enough — the signature is the rule', () => { // `return null` today is one edit away from `return true` tomorrow, and the // edit that breaks it looks harmless in a diff. A void method cannot be turned // into a veto without changing its signature, which is visible. - const methods = ` private bool CanBeTargeted(BaseCombatEntity entity, AutoTurret turret) + const methods = ` private bool CanLootEntity(BasePlayer player, StorageContainer container) { return true; }` - const problems = check(source({ expected: ['CanBeTargeted'], methods }), toml()) - assert.match(problems[0], /CanBeTargeted returns bool, not void/) + const problems = check(source({ expected: ['CanLootEntity'], methods }), toml()) + assert.match(problems[0], /CanLootEntity returns bool, not void/) }) test('a method that is not shaped like a hook is left alone', () => { -- 2.49.1 From 4ea50c4fd2b9e28dd76e590377741f8402b01806 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 1 Oct 2026 08:39:33 -0500 Subject: [PATCH 08/24] fix: escort end, line of sight and rockets; test(harness): stage 5 group (stage 5) What the rigs' stage 5 runs found in the plugin: - an escort whose charge was destroyed was never released: Unity's == says a destroyed entity is null, so CheckEscort returned before it looked. It now tests the reference itself (D269); - our own fight's line of sight no longer uses Rust's CanSeeTarget for an NPC target: its rays are unbounded, so on open ground they run past the target into the terrain behind it. A bounded linecast to the target's centre, which counts the target's own collider as seen; - a rocket's line of sight: Rust's sensed one for a real player, ours for an NPC or an animal, which are never in Rust's player memory. tools/RunicNpcTest.cs 0.5.0 adds `rnt.run s5`: 52 checks of the faction table and its refusals, faction fights, the leash, shooting back, the alarm, allies spared and defended (players and buildings), escort, the guard, the tether, PVE, turrets (`ignore` through the hook, `always` on the map's own sentries with the org lead's condition: no stock scientist or bandit guard ever targeted), and the kit's extras used up. Also `rnt.duel`, `rnt.kitprobe`, `rnt.rocketprobe` and `rnt.list`, for looking. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin/RunicNPC.cs | 36 ++- tools/RunicNpcTest.cs | 690 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 707 insertions(+), 19 deletions(-) diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index 514071e..5670545 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -2063,14 +2063,14 @@ namespace Oxide.Plugins } /// - /// Rust's CanSeeTarget for a player; for anything else (which Rust answers "seen" without - /// a ray), a line from its eyes that counts the target's own collider as seen. + /// A line from its eyes to the target's centre that counts the target's own collider as seen. + /// Not Rust's CanSeeTarget, even for a scientist or one of ours: its rays are unbounded, + /// so on open ground they run past the target into the terrain behind it, and an NPC 25 m off + /// read as unseen until ours had walked right up to it (stage 5 build). Players are Rust's + /// design's to see; this is only for what our own fight takes on. /// private static bool Los(RunicNpcPlayer 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; @@ -2335,15 +2335,17 @@ namespace Oxide.Plugins /// D269: whom it escorts died or left the server; it walks back to its spot. private void CheckEscort(RunicNpcPlayer npc) { + // Unity's == says a destroyed entity is null, which is exactly the case to catch here. BaseEntity e = npc.Escort; - if (e == null) + if (ReferenceEquals(e, null)) return; - BasePlayer p = e as BasePlayer; - BaseCombatEntity c = e as BaseCombatEntity; - bool gone = e.IsDestroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected); + bool destroyed = e == null || e.IsDestroyed; + BasePlayer p = destroyed ? null : e as BasePlayer; + BaseCombatEntity c = destroyed ? null : e as BaseCombatEntity; + bool gone = destroyed || (c != null && c.IsDead()) || (p != null && !p.IsNpc && !p.IsConnected); if (!gone) return; - string who = p != null ? p.displayName : e.ShortPrefabName; + string who = p != null ? p.displayName : destroyed ? "its charge" : e.ShortPrefabName; npc.Escort = null; NoteDefender(npc); npc.RnBrain?.Navigator?.SetDestination(npc.Home, BaseNavigator.NavigationSpeed.Normal); @@ -2491,10 +2493,17 @@ namespace Oxide.Plugins } } - if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket && npc.TargetLos) + if (k.Rockets && distance >= 15f && distance <= 80f && now >= npc.NextRocket) { - npc.NextRocket = now + 10f; - if (FireRocket(npc, target)) + npc.NextRocket = now + 2f; + // A real player's line of sight is the one Rust's design shoots on; an NPC (a scientist, one of + // ours) or an animal is never in Rust's player memory, so it is ours. + BaseCombatEntity aim = target as BaseCombatEntity; + BasePlayer human = aim as BasePlayer; + bool seen = human != null && !human.IsNpc + ? brain.Senses?.Memory != null && brain.Senses.Memory.IsLOS(aim) + : aim != null && Los(npc, aim); + if (seen && FireRocket(npc, target)) { npc.KitBusyUntil = now + 1.5f; return; @@ -2594,6 +2603,7 @@ namespace Oxide.Plugins ammo.UseItem(1); launcher.GetHeldEntity()?.SignalBroadcast(BaseEntity.Signal.Attack, string.Empty); npc.Rockets++; + npc.NextRocket = Time.time + 10f; timer.Once(1.2f, () => { if (npc != null && !npc.IsDestroyed && !npc.IsDead()) diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 3055ff5..c5175ce 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -24,7 +24,9 @@ namespace Oxide.Plugins /// /// /// rnt.run <group> runs one group: api, hooks, move, - /// sentry, sleep, cmd (stage 3's commands), api3 (stage 4's calls), place, or all for each in turn. + /// sentry, sleep, cmd (stage 3's commands), api3 (stage 4's calls), place, + /// s5 (stage 5's behaviour: factions, fights, alarm, allies, escort, guard, tether, turrets, + /// PVE and the kit's extras), or all for each in turn. /// rnt.run place leaves two placements behind for the restart check, and /// rnt.after checks, after a reload or restart, that every placement is back and nothing /// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and @@ -42,8 +44,8 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [Info("RunicNpcTest", "Runic Gateway", "0.4.0")] - [Description("RunicNPC stage 2, 3 and 4 tests. A developer tool: never ship it.")] + [Info("RunicNpcTest", "Runic Gateway", "0.5.0")] + [Description("RunicNPC stage 2, 3, 4 and 5 tests. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { [PluginReference] private Plugin RunicNPC; @@ -277,6 +279,7 @@ namespace Oxide.Plugins all["t_leash"] = leash; if (withEach) all["t_each"] = Prof(name: "TEach"); + Stage5Profiles(all); return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "no answer" }; } @@ -325,6 +328,100 @@ namespace Oxide.Plugins arg.ReplyWith($"rnt: spawned {n} as {Owner}"); } + /// A probe: two profiles 15 m apart, described every two seconds for 30 s. + [ConsoleCommand("rnt.duel")] + private void CmdDuel(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + BasePlayer x = Spawn(OnMesh(f + new Vector3(-140, 0, 0)), arg.GetString(0, "t_red")); + BasePlayer y = Spawn(OnMesh(f + new Vector3(-140, 0, 15)), arg.GetString(1, "t_blue")); + int n = 0; + timer.Repeat(2f, 15, () => + { + n++; + foreach (BasePlayer p in new[] { x, y }) + { + Dictionary d = p == null || p.IsDestroyed ? null : Describe(p); + Puts($"[duel {n}] {(d == null ? "gone" : JsonConvert.SerializeObject(d))} hp={(p == null || p.IsDestroyed ? 0 : p.health):0} held={(p == null || p.IsDestroyed ? "-" : p.GetActiveItem()?.info.shortname ?? "none")}"); + } + if (n == 15) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: duel started"); + } + + /// A probe: a t_kit NPC with a flamethrower and fuel, a stand-in 4 m off, every second for 20 s. + [ConsoleCommand("rnt.kitprobe")] + private void CmdKitProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + Vector3 at = OnMesh(f + new Vector3(-30, 0, -60)); + BasePlayer flamer = Spawn(at, "t_kit"); + Give(flamer, "flamethrower", 1); + Give(flamer, "lowgradefuel", 50); + BasePlayer target = StandIn(OnMesh(at + new Vector3(4, 0, 0)), 63); + int n = 0; + timer.Repeat(1f, 20, () => + { + n++; + ScientistNPC s = flamer as ScientistNPC; + Item held = flamer == null || flamer.IsDestroyed ? null : flamer.GetActiveItem(); + FlameThrower tank = held?.GetHeldEntity() as FlameThrower; + if (n == 3 && tank != null) tank.ammo = 0; + var mem = s?.Brain?.Senses?.Memory; + Puts($"[kit {n}] state={s?.Brain?.CurrentState?.StateType} held={held?.info.shortname ?? "none"} ammo={(tank != null ? tank.ammo : -1)} fuel={(flamer == null || flamer.IsDestroyed ? -1 : Count(flamer, "lowgradefuel"))} knows={Knows(flamer, target)} los={(mem != null && mem.IsLOS(target))} hits={_hitsOnStandIns}"); + if (n == 20) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: kit probe started"); + } + + /// Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks. + [ConsoleCommand("rnt.list")] + private void CmdList(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + string owner = arg.GetString(0, null); + var rows = List(owner); + arg.ReplyWith(string.Join(Environment.NewLine, rows.Select(d => JsonConvert.SerializeObject(d)).ToArray()) + Environment.NewLine + $"rnt: {rows.Count} NPC(s)"); + } + + /// A probe: a t_kit rocketeer and a stock scientist 25 m off, described every 2 s for 40 s. + [ConsoleCommand("rnt.rocketprobe")] + private void CmdRocketProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + Vector3 at = OnMesh(f + new Vector3(-30, 0, -60)); + BasePlayer r = Spawn(at, "t_kit"); + Give(r, "rocket.launcher", 1); + Give(r, "ammo.rocket.basic", 2); + ScientistNPC target = SpawnScientist(at + new Vector3(25, 0, 0)); + int n = 0; + timer.Repeat(2f, 20, () => + { + n++; + Dictionary d = Gone(r) ? null : Describe(r); + string line = "-"; + if (!Gone(r) && !Gone(target)) + { + RaycastHit hh; + line = Physics.Linecast(r.eyes.position, target.CenterPoint(), out hh, 1218519041, QueryTriggerInteraction.Ignore) + ? $"hit {hh.collider?.name}/{LayerMask.LayerToName(hh.collider != null ? hh.collider.gameObject.layer : 0)}/{hh.GetEntity()?.ShortPrefabName ?? "none"} at {hh.distance:0.0}" + : "clear"; + line += $" eyes={r.eyes.position.y:0.0} centre={target.CenterPoint().y:0.0}"; + } + Puts($"[rocket {n}] line={line} target={TargetOf(d)} engaged={d?["engaged"]} state={d?["state"]} rest={d?["rest"]} kit={(d != null ? JsonConvert.SerializeObject(d["kitUsed"]) : "-")} held={(Gone(r) ? "-" : r.GetActiveItem()?.info.shortname)} ammo={(Gone(r) ? -1 : Count(r, "ammo.rocket.basic"))} sci={(Gone(target) ? 0f : target.health):0} dist={(Gone(r) || Gone(target) ? 0f : Vector3.Distance(r.transform.position, target.transform.position)):0}"); + if (n == 20) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: rocket probe started"); + } + [ConsoleCommand("rnt.clear")] private void CmdClear(ConsoleSystem.Arg arg) { @@ -351,11 +448,11 @@ namespace Oxide.Plugins } Dictionary refused = SetTestProfiles(); - Check("setup.profiles", refused.Count == 1 && refused.ContainsKey("t_bad"), "refused: " + string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray())); + Check("setup.profiles", refused.Count == 3 && refused.ContainsKey("t_bad") && refused.ContainsKey("t_badfaction") && refused.ContainsKey("t_badturrets"), "refused: " + string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray())); string route = SetLoop(field + new Vector3(60f, 0f, 0f)); Check("setup.route", route == null, route ?? "t_loop set"); - string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "place" } : new[] { group }; + string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "place" } : new[] { group }; foreach (string g in groups) { IEnumerator inner = g == "api" ? Api(field) @@ -367,6 +464,7 @@ namespace Oxide.Plugins : g == "api3" ? Api3(field) : g == "floor" ? Floor(field) : g == "place" ? Place(field) + : g == "s5" ? Stage5() : null; if (inner == null) { @@ -388,7 +486,7 @@ namespace Oxide.Plugins private IEnumerator Api(Vector3 field) { - Check("api.version", Api("RunicNpc_ApiVersion") == 3, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + Check("api.version", Api("RunicNpc_ApiVersion") == 4, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); JObject profiles = Api("RunicNpc_Profiles"); Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]); @@ -1188,6 +1286,586 @@ namespace Oxide.Plugins Note($"after: {_pass} pass, {_fail} fail"); } + // ==== stage 5: behaviour (docs/runicnpc/PLAN.md, D253–D272) ==== + + private const string ScientistPrefab = "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab"; + private const string Zone = "rnt_zone"; + + private readonly List _escortEnded = new List(); + + private void OnRunicNpcEscortEnded(BasePlayer npc, string profile, string owner) => _escortEnded.Add($"{profile}/{owner}"); + + /// The stage 5 test profiles, beside the others; three are refused on purpose. + private static void Stage5Profiles(JObject all) + { + JObject P(string name, Action edit) + { + JObject p = Prof(name: name); + edit(p); + return p; + } + + all["t_red"] = P("TRed", p => p["faction"] = "red"); + all["t_blue"] = P("TBlue", p => p["faction"] = "blue"); + all["t_hunter"] = P("THunter", p => + { + p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); + p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 40f, attack = 30f }); + }); + all["t_leashed"] = P("TLeashed", p => + { + p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); + p["ranges"] = JObject.FromObject(new { sense = 40f, loseTarget = 50f, chase = 8f, attack = 30f }); + }); + all["t_bully"] = P("TBully", p => p["relations"] = JObject.FromObject(new Dictionary { ["profile:t_roam"] = "hostile" })); + all["t_guard"] = P("TGuard", p => p["role"] = "guard"); + all["t_kit"] = P("TKit", p => + { + p["kitUse"] = JObject.FromObject(new { heal = true, grenades = true, melee = true, rockets = true, flamethrower = true }); + // Hostile to scientists, so the rocketeer's target is an NPC: Rust's player line of sight never sees a stand-in at 25 m. + p["relations"] = JObject.FromObject(new Dictionary { ["scientists"] = "hostile" }); + }); + all["t_ignore"] = P("TIgnore", p => p["turrets"] = "ignore"); + all["t_always"] = P("TAlways", p => p["turrets"] = "always"); + all["t_unkill"] = P("TUnkill", p => p["hurtByPlayers"] = false); + all["t_harmless"] = P("THarmless", p => p["hurtsPlayers"] = false); + all["t_badfaction"] = P("TBadFaction", p => p["faction"] = "scientists"); + all["t_badturrets"] = P("TBadTurrets", p => p["turrets"] = "sometimes"); + } + + private ScientistNPC SpawnScientist(Vector3 at) + { + ScientistNPC s = GameManager.server.CreateEntity(ScientistPrefab, OnMesh(at)) as ScientistNPC; + if (s == null) return null; + s.enableSaving = false; + s.Spawn(); + _built.Add(s); + return s; + } + + private static ulong Id(BaseEntity e) => e != null && e.net != null ? e.net.ID.Value : 0UL; + + private static bool Gone(BaseCombatEntity e) => e == null || e.IsDestroyed || e.IsDead(); + + private static bool Knows(BasePlayer npc, BaseEntity who) + { + ScientistNPC s = npc as ScientistNPC; + return s != null && s.Brain != null && s.Brain.Senses != null && s.Brain.Senses.Memory.Targets.Contains(who); + } + + /// + /// Hands a player to an NPC's own memory, as Rust's senses do when they notice one. Rust's sensing + /// of a stand-in is unreliable (it can miss one at 2 m), so the tests that are about what happens + /// next start from here: SetKnown asks the NPC's IsTarget, which is what they test. + /// + private static void Sense(BasePlayer npc, BasePlayer who) + { + ScientistNPC s = npc as ScientistNPC; + if (s?.Brain?.Senses?.Memory != null && who != null) + s.Brain.Senses.Memory.SetKnown(who, s, s.Brain.Senses); + } + + private static string TargetOf(Dictionary d) => d == null ? "?" : d["target"] as string ?? "none"; + + private static void Give(BasePlayer npc, string item, int amount) + { + Item made = ItemManager.CreateByName(item, amount); + if (made != null && !made.MoveToContainer(npc.inventory.containerMain)) + made.Remove(); + } + + private static int Count(BasePlayer npc, string item) => + Items(npc).Where(i => i.info.shortname == item).Sum(i => i.amount); + + private static List Items(BasePlayer npc) + { + var all = new List(); + npc.inventory.GetAllItems(all); + return all; + } + + private static void Teleport(BasePlayer npc, Vector3 to) + { + ScientistNPC s = npc as ScientistNPC; + NavMeshHit hit; + if (s?.Brain?.Navigator?.Agent != null && SampleNavmesh(to, 3f, out hit)) + { + s.Brain.Navigator.Stop(); + s.Brain.Navigator.Agent.Warp(hit.position); + s.transform.position = hit.position; + } + } + + private static void MoveStandIn(BasePlayer p, Vector3 to) + { + p.transform.position = OnMesh(to); + p.SendNetworkUpdateImmediate(); + } + + /// A spot near with a clear line to a second spot m east: no rock between. + private static bool ClearLine(Vector3 near, float range, out Vector3 a, out Vector3 b) + { + for (int i = 0; i < 40; i++) + { + a = OnMesh(near + new Vector3((i % 8) * 6f - 21f, 0, (i / 8) * 8f - 16f)); + b = OnMesh(a + new Vector3(range, 0, 0)); + NavMeshHit h1, h2; + if (!SampleNavmesh(a, 1f, out h1) || !SampleNavmesh(b, 1f, out h2)) continue; + if (!Physics.Linecast(a + Vector3.up * 1.6f, b + Vector3.up * 1f, 1218519041, QueryTriggerInteraction.Ignore)) + return true; + } + + a = b = Vector3.zero; + return false; + } + + private int HorrorLeft() => BaseNetworkable.serverEntities.OfType().Count(c => !c.IsDestroyed && c.faction == BaseCombatEntity.Faction.Horror); + + private IEnumerator Stage5() + { + Vector3 f; + if (!FindField(300f, 2025, out f)) + { + Check("s5.setup.field", false, "no second open field on the navmesh"); + yield break; + } + + int horrorBefore = HorrorLeft(); + Note($"s5 field {f}, Horror entities before: {horrorBefore}"); + + // ---- the faction table (D254, D268) and the profile refusals ---- + string said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "red", relation = "hostile" } })); + Check("s5.factions.refuseSelf", said != null && said.Contains("allied to itself"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "scientists", b = "animals", relation = "hostile" } })); + Check("s5.factions.refuseRust", said != null && said.Contains("Rust's own"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "angry" } })); + Check("s5.factions.refuseRelation", said != null && said.Contains("not hostile, neutral or allied"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" }, new { a = "blue", b = "red", relation = "allied" } })); + Check("s5.factions.refuseTwice", said != null && said.Contains("both ways"), said ?? "null"); + said = Api("RunicNpc_SetFactions", JArray.FromObject(new[] { new { a = "red", b = "blue", relation = "hostile" } })); + JArray table = Api("RunicNpc_Factions"); + Check("s5.factions.set", said == null && table != null && table.Count == 1, (said ?? "set") + " " + table?.ToString(Formatting.None)); + JObject profiles = Api("RunicNpc_Profiles"); + Check("s5.profile.refuseFaction", ("" + profiles?["refused"]?["t_badfaction"]).Contains("Rust's own"), "" + profiles?["refused"]?["t_badfaction"]); + Check("s5.profile.refuseTurrets", ("" + profiles?["refused"]?["t_badturrets"]).Contains("not default, ignore or always"), "" + profiles?["refused"]?["t_badturrets"]); + + // A stand-in 60 m off keeps every NPC of the field (x ±140) within 160 m and awake (D235), unsensed. + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + + // ---- five fights at once, 70 m apart (D231, D255, D263, D268) ---- + Vector3 a = OnMesh(f + new Vector3(-140, 0, 0)); + Vector3 b = OnMesh(f + new Vector3(-70, 0, 0)); + Vector3 c = OnMesh(f + new Vector3(0, 0, 0)); + Vector3 d = OnMesh(f + new Vector3(70, 0, 0)); + Vector3 e = OnMesh(f + new Vector3(140, 0, 0)); + + BasePlayer red = Spawn(a, "t_red"); + BasePlayer blue = Spawn(OnMesh(a + new Vector3(0, 0, 15)), "t_blue"); + BasePlayer hunter = Spawn(b, "t_hunter"); + ScientistNPC prey = SpawnScientist(b + new Vector3(0, 0, 15)); + BasePlayer neutral = Spawn(c, "t_roam"); + ScientistNPC bystander = SpawnScientist(c + new Vector3(0, 0, 15)); + BasePlayer leashed = Spawn(d, "t_leashed"); + ScientistNPC far = SpawnScientist(d + new Vector3(0, 0, 25)); + BasePlayer victim = Spawn(e, "t_roam"); + BasePlayer bully = Spawn(OnMesh(e + new Vector3(0, 0, 15)), "t_bully"); + Check("s5.fight.spawned", red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully, ""); + if (!(red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully)) yield break; + + float farHealth = far.health, bystanderHealth = bystander.health, preyHealth = prey.health; + float redHp = red.health, blueHp = blue.health; + float factionsAt = -1f, scientistAt = -1f; + bool neutralFought = false, leashedFought = false, victimAnswered = false; + int marked = 0; + for (int t = 1; t <= 70; t++) + { + yield return new WaitForSeconds(1f); + if (factionsAt < 0f && (Gone(red) || Gone(blue))) factionsAt = t; + if (scientistAt < 0f && Gone(prey)) scientistAt = t; + if (!Gone(neutral) && TargetOf(Describe(neutral)) != "none") neutralFought = true; + if (!Gone(leashed) && TargetOf(Describe(leashed)) != "none") leashedFought = true; + if (!Gone(victim) && TargetOf(Describe(victim)) != "none") victimAnswered = true; + foreach (BasePlayer x in new[] { red, blue, hunter }) + { + Dictionary dx = Gone(x) ? null : Describe(x); + if (dx != null) marked = Math.Max(marked, Convert.ToInt32(dx["shotsMarked"])); + } + + if (factionsAt > 0f && scientistAt > 0f && victimAnswered && t >= 25) break; + } + + float redNow = Gone(red) ? 0f : red.health, blueNow = Gone(blue) ? 0f : blue.health; + Check("s5.fight.factions", redNow < redHp && blueNow < blueHp, + $"red and blue (hostile in the table) both hit: red {redHp:0}→{redNow:0}, blue {blueHp:0}→{blueNow:0}{(factionsAt > 0f ? $", one died at {factionsAt:0} s" : "")}"); + Check("s5.fight.marked", marked > 0, $"{marked} shot(s) marked Horror for their length"); + Check("s5.fight.scientists", scientistAt > 0f || (!Gone(prey) && prey.health < preyHealth * 0.5f), + scientistAt > 0f ? $"the hunter killed a stock scientist at {scientistAt:0} s" : $"scientist {preyHealth:0}→{prey?.health:0} hp in 70 s"); + Check("s5.fight.noFactionNoFight", !neutralFought && !Gone(bystander) && bystander.health >= bystanderHealth, $"t_roam beside a scientist: fought={neutralFought}, scientist {bystander?.health:0}/{bystanderHealth:0} (D255)"); + Check("s5.fight.leash", !leashedFought && !Gone(far) && far.health >= farHealth, $"chase 8, scientist 25 m off: fought={leashedFought}, scientist {far?.health:0}/{farHealth:0} (D263)"); + Check("s5.fight.shootBack", victimAnswered, $"t_roam (neutral) shot by a profile hostile to it one-sidedly: answered={victimAnswered} (D268)"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the alarm (D256): allies within 40 m learn the attacker, seen or not ---- + BasePlayer shooter = StandIn(f + new Vector3(0, 0, -120), 51); + BasePlayer b0 = Spawn(a, "t_blue"); + BasePlayer b1 = Spawn(OnMesh(a + new Vector3(6, 0, 0)), "t_blue"); + BasePlayer b2 = Spawn(OnMesh(a + new Vector3(-6, 0, 0)), "t_blue"); + BasePlayer b3 = Spawn(OnMesh(a + new Vector3(0, 0, 60)), "t_blue"); + BasePlayer r0 = Spawn(OnMesh(a + new Vector3(0, 0, -6)), "t_roam"); + yield return new WaitForSeconds(2f); + b0.Hurt(new HitInfo(shooter, b0, Rust.DamageType.Bullet, 5f)); + yield return new WaitForSeconds(1f); + Check("s5.alarm.near", Knows(b1, shooter) && Knows(b2, shooter), $"b1={Knows(b1, shooter)} b2={Knows(b2, shooter)}"); + Check("s5.alarm.far", !Knows(b3, shooter), $"60 m off: knows={Knows(b3, shooter)}"); + Check("s5.alarm.notEnemy", !Knows(r0, shooter), $"a t_roam beside them: knows={Knows(r0, shooter)} (not their ally)"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- allies (D257, D270): never targeted, and defended with what they own ---- + BasePlayer friend = StandIn(OnMesh(b + new Vector3(0, 0, 12)), 52); + // The control: the same profile beside it, allied to nobody, sees the same player. (Rust senses + // only one of two stand-ins this close, so a second stand-in cannot be the control.) + BasePlayer guardian = Spawn(b, "t_roam"); + BasePlayer control = Spawn(OnMesh(b + new Vector3(2, 0, 0)), "t_roam"); + said = Api("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString); + Check("s5.ally.set", said == null, said ?? "allied to a player"); + said = Api("RunicNpc_Ally", Id(guardian), "clan", "424242"); + Check("s5.ally.refuseClan", said != null && said.Contains("no clan"), said ?? "null"); + Api("RunicNpc_Ally", Id(guardian), "player", friend.UserIDString); + yield return new WaitForSeconds(2f); + Sense(guardian, friend); + Sense(control, friend); + yield return new WaitForSeconds(1f); + Check("s5.ally.spared", !Knows(guardian, friend), $"the ally 12 m off in sight: target={Knows(guardian, friend)}"); + Check("s5.ally.controlFights", Knows(control, friend), $"the same profile, allied to nobody, 2 m away: target={Knows(control, friend)}"); + Api("RunicNpc_Despawn", Id(control)); + BasePlayer raider = StandIn(OnMesh(b + new Vector3(0, 0, -90)), 54); + friend.Hurt(new HitInfo(raider, friend, Rust.DamageType.Bullet, 5f)); + yield return new WaitForSeconds(1f); + Check("s5.ally.defendsPlayer", Knows(guardian, raider), $"a raider 90 m off shot the ally: guardian knows={Knows(guardian, raider)}"); + + BaseEntity foundation = GameManager.server.CreateEntity("assets/prefabs/building core/foundation/foundation.prefab", OnMesh(b + new Vector3(8, 0, -8))); + if (foundation != null) + { + foundation.enableSaving = false; + foundation.OwnerID = (ulong)friend.userID; + if (foundation is StabilityEntity) ((StabilityEntity)foundation).grounded = true; + foundation.Spawn(); + _built.Add(foundation); + BasePlayer wrecker = StandIn(OnMesh(b + new Vector3(-90, 0, 0)), 55); + ((BaseCombatEntity)foundation).Hurt(new HitInfo(wrecker, foundation, Rust.DamageType.Explosion, 1f)); + yield return new WaitForSeconds(1f); + Check("s5.ally.defendsBuilding", Knows(guardian, wrecker), $"a wrecker 90 m off hit the ally's foundation: guardian knows={Knows(guardian, wrecker)}"); + } + else + Check("s5.ally.defendsBuilding", false, "no foundation could be made"); + + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- escort (D258, D269) ---- + _escortEnded.Clear(); + // A stock scientist as the charge: a stand-in has no connection, so it is never "on the server". + ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0)); + BasePlayer escort = Spawn(c, "t_roam"); + BasePlayer sentry = Spawn(OnMesh(c + new Vector3(-10, 0, 0)), "t_sentry"); + said = Api("RunicNpc_Escort", Id(sentry), charge); + Check("s5.escort.refuseSentry", said != null && said.Contains("sentry"), said ?? "null"); + said = Api("RunicNpc_Escort", Id(escort), charge); + Check("s5.escort.set", said == null, said ?? "escorting"); + yield return new WaitForSeconds(2f); + Vector3 home = escort.transform.position; + Teleport(charge, c + new Vector3(0, 0, 35)); + float closest = float.MaxValue; + for (int t = 0; t < 20; t++) + { + yield return new WaitForSeconds(1f); + closest = Mathf.Min(closest, Flat(escort.transform.position, charge.transform.position)); + } + + Check("s5.escort.follows", closest < 8f, $"its charge moved 35 m; closest {closest:0.0} m in 20 s"); + Check("s5.escort.spared", !Knows(escort, charge), $"target={Knows(escort, charge)}"); + charge.Kill(); + for (int t = 0; t < 20 && _escortEnded.Count == 0; t++) + yield return new WaitForSeconds(1f); + Dictionary de = Describe(escort); + Check("s5.escort.ends", _escortEnded.Count == 1 && de != null && de["escort"] == null, $"hook {_escortEnded.Count}, escort={de?["escort"] ?? "null"}"); + float back = float.MaxValue; + for (int t = 0; t < 45 && back > 4f; t++) + { + yield return new WaitForSeconds(1f); + back = Flat(escort.transform.position, V(Describe(escort)["home"])); + } + + Check("s5.escort.home", back <= 4f, $"{back:0.0} m from its spot after its charge was gone (D269)"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the guard role ---- + BasePlayer guard = Spawn(d, "t_guard"); + yield return new WaitForSeconds(2f); + Vector3 post = V(Describe(guard)["home"]); + Teleport(guard, post + new Vector3(10, 0, 0)); + float off = float.MaxValue; + for (int t = 0; t < 25 && off > 2.5f; t++) + { + yield return new WaitForSeconds(1f); + off = Flat(guard.transform.position, post); + } + + Check("s5.guard.returns", off <= 2.5f, $"put 10 m off its post: {off:0.0} m from it"); + // It stops within 2 m of its post; let it arrive before measuring how still it holds. + yield return new WaitForSeconds(4f); + Vector3 held = guard.transform.position; + float drift = 0f; + for (int t = 0; t < 12; t++) + { + yield return new WaitForSeconds(1f); + drift = Mathf.Max(drift, Flat(guard.transform.position, held)); + } + + Check("s5.guard.holds", drift < 1.5f, $"moved {drift:0.0} m in 12 s at its post (a roamer would wander)"); + Api("RunicNpc_DespawnOwner", Owner); + yield return new WaitForSeconds(1f); + + // ---- the tether (D272) ---- + Plugin zm = plugins.Find("ZoneManager"); + if (zm == null) + Note("s5.tether skipped: ZoneManager is not loaded"); + else + { + // Within 160 m of the keeper, or the NPC sleeps (D235) and the test reads 0 m. + Vector3 centre = OnMesh(c); + zm.Call("CreateOrUpdateZone", Zone, new[] { "radius", "12", "name", "rnt tether" }, centre); + BasePlayer tethered = Spawn(centre, "t_roam"); + said = Api("RunicNpc_Tether", Id(tethered), "rnt_nozone"); + Check("s5.tether.refuseMissing", said != null && said.Contains("no zone"), said ?? "null"); + said = Api("RunicNpc_Tether", Id(tethered), Zone); + Check("s5.tether.set", said == null, said ?? "held"); + float widest = 0f; + for (int t = 0; t < 30; t++) + { + yield return new WaitForSeconds(1f); + widest = Mathf.Max(widest, Flat(tethered.transform.position, centre)); + } + + Check("s5.tether.holds", widest <= 13f, $"wander radius 15 in a 12 m zone: widest {widest:0.0} m from its centre in 30 s"); + Teleport(tethered, centre + new Vector3(25, 0, 0)); + float inside = float.MaxValue; + for (int t = 0; t < 25 && inside > 12f; t++) + { + yield return new WaitForSeconds(1f); + inside = Flat(tethered.transform.position, centre); + } + + Check("s5.tether.returns", inside <= 12f, $"put 25 m out: {inside:0.0} m from the centre"); + string placed = Api("RunicNpc_SetPlacement", "t_tether", JObject.FromObject(new { profile = "t_roam", position = new { centre.x, centre.y, centre.z }, tether = "rnt_nozone" })); + Check("s5.tether.placementRefused", placed != null && placed.Contains("no zone"), placed ?? "null"); + Api("RunicNpc_RemovePlacement", "t_tether"); + zm.Call("EraseZone", Zone); + Api("RunicNpc_DespawnOwner", Owner); + } + + // ---- PVE (D261, D271) ---- + BasePlayer player = StandIn(OnMesh(a + new Vector3(0, 0, 40)), 57); + BasePlayer unkill = Spawn(a, "t_unkill"); + BasePlayer harmless = Spawn(OnMesh(a + new Vector3(4, 0, 0)), "t_harmless"); + BasePlayer plain = Spawn(OnMesh(a + new Vector3(-4, 0, 0)), "t_roam"); + object o1 = Interface.CallHook("CanEntityTakeDamage", unkill, new HitInfo(player, unkill, Rust.DamageType.Bullet, 10f)); + object o2 = Interface.CallHook("CanEntityTakeDamage", plain, new HitInfo(player, plain, Rust.DamageType.Bullet, 10f)); + object o3 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f)); + object o4 = Interface.CallHook("CanEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f)); + Check("s5.pve.answers", Equals(o1, false) && Equals(o2, true) && Equals(o3, false) && Equals(o4, true), $"unkillable {o1 ?? "null"}, plain {o2 ?? "null"}, harmless→player {o3 ?? "null"}, plain→player {o4 ?? "null"}"); + float hp = unkill.health; + unkill.Hurt(new HitInfo(player, unkill, Rust.DamageType.Bullet, 30f)); + float plainHp = plain.health; + plain.Hurt(new HitInfo(player, plain, Rust.DamageType.Bullet, 30f)); + Check("s5.pve.unkillable", Mathf.Approximately(unkill.health, hp) && plain.health < plainHp, $"unkillable {hp:0}→{unkill.health:0}, plain {plainHp:0}→{plain.health:0}"); + object h1 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(harmless, player, Rust.DamageType.Bullet, 10f)); + object h2 = Interface.CallHook("OnEntityTakeDamage", player, new HitInfo(plain, player, Rust.DamageType.Bullet, 10f)); + Check("s5.pve.harmless", Equals(h1, true) && h2 == null, $"harmless→player {h1 ?? "null"}, plain→player {h2 ?? "null"}"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- turrets (D259, D264, D267): the hook, then Outpost's own sentries ---- + BasePlayer ignoring = Spawn(b, "t_ignore"); + BasePlayer defaulting = Spawn(OnMesh(b + new Vector3(4, 0, 0)), "t_roam"); + object i1 = Interface.CallHook("CanBeTargeted", ignoring, (MonoBehaviour)null); + object i2 = Interface.CallHook("CanBeTargeted", defaulting, (MonoBehaviour)null); + Check("s5.turrets.ignore", Equals(i1, false) && i2 == null, $"ignore {i1 ?? "null"}, default {i2 ?? "null"}"); + Api("RunicNpc_DespawnOwner", Owner); + IEnumerator sentries = Sentries(); + while (sentries.MoveNext()) yield return sentries.Current; + ClearWorld(); + keeper = StandIn(f + new Vector3(0, 0, 60), 50); + yield return new WaitForSeconds(1f); + + // ---- the kit's extras (D260, D265), each NPC 60 m from the next ---- + IEnumerator kit = KitExtras(f); + while (kit.MoveNext()) yield return kit.Current; + + int horrorAfter = HorrorLeft(); + Check("s5.noHorrorLeft", horrorAfter <= horrorBefore, $"Horror entities: {horrorBefore} before, {horrorAfter} after"); + Api("RunicNpc_SetFactions", new JArray()); + } + + /// + /// D264 and D267 on Outpost's (or Bandit Camp's) own sentries, and the org lead's condition: they + /// must never target Rust's own scientists or bandit guards. Ours are put in front of a sentry: + /// one always, one default, and a stock scientist beside them. + /// + private IEnumerator Sentries() + { + List turrets = BaseNetworkable.serverEntities.OfType().Where(t => t != null && !t.IsDestroyed).ToList(); + if (turrets.Count == 0) + { + Note("s5.sentry skipped: this map has no Outpost or Bandit Camp sentries"); + yield break; + } + + NPCAutoTurret turret = null; + Vector3 spot = Vector3.zero; + foreach (NPCAutoTurret t in turrets) + { + Vector3 ahead = t.transform.position + t.transform.forward * 12f; + NavMeshHit hit, start; + // The approach must be walkable too: ours start 45 m out and are walked in (a sentry by the + // water left the Carbon run's NPC unspawned). + if (SampleNavmesh(ahead, 4f, out hit) && Mathf.Abs(hit.position.y - t.transform.position.y) < 6f && + SampleNavmesh(t.transform.position + t.transform.forward * 45f, 3f, out start)) + { + turret = t; + spot = hit.position; + break; + } + } + + if (turret == null) + { + Check("s5.sentry.spot", false, $"no navmesh 12 m in front of any of {turrets.Count} sentries"); + yield break; + } + + Note($"s5.sentry: {turret.ShortPrefabName} at {turret.transform.position}, {turrets.Count} on the map"); + BasePlayer near = StandIn(spot + new Vector3(0, 0, 80), 58); + Vector3 side = Vector3.Cross(Vector3.up, turret.transform.forward).normalized; + // Spawned 45 m out and walked in: a turret notices only what ENTERS its trigger (stage 5 spike). + Vector3 outside = turret.transform.position + turret.transform.forward * 45f; + BasePlayer always = Spawn(OnMesh(outside), "t_always"); + BasePlayer plain = Spawn(OnMesh(outside + side * 4f), "t_roam"); + ScientistNPC stock = SpawnScientist(outside - side * 4f); + yield return new WaitForSeconds(1f); + Teleport(always, spot); + Teleport(plain, spot + side * 4f); + Teleport(stock, spot - side * 4f); + float alwaysHp = always != null ? always.health : 0f; + var targeted = new HashSet(); + bool alwaysTargeted = false; + int stockTargets = 0; + for (int t = 0; t < 120; t++) + { + yield return new WaitForSeconds(0.25f); + foreach (NPCAutoTurret any in turrets) + { + BaseCombatEntity target = any != null && !any.IsDestroyed ? any.target : null; + if (target == null) continue; + targeted.Add(target.GetType().Name); + if (target == always) alwaysTargeted = true; + if (target is BanditGuard || (target is ScientistNPC && target.GetType().Name != "RunicNpcPlayer")) stockTargets++; + } + } + + if (ReferenceEquals(always, null) || ReferenceEquals(plain, null) || ReferenceEquals(stock, null)) Note($"s5.sentry: spawned always={always != null} default={plain != null} stock={stock != null}"); + Check("s5.sentry.always", alwaysTargeted && (Gone(always) || always.health < alwaysHp), $"targeted={alwaysTargeted}, health {alwaysHp:0}→{(Gone(always) ? 0f : always.health):0}"); + Check("s5.sentry.default", Gone(plain) || plain.health >= plain.MaxHealth() - 0.5f, $"a default NPC beside it: {(Gone(plain) ? "dead" : plain.health.ToString("0"))} hp"); + Check("s5.sentry.noStockTargets", stockTargets == 0 && !Gone(stock), $"samples on a stock scientist or bandit guard: {stockTargets}; targets seen: {string.Join(", ", targeted.ToArray())} (the org lead's condition, D267)"); + Api("RunicNpc_DespawnOwner", Owner); + } + + private IEnumerator KitExtras(Vector3 f) + { + Vector3[] at = new Vector3[6]; + for (int i = 0; i < 6; i++) + at[i] = OnMesh(f + new Vector3(-150 + i * 60, 0, -60)); + + // Heal in a lull, one syringe used up; and a profile that did not opt in never heals. + BasePlayer healer = Spawn(at[0], "t_kit"); + BasePlayer plain = Spawn(OnMesh(at[0] + new Vector3(8, 0, 0)), "t_roam"); + Give(healer, "syringe.medical", 2); + Give(plain, "syringe.medical", 2); + yield return new WaitForSeconds(1f); + healer.health = healer.MaxHealth() * 0.3f; + plain.health = plain.MaxHealth() * 0.3f; + float plainHp = plain.health; + + // A grenadier, a rocketeer, a brawler and a flamer, each with a stand-in at its range. + BasePlayer grenadier = Spawn(at[1], "t_kit"); + Give(grenadier, "grenade.f1", 2); + BasePlayer gTarget = StandIn(OnMesh(at[1] + new Vector3(12, 0, 0)), 60); + Vector3 rAt, rTo; + if (!ClearLine(at[2], 25f, out rAt, out rTo)) { rAt = at[2]; rTo = at[2] + new Vector3(25, 0, 0); Note("s5.kit.rocket: no clear 25 m line found near its spot"); } + BasePlayer rocketeer = Spawn(rAt, "t_kit"); + Give(rocketeer, "rocket.launcher", 1); + Give(rocketeer, "ammo.rocket.basic", 2); + ScientistNPC rTarget = SpawnScientist(rTo); + BasePlayer brawler = Spawn(at[3], "t_kit"); + Give(brawler, "machete", 1); + BasePlayer mTarget = StandIn(OnMesh(at[3] + new Vector3(1.5f, 0, 0)), 62); + BasePlayer flamer = Spawn(at[4], "t_kit"); + Give(flamer, "flamethrower", 1); + Give(flamer, "lowgradefuel", 50); + // An empty tank, so the extra must burn the kit's fuel (D265: only extras are used up). + foreach (Item it in Items(flamer)) + if (it.GetHeldEntity() is FlameThrower) ((FlameThrower)it.GetHeldEntity()).ammo = 0; + BasePlayer near = StandIn(OnMesh(at[4] + new Vector3(4, 0, 0)), 63); + Sense(flamer, near); + + bool flameHeld = false; + for (int t = 0; t < 45; t++) + { + yield return new WaitForSeconds(1f); + if (t % 5 == 0) + { + if (!Gone(flamer)) Sense(flamer, near); + if (!Gone(grenadier)) Sense(grenadier, gTarget); + if (!Gone(brawler)) Sense(brawler, mTarget); + } + Item held = Gone(flamer) ? null : flamer.GetActiveItem(); + if (held == null || held.info.shortname != "flamethrower") continue; + // Its tank emptied while it is held, then one shot: the extra must burn the kit's fuel (D265). + FlameThrower tank = held.GetHeldEntity() as FlameThrower; + if (!flameHeld && tank != null) + { + tank.ammo = 0; + ((NPCPlayer)flamer).ShotTest(4f); + } + + flameHeld = true; + } + + Dictionary Used(BasePlayer x) => Gone(x) ? null : Describe(x)?["kitUsed"] as Dictionary; + int UsedN(BasePlayer x, string k) => Used(x) != null ? Convert.ToInt32(Used(x)[k]) : -1; + + Check("s5.kit.heal", UsedN(healer, "heals") > 0 && Count(healer, "syringe.medical") < 2, $"heals {UsedN(healer, "heals")}, syringes left {(Gone(healer) ? -1 : Count(healer, "syringe.medical"))}, health {(Gone(healer) ? 0 : healer.health):0}"); + Check("s5.kit.offByDefault", !Gone(plain) && plain.health <= plainHp + 0.5f && Count(plain, "syringe.medical") == 2, $"t_roam: health {plainHp:0}→{(Gone(plain) ? 0 : plain.health):0}, syringes {(Gone(plain) ? -1 : Count(plain, "syringe.medical"))}"); + Check("s5.kit.grenade", UsedN(grenadier, "throws") > 0 && Count(grenadier, "grenade.f1") < 2, $"throws {UsedN(grenadier, "throws")}, grenades left {(Gone(grenadier) ? -1 : Count(grenadier, "grenade.f1"))}"); + Check("s5.kit.rocket", (UsedN(rocketeer, "rockets") > 0 || Gone(rTarget)) && Count(rocketeer, "ammo.rocket.basic") < 2, $"rockets {UsedN(rocketeer, "rockets")}, left {(Gone(rocketeer) ? -1 : Count(rocketeer, "ammo.rocket.basic"))}"); + Check("s5.kit.melee", UsedN(brawler, "meleeHits") > 0, $"melee swings {UsedN(brawler, "meleeHits")}"); + Check("s5.kit.flame", flameHeld && !Gone(flamer) && Count(flamer, "lowgradefuel") < 50, $"held={flameHeld}, fuel left {(Gone(flamer) ? -1 : Count(flamer, "lowgradefuel"))}"); + Api("RunicNpc_DespawnOwner", Owner); + } + /// A route, as RunicNpc_Routes returns it. private sealed class Route { -- 2.49.1 From 900ac4e992c103d80743390e1c96f0613dae46b8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 1 Oct 2026 09:16:50 -0500 Subject: [PATCH 09/24] test(harness): the escort test walks its charge away from the keeper; rnt.escortprobe (stage 5) Run alone on each rig, s5 is 52/52 on Oxide and on Carbon, and rnt.run all was otherwise green on both (stages 2-4 unchanged). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- tools/RunicNpcTest.cs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index c5175ce..d4dd848 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -422,6 +422,31 @@ namespace Oxide.Plugins arg.ReplyWith("rnt: rocket probe started"); } + /// A probe: an escort follows a scientist 35 m, the scientist is killed, then 30 s of what the escort does. + [ConsoleCommand("rnt.escortprobe")] + private void CmdEscortProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + BasePlayer keeper = StandIn(f + new Vector3(0, 0, 60), 50); + Vector3 c = OnMesh(f); + ScientistNPC charge = SpawnScientist(c + new Vector3(5, 0, 0)); + BasePlayer escort = Spawn(c, "t_roam"); + Api("RunicNpc_Escort", Id(escort), charge); + int n = 0; + timer.Repeat(2f, 30, () => + { + n++; + if (n == 3) Teleport(charge, c + new Vector3(0, 0, 35)); + if (n == 12 && !Gone(charge)) charge.Kill(); + Dictionary d = Gone(escort) ? null : Describe(escort); + Puts($"[esc {n}] state={d?["state"]} rest={d?["rest"]} escort={d?["escort"] ?? "null"} target={TargetOf(d)} engaged={d?["engaged"]} home={(d == null ? -1f : Flat(escort.transform.position, V(d["home"]))):0.0} ended={_escortEnded.Count}"); + if (n == 30) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: escort probe started"); + } + [ConsoleCommand("rnt.clear")] private void CmdClear(ConsoleSystem.Arg arg) { @@ -1582,7 +1607,9 @@ namespace Oxide.Plugins Check("s5.escort.set", said == null, said ?? "escorting"); yield return new WaitForSeconds(2f); Vector3 home = escort.transform.position; - Teleport(charge, c + new Vector3(0, 0, 35)); + // East, not north: north brings the escort within sense range of the keeper stand-in, and Rust's + // design then fights it instead of walking home. + Teleport(charge, c + new Vector3(35, 0, 0)); float closest = float.MaxValue; for (int t = 0; t < 20; t++) { -- 2.49.1 From c8db94ece4a52599b7e80363f0bb92b4f5202992 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 06:23:34 -0500 Subject: [PATCH 10/24] test(harness): stage 6 spike, rnt.spike6 (D282) RunicNpcTest 0.6.0 gets stage 6's spike, run on both rigs against RunicNPC API 4: press E (a use ray from a player's eyes to our NPC, and what OnPlayerInput's not-pressed test costs), the health bar (CuiElement.Update, the bar's JSON and what finding its viewers costs), Kits' data file (a kit written to it, two reloads, a claim held only in memory, real players' usage), a kit swap mid-fight (strip, GiveKit, EquipWeapon, timed; and without EquipWeapon), and summoned adds (4 and 10 on a ring around a fighting NPC). The Kits reload notes whether RunicNPC was reloaded with it and what became of its NPCs: its "// Requires: Kits" takes it down on both frameworks. Results: 26 of 26 on each rig (docs/runicnpc/PLAN.md stage 6). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- tools/RunicNpcTest.cs | 504 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 502 insertions(+), 2 deletions(-) diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index d4dd848..753f264 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -7,6 +7,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core; using Oxide.Core.Plugins; +using Oxide.Game.Rust.Cui; using UnityEngine; using UnityEngine.AI; @@ -44,11 +45,12 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [Info("RunicNpcTest", "Runic Gateway", "0.5.0")] - [Description("RunicNPC stage 2, 3, 4 and 5 tests. A developer tool: never ship it.")] + [Info("RunicNpcTest", "Runic Gateway", "0.6.0")] + [Description("RunicNPC stage 2, 3, 4, 5 tests and the stage 6 spike. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { [PluginReference] private Plugin RunicNPC; + [PluginReference] private Plugin Kits; private const string Kit = "rnhrevolver"; private const string Owner = "plugin:RunicNpcTest"; @@ -238,6 +240,17 @@ namespace Oxide.Plugins private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info) { + // Stage 6's spike: the scientist a boss fights takes no damage, and every hit is tallied by weapon. + if (_spikeTarget != null && victim == _spikeTarget && info != null) + { + string w = info.Weapon?.GetItem()?.info.shortname ?? info.WeaponPrefab?.ShortPrefabName ?? "?"; + int n; + _spikeHits.TryGetValue(w, out n); + _spikeHits[w] = n + 1; + info.damageTypes.ScaleAll(0f); + return; + } + BasePlayer p = victim as BasePlayer; if (p == null || !_standIns.Contains(p) || info == null) return; if (info.Initiator is BasePlayer && ((BasePlayer)info.Initiator).IsNpc) _hitsOnStandIns++; @@ -1893,6 +1906,493 @@ namespace Oxide.Plugins Api("RunicNpc_DespawnOwner", Owner); } + // ---- stage 6's spike (D282): press E, the health bar, Kits' data file, a kit swap mid-fight, adds ---- + + private const string SwapKit = "rnt6smg"; + private const string CooldownKit = "rnt6cd"; + private BaseCombatEntity _spikeTarget; + private readonly Dictionary _spikeHits = new Dictionary(); + + private static bool IsCarbon() => AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")); + + private Plugin K => plugins.Find("Kits"); + + private int SpikeHits(string weapon) + { + int n; + return _spikeHits.TryGetValue(weapon, out n) ? n : 0; + } + + private static string Held(BasePlayer npc) => Gone(npc) ? "-" : npc.GetActiveItem()?.info.shortname ?? "none"; + + /// + /// rnt.spike6 [part,...]: stage 6's measurements, one part or all in order: + /// use, bar, kits, swap, adds, unkits. swap needs the + /// kit kits writes; unkits takes both spike kits out of Kits' file again. + /// + [ConsoleCommand("rnt.spike6")] + private void CmdSpike6(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + string part = arg.GetString(0, "all"); + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + _running = ServerMgr.Instance.StartCoroutine(Spike6(part)); + arg.ReplyWith($"rnt: spike6 {part}"); + } + + private IEnumerator Spike6(string part) + { + _pass = _fail = 0; + Note($"spike6 {part} on {(IsCarbon() ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}, Kits {K?.Version}"); + SetTestProfiles(); + Vector3 f; + if (!FindField(300f, 2025, out f)) + { + Check("s6.setup.field", false, "no open field on the navmesh"); + yield break; + } + + string[] parts = part == "all" ? new[] { "use", "bar", "kits", "swap", "adds", "unkits" } : part.Split(','); + foreach (string p in parts) + { + StandIn(f + new Vector3(0, 0, 60), 50); + IEnumerator inner = p == "use" ? UseSpike(f) + : p == "bar" ? BarSpike(f) + : p == "kits" ? KitsSpike(f) + : p == "swap" ? SwapSpike(f) + : p == "adds" ? AddsSpike(f) + : p == "unkits" ? UnkitsSpike() + : null; + if (inner == null) + { + Check("s6.setup.part", false, "no part " + p); + continue; + } + + while (inner.MoveNext()) yield return inner.Current; + _spikeTarget = null; + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + yield return new WaitForSeconds(1f); + } + + Note($"done spike6 {part}: {_pass} pass, {_fail} fail"); + _running = null; + } + + // ---- press E (D278) ---- + + private IEnumerator UseSpike(Vector3 f) + { + Vector3 at = OnMesh(f + new Vector3(30, 0, -60)); + BasePlayer npc = Spawn(at, "t_sentry"); + yield return new WaitForSeconds(1.5f); + Check("s6.use.spawned", !Gone(npc), npc != null ? npc.GetType().Name : "null"); + if (Gone(npc)) yield break; + + string cols = string.Join("; ", npc.GetComponentsInChildren(true) + .Select(c => $"{c.GetType().Name} '{c.name}' layer={LayerMask.LayerToName(c.gameObject.layer)} enabled={c.enabled} trigger={c.isTrigger}").ToArray()); + Note($"s6.use npc layer={LayerMask.LayerToName(npc.gameObject.layer)} colliders: {cols}"); + + int playerMask = LayerMask.GetMask("Player (Server)"); + int broadMask = LayerMask.GetMask("Player (Server)", "Construction", "Deployed", "World", "Terrain", "Default", "Tree"); + BasePlayer p = StandIn(at + new Vector3(2f, 0, 0), 61); + int hitsPlayerMask = 0, hitsBroad = 0, rays = 0; + foreach (float dist in new[] { 1.5f, 2.5f, 3.5f }) + { + MoveStandIn(p, at + new Vector3(dist, 0, 0)); + yield return new WaitForSeconds(0.5f); + if (Gone(npc)) break; + foreach (KeyValuePair aim in new Dictionary + { + ["head"] = npc.eyes.position, ["chest"] = npc.CenterPoint(), ["legs"] = npc.transform.position + Vector3.up * 0.4f + }) + { + Vector3 eye = p.eyes.position; + Vector3 dir = (aim.Value - eye).normalized; + RaycastHit h1, h2; + bool a = Physics.Raycast(eye, dir, out h1, 5f, playerMask, QueryTriggerInteraction.Ignore) && h1.GetEntity() == npc; + bool b = Physics.Raycast(eye, dir, out h2, 5f, broadMask, QueryTriggerInteraction.Ignore) && h2.GetEntity() == npc; + rays++; + if (a) hitsPlayerMask++; + if (b) hitsBroad++; + Note($"s6.use ray {dist} m at {aim.Key}: player-mask {(a ? $"HIT '{h1.collider.name}' at {h1.distance:0.00}" : "miss " + (h1.collider != null ? h1.collider.name : "-"))}, broad {(b ? $"HIT at {h2.distance:0.00}" : "miss " + (h2.collider != null ? $"{h2.collider.name}/{h2.GetEntity()?.ShortPrefabName}" : "-"))}"); + } + } + + Check("s6.use.ray", hitsBroad == rays, $"a ray from a player's eyes reaches our NPC: {hitsBroad}/{rays} with the world in the mask, {hitsPlayerMask}/{rays} on Player (Server) alone"); + + // What OnPlayerInput costs per player tick when E is not pressed, and one use ray when it is. + var input = new InputState(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + int pressed = 0; + for (int i = 0; i < 1000000; i++) + if (input.WasJustPressed(BUTTON.USE)) pressed++; + double perTickNs = sw.Elapsed.TotalMilliseconds * 1e6 / 1000000; + sw.Restart(); + int found = 0; + for (int i = 0; i < 10000; i++) + { + RaycastHit h; + if (Physics.Raycast(p.eyes.position, (npc.CenterPoint() - p.eyes.position).normalized, out h, 3f, broadMask, QueryTriggerInteraction.Ignore)) found++; + } + + double perRayUs = sw.Elapsed.TotalMilliseconds * 1000 / 10000; + Check("s6.use.cost", perTickNs < 1000 && perRayUs < 100, $"OnPlayerInput's not-pressed test {perTickNs:0.0} ns a tick; one 3 m use ray {perRayUs:0.00} µs ({found} hit)"); + } + + // ---- the health bar (D274) ---- + + private IEnumerator BarSpike(Vector3 f) + { + Type el = typeof(CuiElement); + MemberInfo upd = (MemberInfo)el.GetProperty("Update") ?? el.GetField("Update"); + Note("s6.bar CuiElement: " + string.Join(", ", el.GetProperties().Select(x => x.Name).Concat(el.GetFields().Select(x => x.Name)).ToArray())); + Check("s6.bar.updateMember", upd != null, upd != null ? $"CuiElement.{upd.Name} ({upd.MemberType})" : "no Update: every redraw is destroy + add"); + + Func> build = (frac, text, fillOnly) => + { + var c = new CuiElementContainer(); + if (!fillOnly) + c.Add(new CuiPanel { Image = { Color = "0 0 0 0.6" }, RectTransform = { AnchorMin = "0.35 0.9", AnchorMax = "0.65 0.93" } }, "Hud", "rnpc.bar"); + c.Add(new CuiPanel { Image = { Color = "0.75 0.1 0.1 0.9" }, RectTransform = { AnchorMin = "0 0", AnchorMax = $"{frac:0.###} 1" } }, "rnpc.bar", "rnpc.bar.fill"); + c.Add(new CuiLabel { Text = { Text = text, FontSize = 12, Align = TextAnchor.MiddleCenter }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, "rnpc.bar", "rnpc.bar.text"); + if (fillOnly && upd != null) + foreach (CuiElement e in c) + { + if (upd is PropertyInfo) ((PropertyInfo)upd).SetValue(e, true, null); + else ((FieldInfo)upd).SetValue(e, true); + } + + return c; + }; + + string full = CuiHelper.ToJson(build(0.73f, "Juggernaut 1825 / 2500", false)); + string fill = CuiHelper.ToJson(build(0.72f, "Juggernaut 1800 / 2500", true)); + Note("s6.bar full json: " + full); + Note("s6.bar update json: " + fill); + var sw = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < 2000; i++) CuiHelper.ToJson(build(i / 2000f, "Juggernaut " + i + " / 2500", false)); + double fullUs = sw.Elapsed.TotalMilliseconds * 1000 / 2000; + sw.Restart(); + for (int i = 0; i < 2000; i++) CuiHelper.ToJson(build(i / 2000f, "Juggernaut " + i + " / 2500", true)); + double fillUs = sw.Elapsed.TotalMilliseconds * 1000 / 2000; + Check("s6.bar.build", fullUs < 500 && fillUs < 500, + $"whole bar {full.Length} B in {fullUs:0.0} µs; update {fill.Length} B in {fillUs:0.0} µs, contains update={fill.Contains("\"update\"")}"); + + // Who sees it: 40 players scattered over 150 m, the ones within 100 m found by one sphere query. + var rng = new System.Random(6); + var crowd = new List(); + for (int i = 0; i < 40; i++) + { + float a = (float)(rng.NextDouble() * Math.PI * 2), r = (float)(rng.NextDouble() * 150); + BasePlayer s = StandIn(OnMesh(f + new Vector3(Mathf.Cos(a) * r, 0, Mathf.Sin(a) * r)), 70 + i); + if (s != null) crowd.Add(s); + } + + yield return new WaitForSeconds(1f); + int within = crowd.Count(s => Vector3.Distance(s.transform.position, f) <= 100f); + var found = new List(); + sw.Restart(); + for (int i = 0; i < 1000; i++) + { + found.Clear(); + Vis.Entities(f, 100f, found, LayerMask.GetMask("Player (Server)"), QueryTriggerInteraction.Ignore); + } + + double visUs = sw.Elapsed.TotalMilliseconds * 1000 / 1000; + int seen = found.Count(s => crowd.Contains(s)); + sw.Restart(); + int byLoop = 0; + for (int i = 0; i < 1000; i++) + { + byLoop = 0; + foreach (BasePlayer s in crowd) + if ((s.transform.position - f).sqrMagnitude <= 100f * 100f) byLoop++; + } + + double loopUs = sw.Elapsed.TotalMilliseconds * 1000 / 1000; + Check("s6.bar.viewers", seen == within && byLoop == within, + $"{within} of 40 within 100 m: sphere query found {seen} in {visUs:0.0} µs, a distance loop over the list {byLoop} in {loopUs:0.0} µs"); + bool sent = CuiHelper.AddUi(crowd[0], full); + Note($"s6.bar AddUi to a player with no connection returns {sent} (sending is not measured here: no client)"); + } + + // ---- Kits' data file (D280) ---- + + private static JObject KitItem(string shortname, int amount, int position, float condition = 0f) => + JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = condition, MaxCondition = condition, Ammo = 0, Position = position, Frequency = -1 }); + + private static JObject SpikeKit(string name, int cooldown, int maxUses, JObject[] main, JObject[] wear, JObject[] belt) => + new JObject + { + ["Name"] = name, ["Description"] = "RunicNPC stage 6 spike (remove after)", ["RequiredPermission"] = "", + ["MaximumUses"] = maxUses, ["RequiredAuth"] = 0, ["Cooldown"] = cooldown, ["Cost"] = 0, ["IsHidden"] = true, + ["CopyPasteFile"] = "", ["KitImage"] = "", + ["MainItems"] = new JArray(main), ["WearItems"] = new JArray(wear), ["BeltItems"] = new JArray(belt) + }; + + private JObject KitsFile() => Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data"); + + private double _reloadSeconds; + private bool _reloaded; + + /// Reloads Kits the way an operator would at the console (the bridge's way on Carbon, W3) and waits for the new instance. + private IEnumerator ReloadKits() + { + Plugin old = K, oldRunic = plugins.Find("RunicNPC"); + int npcsBefore = List(null).Count; + var sw = System.Diagnostics.Stopwatch.StartNew(); + ConsoleSystem.Run(ConsoleSystem.Option.Server, IsCarbon() ? "c.reload" : "oxide.reload", "Kits"); + _reloaded = false; + double kitsAt = -1, runicAt = -1; + while (sw.Elapsed.TotalSeconds < 60) + { + yield return new WaitForSeconds(0.25f); + Plugin now = K, runic = plugins.Find("RunicNPC"); + if (kitsAt < 0 && now != null && now != old && now.IsLoaded) kitsAt = sw.Elapsed.TotalSeconds; + // RunicNPC requires Kits (// Requires: Kits): both frameworks unload it with Kits and load it again. + if (runicAt < 0 && runic != null && runic != oldRunic && runic.IsLoaded) runicAt = sw.Elapsed.TotalSeconds; + // Carbon unloads it only after a fresh compile of RunicNPC; give it 20 s, then call it not reloaded. + if (kitsAt >= 0 && (runicAt >= 0 || sw.Elapsed.TotalSeconds > kitsAt + 20)) break; + } + + _reloaded = kitsAt >= 0; + _reloadSeconds = sw.Elapsed.TotalSeconds; + Note($"s6.kits reload: Kits back at {kitsAt:0.0} s, RunicNPC {(oldRunic == plugins.Find("RunicNPC") ? "NOT reloaded" : $"reloaded with it, back at {runicAt:0.0} s")}; RunicNPC's NPCs {npcsBefore} before, {List(null).Count} after"); + } + + /// Every player's uses of every kit, as Kits says now, for the ids in its player file. + private Dictionary Usage() + { + var usage = new Dictionary(); + JObject players = Interface.Oxide.DataFileSystem.ReadObject("Kits/player_data")?["_players"] as JObject; + if (players == null) return usage; + foreach (JProperty pl in players.Properties().Where(x => ulong.Parse(x.Name) < StandInId - 1000UL || ulong.Parse(x.Name) > StandInId + 1000UL)) + foreach (JProperty kit in ((pl.Value as JObject)?["_usageData"] as JObject ?? new JObject()).Properties()) + usage[pl.Name + "/" + kit.Name] = Convert.ToInt32(K?.Call("GetPlayerKitUses", ulong.Parse(pl.Name), kit.Name) ?? -1); + return usage; + } + + private IEnumerator KitsSpike(Vector3 f) + { + Check("s6.kits.loaded", K != null && K.IsLoaded, $"Kits {K?.Version}"); + JObject file = KitsFile(); + JObject kits = file?["_kits"] as JObject; + Check("s6.kits.read", kits != null, kits != null ? $"{kits.Count} kit(s): {string.Join(", ", kits.Properties().Select(x => x.Name).ToArray())}" : "no _kits"); + if (kits == null) yield break; + int before = kits.Count; + Dictionary realBefore = Usage(); + // Two NPCs a plugin spawned (as an event's are), to see what a Kits reload does to them. + Spawn(OnMesh(f + new Vector3(-60, 0, 30)), "t_roam"); + Spawn(OnMesh(f + new Vector3(-60, 0, 36)), "t_roam"); + yield return new WaitForSeconds(1f); + + // Round 1: a kit with a cooldown and a use limit, so a claim leaves usage behind. + kits[CooldownKit] = SpikeKit(CooldownKit, 3600, 5, new[] { KitItem("bandage", 1, 0) }, new JObject[0], new JObject[0]); + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + Check("s6.kits.notLive", !(K.Call("IsKit", CooldownKit) is bool && (bool)K.Call("IsKit", CooldownKit)), "Kits does not see a kit written to its file until it reloads"); + IEnumerator r = ReloadKits(); + while (r.MoveNext()) yield return r.Current; + Check("s6.kits.reload1", _reloaded, $"{(IsCarbon() ? "c.reload" : "oxide.reload")} Kits: new instance in {_reloadSeconds:0.0} s"); + Check("s6.kits.added", K != null && K.Call("IsKit", CooldownKit) is bool && (bool)K.Call("IsKit", CooldownKit), ""); + + BasePlayer claimer = StandIn(OnMesh(new Vector3(0, 0, 0)), 98); + ulong cid = (ulong)claimer.userID; + // An earlier run's claim is in Kits' player file (saved when it unloaded): clear it first. + K?.Call("SetPlayerKitUses", cid, CooldownKit, 0); + K?.Call("SetPlayerKitCooldown", cid, CooldownKit, 0.0); + object claimed = K?.Call("TryClaimKit", claimer, CooldownKit, false); + int uses = Convert.ToInt32(K?.Call("GetPlayerKitUses", cid, CooldownKit) ?? -1); + double cd = Convert.ToDouble(K?.Call("GetPlayerKitCooldown", cid, CooldownKit) ?? -1.0); + Check("s6.kits.claim", claimed is bool && (bool)claimed && uses == 1 && cd > 3500, $"a player claims it: {claimed ?? "null (no such hook)"}, uses {uses}, cooldown {cd:0} s — in Kits' memory, not yet saved"); + JObject onDisk = Interface.Oxide.DataFileSystem.ReadObject("Kits/player_data")?["_players"]?[cid.ToString()] as JObject; + Note($"s6.kits the claim on disk before the reload: {(onDisk != null ? "yes" : "no")}"); + + // Round 2: the swap's kit, written while a player's usage is only in memory. + file = KitsFile(); + kits = (JObject)file["_kits"]; + kits[SwapKit] = SpikeKit(SwapKit, 0, 0, new[] { KitItem("ammo.pistol", 120, 0) }, new[] { KitItem("hazmatsuit", 1, 0) }, new[] { KitItem("smg.mp5", 1, 0, 100f) }); + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + r = ReloadKits(); + while (r.MoveNext()) yield return r.Current; + Check("s6.kits.reload2", _reloaded, $"second reload: new instance in {_reloadSeconds:0.0} s"); + var names = new List(); + K?.Call("GetKitNames", names); + Check("s6.kits.all", names.Count == before + 2 && names.Contains(SwapKit) && names.Contains(CooldownKit), $"{names.Count} kit(s) now ({before} before): {string.Join(", ", names.ToArray())}"); + int usesAfter = Convert.ToInt32(K?.Call("GetPlayerKitUses", cid, CooldownKit) ?? -1); + double cdAfter = Convert.ToDouble(K?.Call("GetPlayerKitCooldown", cid, CooldownKit) ?? -1.0); + Check("s6.kits.usageKept", usesAfter == uses && cdAfter > cd - _reloadSeconds - 5 && cdAfter <= cd, $"the claim survived the reload: uses {uses}→{usesAfter}, cooldown {cd:0}→{cdAfter:0} s"); + Dictionary realAfter = Usage(); + Check("s6.kits.realUsageKept", realBefore.All(kv => realAfter.ContainsKey(kv.Key) && realAfter[kv.Key] == kv.Value), + realBefore.Count == 0 ? "no real players on file" : string.Join(", ", realBefore.Select(kv => $"{kv.Key} {kv.Value}→{(realAfter.ContainsKey(kv.Key) ? realAfter[kv.Key] : -1)}").ToArray())); + JObject fileAfter = KitsFile(); + Check("s6.kits.fileKept", fileAfter?["_kits"]?[SwapKit] != null && fileAfter?["_kits"]?[CooldownKit] != null, "Kits did not rewrite its file on load"); + Check("s6.kits.ref", Kits != null && Kits == K, "this plugin's [PluginReference] Kits follows the reload"); + } + + private IEnumerator UnkitsSpike() + { + JObject file = KitsFile(); + JObject kits = file?["_kits"] as JObject; + if (kits == null) yield break; + kits.Remove(SwapKit); + kits.Remove(CooldownKit); + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + IEnumerator r = ReloadKits(); + while (r.MoveNext()) yield return r.Current; + bool gone = !(K?.Call("IsKit", SwapKit) is bool && (bool)K.Call("IsKit", SwapKit)); + Check("s6.unkits", _reloaded && gone, $"spike kits removed, reload {_reloadSeconds:0.0} s"); + } + + // ---- a phase's kit swap mid-fight (D275) ---- + + private IEnumerator SwapSpike(Vector3 f) + { + if (!(K?.Call("IsKit", SwapKit) is bool && (bool)K.Call("IsKit", SwapKit))) + { + Check("s6.swap.kit", false, "no " + SwapKit + ": run the kits part first"); + yield break; + } + + Vector3 at, there; + if (!ClearLine(OnMesh(f + new Vector3(-30, 0, -60)), 15f, out at, out there)) + { + Check("s6.swap.spot", false, "no clear 15 m line"); + yield break; + } + + BasePlayer boss = Spawn(at, "t_hunter"); + ScientistNPC sci = SpawnScientist(there); + if (boss == null || sci == null) + { + Check("s6.swap.spawned", false, $"boss {(boss != null ? "up" : "null")}, scientist {(sci != null ? "up" : "null")} at {at}; profiles refused: {Api("RunicNpc_Profiles")?["refused"]?.ToString(Formatting.None)}"); + yield break; + } + + _spikeTarget = sci; + _spikeHits.Clear(); + int t; + for (t = 1; t <= 30; t++) + { + yield return new WaitForSeconds(1f); + if (SpikeHits("pistol.revolver") >= 3 && TargetOf(Describe(boss)) != "none") break; + } + + string target = TargetOf(Describe(boss)); + Check("s6.swap.fighting", SpikeHits("pistol.revolver") >= 3, $"revolver hits {SpikeHits("pistol.revolver")} by {t} s, target {target}, hits {JsonConvert.SerializeObject(_spikeHits)}"); + if (Gone(boss) || Gone(sci)) yield break; + + // The swap, as a phase would make it: strip, give, equip — one frame. + object given = null; + string split = SwapTimed(boss, SwapKit, out given); + Check("s6.swap.given", !(given is string) && Held(boss) == "smg.mp5", $"given={given ?? "null"}, held {Held(boss)}, {split}"); + int rev = SpikeHits("pistol.revolver"); + bool lost = false; + for (t = 1; t <= 20; t++) + { + yield return new WaitForSeconds(1f); + if (TargetOf(Describe(boss)) == "none") lost = true; + if (SpikeHits("smg.mp5") >= 5) break; + } + + Check("s6.swap.keepsTarget", !lost, $"target before {target}, after {TargetOf(Describe(boss))}, lost for a second: {lost}"); + Check("s6.swap.newWeapon", SpikeHits("smg.mp5") > 0 && SpikeHits("pistol.revolver") == rev, $"MP5 hits {SpikeHits("smg.mp5")} in {t} s, revolver hits after the swap {SpikeHits("pistol.revolver") - rev}"); + + // And back, WITHOUT EquipWeapon: does Rust's brain pick the new weapon up by itself? + boss.inventory.Strip(); + K.Call("GiveKit", boss, Kit); + string heldNow = Held(boss); + int mp5 = SpikeHits("smg.mp5"), revBefore = SpikeHits("pistol.revolver"); + string heldLater = heldNow; + for (t = 1; t <= 15; t++) + { + yield return new WaitForSeconds(1f); + heldLater = Held(boss); + if (SpikeHits("pistol.revolver") > revBefore) break; + } + + Note($"s6.swap without EquipWeapon: held {heldNow} at once, {heldLater} after {t} s, revolver hits {SpikeHits("pistol.revolver") - revBefore}, MP5 hits {SpikeHits("smg.mp5") - mp5}"); + + // The same swap twice more, warm: is the first one's cost Kits' first call, or every swap's? + for (int i = 2; i <= 3 && !Gone(boss); i++) + { + Note($"s6.swap #{i} warm: {SwapTimed(boss, i == 2 ? SwapKit : Kit, out given)}, held {Held(boss)}"); + yield return new WaitForSeconds(2f); + } + } + + /// Strip, Kits' GiveKit, EquipWeapon, each timed: "strip a ms, give b ms, equip c ms, total d ms". + private string SwapTimed(BasePlayer npc, string kit, out object given) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + npc.inventory.Strip(); + double strip = sw.Elapsed.TotalMilliseconds; + given = K.Call("GiveKit", npc, kit); + double give = sw.Elapsed.TotalMilliseconds; + ((HumanNPC)npc).EquipWeapon(); + double all = sw.Elapsed.TotalMilliseconds; + return $"strip {strip:0.00} ms, give {give - strip:0.00} ms, equip {all - give:0.00} ms, total {all:0.00} ms"; + } + + // ---- summoned adds (D275) ---- + + private IEnumerator AddsSpike(Vector3 f) + { + Vector3 at, there; + if (!ClearLine(OnMesh(f + new Vector3(60, 0, -40)), 20f, out at, out there)) + { + Check("s6.adds.spot", false, "no clear 20 m line"); + yield break; + } + + BasePlayer boss = Spawn(at, "t_hunter"); + ScientistNPC sci = SpawnScientist(there); + _spikeTarget = sci; + _spikeHits.Clear(); + for (int w = 0; w < 20 && TargetOf(Describe(boss)) == "none"; w++) + yield return new WaitForSeconds(1f); + Check("s6.adds.bossFighting", TargetOf(Describe(boss)) != "none", "target " + TargetOf(Describe(boss))); + + foreach (int count in new[] { 4, 10 }) + { + var adds = new List(); + int offMesh = 0; + var sw = System.Diagnostics.Stopwatch.StartNew(); + for (int i = 0; i < count; i++) + { + float a = i * Mathf.PI * 2f / count; + Vector3 ring = boss.transform.position + new Vector3(Mathf.Cos(a), 0, Mathf.Sin(a)) * 6f; + NavMeshHit hit; + if (!SampleNavmesh(ring, 3f, out hit)) + { + offMesh++; + continue; + } + + BasePlayer add = Spawn(hit.position, "t_hunter"); + if (add != null) adds.Add(add); + } + + double ms = sw.Elapsed.TotalMilliseconds; + yield return new WaitForSeconds(2f); + int onMesh = adds.Count(x => { NavMeshHit h; return !Gone(x) && SampleNavmesh(x.transform.position, 0.5f, out h); }); + int fighting = 0; + for (int w = 0; w < 20; w++) + { + fighting = adds.Count(x => !Gone(x) && TargetOf(Describe(x)) != "none"); + if (fighting == adds.Count) break; + yield return new WaitForSeconds(1f); + } + + Check($"s6.adds.{count}", adds.Count == count && onMesh == adds.Count && fighting == adds.Count, + $"{adds.Count}/{count} placed in {ms:0.0} ms ({ms / Math.Max(1, count):0.00} ms each, one frame), {offMesh} ring points off the navmesh, {onMesh} on the mesh after 2 s, {fighting} fighting the boss's target"); + foreach (BasePlayer x in adds) + if (!Gone(x)) Api("RunicNpc_Despawn", Id(x)); + yield return new WaitForSeconds(1f); + } + } + /// A route, as RunicNpc_Routes returns it. private sealed class Route { -- 2.49.1 From 3972bccf3405fe2c9c139864b32e89bac4369e97 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 10:47:00 -0500 Subject: [PATCH 11/24] feat: API 5, bosses, passive NPCs and the example profiles (stage 6) The boss box on any role but passive (D273): a health bar for players within its distance and everyone who hurt it, redrawn in place at most four times a second (D274); phases at health fractions that change its own copy of the profile (damage, aim, speed, ranges), swap its kit, summon adds one per frame and say a line (D275); spawn and death lines to everyone, phase lines to players near it (D277, D286); adds fight on after it dies (D287). Hooks OnRunicNpcBossSpawned, _BossPhase and _BossDied. The passive role (D278, D279, D288): never fights (GetBestTarget and AttackTick refuse, since Rust's design picks targets from every sensed player), cannot be hurt unless its profile says so, moves as its profile says including the new `stand`, and answers E through OnPlayerInput with a chat line or a window. Hook OnRunicNpcUsed. The four example profiles and their kits, written into Kits' data file on the first load only (D280, D284, D289), each kit needing a permission nobody holds. Kits' return re-checks the profiles. The swap's field list regenerated for Rust 2634.289.1: two new fields, eleven now [NonSerialized]. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin.toml | 9 +- plugin/RunicNPC.cs | 948 ++++++++++++++++++++++++++++++++++++++++-- tools/RunicNpcTest.cs | 250 ++++++++++- 3 files changed, 1169 insertions(+), 38 deletions(-) diff --git a/plugin.toml b/plugin.toml index 66c490f..40bda9e 100644 --- a/plugin.toml +++ b/plugin.toml @@ -26,9 +26,12 @@ # 2 was stage 2's whole API, PLAN.md §4. # 3 was stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the # OnRunicNpcPlacementChanged hook. -# Current: 4 — stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5 -# fields, a placement's tether, and the OnRunicNpcEscortEnded hook, documented in docs/runicnpc/API.md. -api = 4 +# 4 was stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5 +# fields, a placement's tether, and the OnRunicNpcEscortEnded hook. +# Current: 5 — stage 6: the profile's `boss` and `use` blocks and the passive role, the hooks +# OnRunicNpcBossSpawned, _BossPhase, _BossDied and OnRunicNpcUsed, and `role` and `boss` in +# RunicNpc_List, documented in docs/runicnpc/API.md. +api = 5 # ── Framework floors ───────────────────────────────────────────────────────── # diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index 5670545..c120c3b 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -11,6 +11,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core; using Oxide.Core.Plugins; +using Oxide.Game.Rust.Cui; using UnityEngine; using UnityEngine.AI; @@ -58,7 +59,7 @@ namespace Oxide.Plugins /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// - private const int ApiVersion = 4; + private const int ApiVersion = 5; /// /// Every hook this plugin implements. rnpc.status reports which have fired, because @@ -74,7 +75,10 @@ namespace Oxide.Plugins "OnEntitySpawned", "OnEntityTakeDamage", "CanBeTargeted", - "CanEntityTakeDamage" + "CanEntityTakeDamage", + "OnPluginLoaded", + "OnPlayerInput", + "OnPlayerDisconnected" }; private readonly Dictionary _hookCounts = new Dictionary(); @@ -230,9 +234,84 @@ namespace Oxide.Plugins /// The kit's extras it uses (D260); all off by default, and each one used is used up (D265). [JsonProperty("kitUse")] public KitUse KitUse = new KitUse(); + // ---- stage 6 (D273–D279) ---- + + /// The boss box (D273): null for an ordinary NPC. Any role but passive may carry it. + [JsonProperty("boss")] public BossBlock Boss; + + /// What pressing E on it does (D278); a passive profile's only, and required there. + [JsonProperty("use")] public UseBlock Use; + [JsonIgnore] public bool IsSentry => Role == "sentry"; [JsonIgnore] public bool IsGuard => Role == "guard"; + + /// D278, D279: never fights, press E to talk; cannot be hurt unless its profile says so. + [JsonIgnore] public bool IsPassive => Role == "passive"; + } + + /// + /// D273–D277: a health bar, phases at health thresholds, and lines said in game. Phases + /// change the NPC's own copy of its profile, never the shared one. + /// + public sealed class BossBlock + { + /// D274: players within this many metres see the bar, and so does everyone who has hurt it. + [JsonProperty("barDistance")] public float BarDistance = 100f; + + /// D277: chat, or popup where PopupNotifications is loaded (chat where it is not). + [JsonProperty("announce")] public string Announce = "chat"; + + /// D286: said to everyone on the server. Blank says nothing. + [JsonProperty("spawnLine")] public string SpawnLine = ""; + + /// D286: said to everyone on the server. {killer} is the killing blow's player. + [JsonProperty("deathLine")] public string DeathLine = ""; + + [JsonProperty("phases")] public List Phases = new List(); + } + + /// D275: what happens when a boss falls to a fraction of its health. Every part is optional. + public sealed class Phase + { + [JsonProperty("at")] public float At; + + /// Replaces the profile's damageDealt from here on. + [JsonProperty("damageDealt", NullValueHandling = NullValueHandling.Ignore)] public float? DamageDealt; + + /// Replaces the profile's aimCone from here on. + [JsonProperty("aimCone", NullValueHandling = NullValueHandling.Ignore)] public float? AimCone; + + /// A multiple of Rust's own speed for the NPC: 1.3 is a third faster. + [JsonProperty("speed", NullValueHandling = NullValueHandling.Ignore)] public float? Speed; + + /// Replaces the profile's ranges from here on. + [JsonProperty("ranges", NullValueHandling = NullValueHandling.Ignore)] public Ranges Ranges; + + /// A Kits kit it swaps to: strip, give, equip (stage 6 spike, finding 3). + [JsonProperty("kit", NullValueHandling = NullValueHandling.Ignore)] public string Kit; + + [JsonProperty("adds", NullValueHandling = NullValueHandling.Ignore)] public Adds Adds; + + /// D286: said only to the players near the boss and those who have hurt it. + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] public string Line; + } + + /// D275, D287: NPCs of another profile placed around the boss. They fight on after it dies. + public sealed class Adds + { + [JsonProperty("profile")] public string Profile; + [JsonProperty("count")] public int Count = 1; + } + + /// D278: a chat line picked at random from lines, or a window with a title, text and Close. + public sealed class UseBlock + { + /// chat or window. + [JsonProperty("mode")] public string Mode = "chat"; + [JsonProperty("lines")] public List Lines = new List(); + [JsonProperty("title")] public string Title = ""; + [JsonProperty("text")] public string Text = ""; } /// D260: the behaviours a kit's extra items allow, each opted into. @@ -354,11 +433,35 @@ namespace Oxide.Plugins private void ReadProfileFile() { - _profileFile = ReadData(DataProfiles); + JObject raw = ReadData(DataProfiles); + try + { + if (raw["profiles"] is JObject) + foreach (JProperty p in ((JObject)raw["profiles"]).Properties()) + NormalizePassive(p.Value as JObject); + _profileFile = raw.ToObject() ?? new ProfileFile(); + } + catch (Exception e) + { + PrintError($"data/{DataProfiles}.json could not be read and is treated as empty: {e.Message}"); + _profileFile = new ProfileFile(); + } + if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); if (_profileFile.Factions == null) _profileFile.Factions = new List(); } + /// + /// D279: a passive NPC cannot be hurt unless its profile says it can. hurtByPlayers is on + /// by default for every other role, so a passive profile that leaves it out is given "off" as + /// it is read, wherever a profile comes in: the file, a site's push, or rnpc.profile. + /// + private static void NormalizePassive(JObject profile) + { + if (profile != null && (string)profile["role"] == "passive" && profile["hurtByPlayers"] == null) + profile["hurtByPlayers"] = false; + } + private void SaveProfiles() => Interface.Oxide.DataFileSystem.WriteObject(DataProfiles, _profileFile); private void SaveRoutes() => Interface.Oxide.DataFileSystem.WriteObject(DataRoutes, _routeFile); @@ -411,10 +514,12 @@ namespace Oxide.Plugins return $"kits: this server has no kit '{kit}'"; if (ResolvePrefab(p.Prefab) == null) return $"prefab: '{p.Prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)"; - if (p.Role != "roamer" && p.Role != "sentry" && p.Role != "guard") - return $"role: '{p.Role}' is not roamer, sentry or guard"; + if (p.Role != "roamer" && p.Role != "sentry" && p.Role != "guard" && p.Role != "passive") + return $"role: '{p.Role}' is not roamer, sentry, guard or passive"; string movement = ValidateMovement(p.Movement); if (movement != null) return movement; + if (p.Movement.Mode == "stand" && !p.IsPassive) + return "movement.mode: stand is a passive NPC's; a fighting NPC that stands is a sentry"; if (!(p.Health > 0f)) return "health: must be above 0"; if (p.DamageDealt < 0f) return "damageDealt: must not be negative"; if (p.DamageTaken == null || p.DamageTaken.Head < 0f || p.DamageTaken.Body < 0f || p.DamageTaken.Legs < 0f) @@ -442,6 +547,83 @@ namespace Oxide.Plugins if (p.Turrets != "default" && p.Turrets != "ignore" && p.Turrets != "always") return $"turrets: '{p.Turrets}' is not default, ignore or always"; if (p.KitUse == null) return "kitUse: an object, even an empty one"; + + // Stage 6 (D273–D279). + if (p.IsPassive) + { + if (p.Boss != null) return "boss: a passive NPC never fights, so it cannot be a boss"; + if (p.Use == null) return "use: a passive NPC says what pressing E on it does (D278)"; + } + + if (p.Use != null && !p.IsPassive) + return "use: only a passive NPC answers E (D278)"; + if (p.Use != null) + { + string use = ValidateUse(p.Use); + if (use != null) return use; + } + + if (p.Boss != null) + { + string boss = ValidateBoss(p.Boss); + if (boss != null) return boss; + } + + return null; + } + + private const int MaxLine = 256; + private const int MaxWindowText = 2000; + private const int MaxAdds = 20; + + private static string ValidateUse(UseBlock u) + { + if (u.Mode != "chat" && u.Mode != "window") + return $"use.mode: '{u.Mode}' is not chat or window"; + if (u.Mode == "chat" && (u.Lines == null || u.Lines.Count == 0 || u.Lines.Any(string.IsNullOrWhiteSpace))) + return "use.lines: a chat answer gives at least one line, and no blank ones"; + if (u.Lines != null && u.Lines.Any(l => l != null && l.Length > MaxLine)) + return $"use.lines: each line is at most {MaxLine} characters"; + if (u.Mode == "window" && string.IsNullOrWhiteSpace(u.Title) && string.IsNullOrWhiteSpace(u.Text)) + return "use: a window has a title or some text"; + if ((u.Title ?? "").Length > 80) return "use.title: at most 80 characters"; + if ((u.Text ?? "").Length > MaxWindowText) return $"use.text: at most {MaxWindowText} characters"; + return null; + } + + private string ValidateBoss(BossBlock b) + { + if (!(b.BarDistance > 0f && b.BarDistance <= 1000f)) return "boss.barDistance: above 0 and at most 1000 metres"; + if (b.Announce != "chat" && b.Announce != "popup") return $"boss.announce: '{b.Announce}' is not chat or popup"; + if ((b.SpawnLine ?? "").Length > MaxLine || (b.DeathLine ?? "").Length > MaxLine) + return $"boss: a line is at most {MaxLine} characters"; + if (b.Phases == null) return "boss.phases: a list, even an empty one"; + var seen = new HashSet(); + for (int i = 0; i < b.Phases.Count; i++) + { + Phase ph = b.Phases[i]; + string at = $"boss.phases[{i}]"; + if (ph == null) return $"{at}: it is empty"; + if (!(ph.At > 0f && ph.At < 1f)) return $"{at}.at: a fraction of its health between 0 and 1"; + if (!seen.Add(ph.At)) return $"{at}.at: two phases at {ph.At}"; + if (ph.DamageDealt.HasValue && ph.DamageDealt.Value < 0f) return $"{at}.damageDealt: must not be negative"; + if (ph.AimCone.HasValue && ph.AimCone.Value < 0f) return $"{at}.aimCone: must not be negative"; + if (ph.Speed.HasValue && !(ph.Speed.Value > 0f && ph.Speed.Value <= 5f)) return $"{at}.speed: above 0 and at most 5 (times Rust's own)"; + if (ph.Ranges != null && (!(ph.Ranges.Sense > 0f) || !(ph.Ranges.Attack > 0f) || ph.Ranges.LoseTarget < ph.Ranges.Sense || ph.Ranges.Chase < 0f)) + return $"{at}.ranges: sense and attack above 0, loseTarget at least sense, chase not negative"; + if (ph.Kit != null && !KitExists(ph.Kit)) return $"{at}.kit: this server has no kit '{ph.Kit}'"; + if (ph.Adds != null) + { + if (ph.Adds.Profile == null || !NameRule.IsMatch(ph.Adds.Profile)) return $"{at}.adds.profile: a profile name"; + if (ph.Adds.Count < 1 || ph.Adds.Count > MaxAdds) return $"{at}.adds.count: 1 to {MaxAdds}"; + Profile add; + if (_profileFile.Profiles.TryGetValue(ph.Adds.Profile, out add) && add != null && add.Boss != null) + return $"{at}.adds.profile: '{ph.Adds.Profile}' is a boss, and a boss does not summon bosses"; + } + + if ((ph.Line ?? "").Length > MaxLine) return $"{at}.line: at most {MaxLine} characters"; + } + return null; } @@ -500,17 +682,21 @@ namespace Oxide.Plugins if (m == null) return "movement: missing"; string kind, route; if (!ParseMode(m.Mode, out kind, out route)) - return $"movement.mode: '{m.Mode}' is not wander, monument or route:"; + return $"movement.mode: '{m.Mode}' is not wander, monument, route: or (passive only) stand"; if (kind == "wander" && !(m.Radius > 0f)) return "movement.radius: a wanderer's radius must be above 0"; return null; } + /// A sentry, or a passive NPC whose movement is stand (D288): it holds its spot, on the navmesh or off it. + private static bool Still(Profile p, Movement placementMovement) => + p.IsSentry || (p.IsPassive && (placementMovement ?? p.Movement)?.Mode == "stand"); + private static bool ParseMode(string mode, out string kind, out string route) { kind = null; route = null; - if (mode == "wander" || mode == "monument") + if (mode == "wander" || mode == "monument" || mode == "stand") { kind = mode; return true; @@ -613,7 +799,7 @@ namespace Oxide.Plugins #region The swap (D223, D232) // - // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 2573b149-ba5d-458a-9b83-7f5f32c76afc. + // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 5c6eb1c7-ffa2-4b1e-a218-0a66bcc56f9e. // Do not edit by hand: regenerate after a Rust update (D232). private static readonly string[] SwapNpcFields = { @@ -636,24 +822,14 @@ namespace Oxide.Plugins "NPCPlayer.damageScale", "NPCPlayer.shortRange", "NPCPlayer.attackLengthMaxShortRangeScale", + "BasePlayer.debugCommandedNpcs", "BasePlayer.GestureViewModel", - "BasePlayer.currentTeam", - "BasePlayer.PetEntity", "BasePlayer.lastPlayerVisibility", "BasePlayer.eyeHistory", "BasePlayer.tickHistory", "BasePlayer.fallDamageEffect", "BasePlayer.drownEffect", - "BasePlayer.playerFlags", "BasePlayer.Belt", - "BasePlayer.weaponMoveSpeedScale", - "BasePlayer.clothingBlocksAiming", - "BasePlayer.clothingMoveSpeedReduction", - "BasePlayer.clothingWaterSpeedBonus", - "BasePlayer.clothingAccuracyBonus", - "BasePlayer.equippingBlocked", - "BasePlayer.eggVision", - "BasePlayer.designingAIEntity", "BaseCombatEntity.spawnDeployableCorpseOnDeath", "BaseCombatEntity.deployableCorpsePrefab", "BaseCombatEntity.skeletonProperties", @@ -668,6 +844,7 @@ namespace Oxide.Plugins "BaseCombatEntity.sendsMeleeHitNotification", "BaseCombatEntity.markAttackerHostile", "BaseCombatEntity.faction", + "BaseEntity.faces", "BaseEntity.bounds", "BaseEntity.impactEffect", "BaseEntity.enableSaving", @@ -803,7 +980,7 @@ namespace Oxide.Plugins /// swapped for ours. Everything the brain reads once, as it starts, is set here (stage 1, /// Q3: Senses.Init copies the sense values a single time). /// - private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, out string error) + private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, bool still, out string error) { error = null; BaseEntity made = GameManager.server.CreateEntity(ResolvePrefab(p.Prefab), at, Quaternion.Euler(0f, yaw, 0f), false); @@ -835,7 +1012,8 @@ namespace Oxide.Plugins // Our own sleep replaces an AI zone's (D235): a zone must never put ours to sleep. brain.AllowedToSleep = false; - brain.Sentry = p.IsSentry; + // A sentry, and a passive NPC that stands (D288), hold their spot and turn. + brain.Sentry = still; return npc; } @@ -893,6 +1071,21 @@ namespace Oxide.Plugins public new float EngagementRange() => Profile != null ? Profile.Ranges.Attack : base.EngagementRange(); + /// + /// D279: a passive NPC never fights. Rust's design picks its target from every player it has + /// sensed (HumanNPC.GetBestTarget reads Senses.Players, which IsTarget + /// does not filter) and shoots through AttackTick; both come through IAIAttack, which + /// this class re-implements, so a passive one has no target and no attack. + /// + public new BaseEntity GetBestTarget() => Profile != null && Profile.IsPassive ? null : base.GetBestTarget(); + + public new void AttackTick(float delta, BaseEntity target, bool targetIsLOS) + { + if (Profile != null && Profile.IsPassive) + return; + base.AttackTick(delta, target, targetIsLOS); + } + // ---- stage 5: whom it fights, beside Rust's own design (D231, D254–D272) ---- /// @@ -933,7 +1126,23 @@ namespace Oxide.Plugins /// escorting (D257, D269). IAISenses is re-implemented so Rust's senses ask here. /// public new bool IsTarget(BaseEntity entity) => - base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity)); + (Profile == null || !Profile.IsPassive) && base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity)); + + // ---- stage 6: the boss (D273–D277) ---- + + /// The phases it has entered, by their at; each is entered once. + public readonly HashSet PhasesFired = new HashSet(); + + /// The phase it is in: 0 before the first. + public int Phase; + + /// Rust's own navigator speed at spawn, which a phase's speed multiplies. + public float BaseSpeed = -1f; + + /// Its bar needs a redraw: set on every hit, cleared by the next redraw (at most four a second). + public bool BarDirty; + + public bool IsBoss => Profile != null && Profile.Boss != null; public new bool IsThreat(BaseEntity entity) => IsTarget(entity); @@ -1022,7 +1231,20 @@ namespace Oxide.Plugins Credit(attackerId, _healthBefore - health); if (Profile != null && !IsDead()) { + // D279: Rust's own Hurt teaches the NPC its attacker; a passive one forgets at once. + if (Profile.IsPassive && Brain?.Senses?.Memory != null) + { + Brain.Senses.Memory.Targets.Clear(); + Brain.Senses.Memory.Threats.Clear(); + } + _self?.CheckThresholds(this); + if (IsBoss) + { + BarDirty = true; + _self?.CheckPhases(this, attackerId); + } + // An unkillable NPC (D271) takes nothing and still answers the attack. if (info != null && info.Initiator != null) { @@ -1902,7 +2124,7 @@ namespace Oxide.Plugins private void NoteDefender(RunicNpcPlayer npc) { - if (npc.HasAlly || npc.Escort != null) + if ((npc.HasAlly || npc.Escort != null) && !npc.Profile.IsPassive) _defenders.Add(npc); else _defenders.Remove(npc); @@ -1933,6 +2155,8 @@ namespace Oxide.Plugins /// private void Fight(RunicNpcPlayer npc, RunicNpcBrain brain, float delta) { + if (npc.Profile.IsPassive) + return; float now = Time.time; HashSet hunts = HuntsOf(npc); BaseCombatEntity t = npc.NpcTarget; @@ -2110,6 +2334,9 @@ namespace Oxide.Plugins /// Called from the NPC's own Hurt, after Rust's. private void NpcHurt(RunicNpcPlayer npc, HitInfo info) { + // D279: a passive NPC never fights back, and raises no alarm. + if (npc.Profile.IsPassive) + return; BaseEntity source = info.Initiator; BaseCombatEntity attacker = TurretOf(source) ?? TurretOf(info.Weapon != null ? info.Weapon.GetParentEntity() : null) ?? source as BaseCombatEntity; if (attacker == null || attacker == npc || attacker.IsDestroyed || attacker.IsDead()) @@ -2158,7 +2385,8 @@ namespace Oxide.Plugins /// Tells one of ours about an attacker: Rust's memory for a player, our own target for anything else. private void Learn(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real) { - if (Spares(npc, attacker)) + // D279: a passive NPC never fights, so an ally's alarm does not reach it. + if (npc.Profile.IsPassive || Spares(npc, attacker)) return; npc.RnBrain?.Rouse(); if (real) @@ -2899,14 +3127,21 @@ namespace Oxide.Plugins Movement movement = movementOverride ?? p.Movement; string kind, route; ParseMode(movement.Mode, out kind, out route); - if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) + if (kind == "stand" && !p.IsPassive) + { + error = "movement stand is a passive NPC's; a fighting NPC that stands is a sentry"; + return null; + } + + bool still = p.IsSentry || kind == "stand"; + if (!still && kind == "route" && !_routeFile.Routes.ContainsKey(route)) { error = $"route '{route}' does not exist"; return null; } Vector3 position = at; - if (!p.IsSentry) + if (!still) { // Rust's sampler reaches further down than across: without the height check, a spot // on a roof finds the ground below it (stage 2). @@ -2926,7 +3161,11 @@ namespace Oxide.Plugins return null; } - RunicNpcPlayer npc = CreateNpc(p, position, yaw, out error); + // A boss's phases change its stats, so it gets its own copy of the profile (D275). + if (p.Boss != null) + p = Effective(p, null); + + RunicNpcPlayer npc = CreateNpc(p, position, yaw, still, out error); if (npc == null) return null; @@ -2960,6 +3199,8 @@ namespace Oxide.Plugins _npcs[npc.NetId] = npc; Index(npc, true); Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner); + if (p.Boss != null) + BossSpawned(npc); return npc; } @@ -2969,6 +3210,8 @@ namespace Oxide.Plugins if (!_npcs.Remove(npc.NetId)) return; Index(npc, false); + if (npc.IsBoss) + ClearBar(npc); if (npc.PlacementId != null) { @@ -3008,6 +3251,8 @@ namespace Oxide.Plugins var contributors = new Dictionary(npc.Contributors); Unregister(npc); Interface.CallHook("OnRunicNpcDied", npc, npc.ProfileName, npc.Owner, info, contributors); + if (npc.IsBoss) + BossDied(npc, info, contributors); } private void NoteRegrounded(RunicNpcPlayer npc, Vector3 from, Vector3 to) @@ -3089,7 +3334,7 @@ namespace Oxide.Plugins { if (_placementsDirty) SavePlacements(); - if (!_ready || _unloading) + if (!_ready || _unloading || _holdForKits) return; float now = Time.realtimeSinceStartup; @@ -3219,13 +3464,13 @@ namespace Oxide.Plugins // A scattered spot that leaves a small floor takes the placement's own spot instead. Vector2 offset = UnityEngine.Random.insideUnitCircle * Scatter; at = spot + new Vector3(offset.x, 0f, offset.y); - if (!p.IsSentry && OffMeshReason(at) != null) + if (!Still(p, data.Movement) && OffMeshReason(at) != null) at = spot; } // D239: the spot was on the navmesh when it was placed. If the world has changed under it (a // player-built floor destroyed), the NPC stands on the nearest navmesh until the spot returns. - string off = p.IsSentry ? null : OffMeshReason(at); + string off = Still(p, data.Movement) ? null : OffMeshReason(at); NavMeshHit nearest; if (off != null && SampleNavmesh(at, Fallback, out nearest)) { @@ -3283,6 +3528,588 @@ namespace Oxide.Plugins #endregion + #region Bosses (stage 6: D273–D277, D286, D287) + + [PluginReference] private Plugin PopupNotifications; + + private const string BarName = "rnpc.bar"; + private const string BarFill = "rnpc.bar.fill"; + private const string BarText = "rnpc.bar.text"; + + /// How far from the boss its adds are placed (D275), before each point is put on the navmesh. + private const float AddRing = 6f; + + /// The bosses alive now. + private readonly HashSet _bosses = new HashSet(); + + /// Whose bar each player sees, by Steam id: one boss at a time, the nearest that qualifies (D274). + private readonly Dictionary _barShown = new Dictionary(); + + private int _barTick; + + private void BossSpawned(RunicNpcPlayer npc) + { + _bosses.Add(npc); + BossBlock b = npc.Profile.Boss; + if (!string.IsNullOrWhiteSpace(b.SpawnLine)) + Say(b, BossText(b.SpawnLine, npc, null), null); + Interface.CallHook("OnRunicNpcBossSpawned", npc, npc.ProfileName, npc.Owner); + } + + private void BossDied(RunicNpcPlayer npc, HitInfo info, Dictionary contributors) + { + BossBlock b = npc.Profile.Boss; + BasePlayer killer = info != null ? info.InitiatorPlayer : null; + if (killer != null && killer.IsNpc) + killer = null; + if (!string.IsNullOrWhiteSpace(b.DeathLine)) + Say(b, BossText(b.DeathLine, npc, killer), null); + Interface.CallHook("OnRunicNpcBossDied", npc, npc.ProfileName, npc.Owner, info, contributors); + } + + /// A line's placeholders: {name} is the boss's name, {killer} the killing blow's player. + private static string BossText(string line, RunicNpcPlayer npc, BasePlayer killer) => + line.Replace("{name}", npc.RnName).Replace("{killer}", killer != null ? killer.displayName : ""); + + /// + /// D277, D286: a line said in game, to everyone ( null) or to the players + /// given. A popup where the profile asks for one and PopupNotifications is loaded; chat otherwise. + /// + private void Say(BossBlock b, string text, IEnumerable to) + { + bool popup = b.Announce == "popup" && PopupNotifications != null && PopupNotifications.IsLoaded; + if (to == null) + { + if (popup) + PopupNotifications.Call("CreatePopupNotification", text, null, 0f); + else + PrintToChat(text); + return; + } + + foreach (BasePlayer p in to) + { + if (p == null || !p.IsConnected) + continue; + if (popup) + PopupNotifications.Call("CreatePopupNotification", text, p, 0f); + else + p.ChatMessage(text); + } + } + + /// D286: the players a phase line reaches: within the bar's distance, or who have hurt it. + private static List Near(RunicNpcPlayer npc) + { + float r = npc.Profile.Boss.BarDistance; + Vector3 at = npc.transform.position; + return BasePlayer.activePlayerList + .Where(p => p != null && !p.IsNpc && ((p.transform.position - at).sqrMagnitude <= r * r || npc.Contributors.ContainsKey((ulong)p.userID))) + .ToList(); + } + + /// Called from the boss's Hurt: enters every phase its health has fallen to, highest first. + private void CheckPhases(RunicNpcPlayer npc, ulong attackerId) + { + float fraction = npc.health / npc.MaxHealth(); + foreach (Phase ph in npc.Profile.Boss.Phases.OrderByDescending(x => x.At)) + if (fraction <= ph.At && npc.PhasesFired.Add(ph.At)) + EnterPhase(npc, ph); + } + + /// D275: a phase changes the boss's own copy of its profile (Spawn made it), never the shared one. + private void EnterPhase(RunicNpcPlayer npc, Phase ph) + { + npc.Phase = npc.PhasesFired.Count; + Profile p = npc.Profile; + RunicNpcBrain brain = npc.RnBrain; + + if (ph.DamageDealt.HasValue) + { + p.DamageDealt = ph.DamageDealt.Value; + npc.damageScale = p.DamageDealt; + } + + if (ph.AimCone.HasValue) + { + p.AimCone = ph.AimCone.Value; + npc.aimConeScale = p.AimCone; + } + + if (ph.Ranges != null) + { + p.Ranges = new Ranges { Sense = ph.Ranges.Sense, LoseTarget = ph.Ranges.LoseTarget, Chase = ph.Ranges.Chase, Attack = ph.Ranges.Attack }; + if (brain != null) + { + brain.SenseRange = p.Ranges.Sense; + brain.TargetLostRange = p.Ranges.LoseTarget; + } + } + + // Rust sets the agent's speed from the navigator's on every move, so this holds. + if (ph.Speed.HasValue && brain?.Navigator != null) + { + if (npc.BaseSpeed < 0f) + npc.BaseSpeed = brain.Navigator.Speed; + brain.Navigator.Speed = npc.BaseSpeed * ph.Speed.Value; + } + + // The spike (finding 3): strip, give, equip. Without EquipWeapon it holds nothing. + if (ph.Kit != null) + { + npc.inventory.Strip(); + object given = Kits?.Call("GiveKit", npc, ph.Kit); + if (given is string) + PrintWarning($"Kits did not give '{ph.Kit}' to {npc.RnName} ({npc.ProfileName}) in phase {npc.Phase}: {given}"); + else + npc.Kit = ph.Kit; + npc.EquipWeapon(); + } + + if (ph.Adds != null) + SummonAdds(npc, ph.Adds); + + if (!string.IsNullOrWhiteSpace(ph.Line)) + Say(p.Boss, BossText(ph.Line, npc, null), Near(npc)); + + Interface.CallHook("OnRunicNpcBossPhase", npc, npc.ProfileName, npc.Owner, npc.Phase, ph.At); + } + + /// + /// D275, D287: adds of another profile on a ring around the boss, one per server frame (the + /// spike measured 4–8 ms each). They belong to the boss's owner, so an event's teardown removes + /// them, and they fight on after the boss dies. Each learns the boss's attackers nearby. + /// + private void SummonAdds(RunicNpcPlayer boss, Adds adds) + { + Profile p; + if (!_profiles.TryGetValue(adds.Profile, out p)) + { + PrintWarning($"{boss.RnName} ({boss.ProfileName}) cannot summon '{adds.Profile}': " + + (_refused.ContainsKey(adds.Profile) ? "it is refused: " + _refused[adds.Profile] : "there is no such profile")); + return; + } + + Vector3 centre = boss.transform.position; + string owner = boss.Owner; + var attackers = boss.Contributors.Keys.ToList(); + float turn = UnityEngine.Random.value * Mathf.PI * 2f; + int i = 0; + Action next = null; + next = () => + { + if (_unloading || i >= adds.Count) + return; + float a = turn + i * Mathf.PI * 2f / adds.Count; + Vector3 ring = centre + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * AddRing; + string error; + NavMeshHit hit; + RunicNpcPlayer add = null; + if (SampleNavmesh(ring, 3f, out hit)) + add = Spawn(adds.Profile, p, hit.position, UnityEngine.Random.Range(0f, 360f), owner, null, null, -1, out error); + else + error = "no navmesh within 3 m of its spot"; + if (add == null) + PrintWarning($"{boss.RnName} ({boss.ProfileName}): add {i + 1} of {adds.Count} ('{adds.Profile}') not placed: {error}"); + else + foreach (ulong id in attackers) + { + BasePlayer who = BasePlayer.FindByID(id); + if (who != null && who.IsAlive() && Vector3.Distance(who.transform.position, add.transform.position) <= 100f) + Learn(add, who, true); + } + + i++; + if (i < adds.Count) + NextFrame(next); + }; + next(); + } + + // ---- the bar (D274): the stage 6 spike's shape (finding 2) ---- + + /// + /// Four times a second: redraw each hurt boss's bar in place for its viewers. Once a second: + /// work out who sees which bar, adding and removing whole bars. + /// + private void BarTick() + { + if (_bosses.Count == 0 && _barShown.Count == 0) + return; + if (++_barTick % 4 == 0) + SweepViewers(); + foreach (RunicNpcPlayer boss in _bosses) + { + if (!boss.BarDirty || boss.IsDestroyed) + continue; + boss.BarDirty = false; + string json = null; + foreach (KeyValuePair kv in _barShown) + { + if (kv.Value != boss) + continue; + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p == null || !p.IsConnected) + continue; + if (json == null) + json = BarJson(boss, true); + CuiHelper.AddUi(p, json); + } + } + } + + private void SweepViewers() + { + var want = new Dictionary(); + foreach (BasePlayer p in BasePlayer.activePlayerList) + { + if (p == null || p.IsNpc || !p.IsConnected) + continue; + ulong id = (ulong)p.userID; + RunicNpcPlayer best = null; + float bestD = float.MaxValue; + foreach (RunicNpcPlayer b in _bosses) + { + if (b == null || b.IsDestroyed || b.IsDead()) + continue; + float d = (b.transform.position - p.transform.position).sqrMagnitude; + float r = b.Profile.Boss.BarDistance; + if ((d <= r * r || b.Contributors.ContainsKey(id)) && d < bestD) + { + best = b; + bestD = d; + } + } + + if (best != null) + want[id] = best; + } + + foreach (KeyValuePair kv in _barShown.ToList()) + { + RunicNpcPlayer now; + if (want.TryGetValue(kv.Key, out now) && now == kv.Value) + continue; + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p != null && p.IsConnected) + CuiHelper.DestroyUi(p, BarName); + _barShown.Remove(kv.Key); + } + + foreach (KeyValuePair kv in want) + { + if (_barShown.ContainsKey(kv.Key)) + continue; + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p == null) + continue; + CuiHelper.AddUi(p, BarJson(kv.Value, false)); + _barShown[kv.Key] = kv.Value; + } + } + + /// A boss gone (death, despawn, unload): its bar comes off every screen showing it. + private void ClearBar(RunicNpcPlayer npc) + { + _bosses.Remove(npc); + foreach (KeyValuePair kv in _barShown.Where(x => x.Value == npc).ToList()) + { + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p != null && p.IsConnected) + CuiHelper.DestroyUi(p, BarName); + _barShown.Remove(kv.Key); + } + } + + /// + /// The bar: a dark strip at the top of the screen, a red fill and the boss's name and health. + /// A redraw sends only the fill and the text, changed in place with CUI's update. + /// + private static string BarJson(RunicNpcPlayer boss, bool update) + { + float fraction = Mathf.Clamp01(boss.health / boss.MaxHealth()); + string text = $"{boss.RnName} {Mathf.CeilToInt(boss.health)} / {Mathf.CeilToInt(boss.MaxHealth())}"; + var c = new CuiElementContainer(); + if (!update) + c.Add(new CuiPanel { Image = { Color = "0.1 0.1 0.1 0.75" }, RectTransform = { AnchorMin = "0.35 0.905", AnchorMax = "0.65 0.935" } }, "Hud", BarName); + c.Add(new CuiPanel { Image = { Color = "0.72 0.12 0.1 0.9" }, RectTransform = { AnchorMin = "0 0", AnchorMax = $"{fraction.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)} 1" } }, BarName, BarFill); + c.Add(new CuiLabel { Text = { Text = text, FontSize = 13, Align = TextAnchor.MiddleCenter, Color = "1 1 1 0.95" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, BarName, BarText); + if (update) + foreach (CuiElement e in c) + e.Update = true; + return CuiHelper.ToJson(c); + } + + #endregion + + #region Passive NPCs and press E (stage 6: D278, D279, D288) + + private const string WindowName = "rnpc.window"; + + /// How far a player's E reaches: Rust's own use distance is about this. + private const float UseReach = 3f; + + private static int _useMask; + + private readonly Dictionary _nextUse = new Dictionary(); + + /// + /// Neither framework raises a hook for E on an NPC, and Rust ignores it (stage 6 spike, finding + /// 1): so every player tick is checked for E pressed, at about 7 ns, and only then is a ray cast. + /// + private void OnPlayerInput(BasePlayer player, InputState input) + { + MarkHook("OnPlayerInput"); + if (input == null || !input.WasJustPressed(BUTTON.USE) || player == null || player.IsNpc) + return; + if (_useMask == 0) + _useMask = LayerMask.GetMask("Player (Server)", "Construction", "Deployed", "World", "Terrain", "Default", "Tree"); + + RaycastHit hit; + if (!Physics.Raycast(player.eyes.HeadRay(), out hit, UseReach, _useMask, QueryTriggerInteraction.Ignore)) + return; + RunicNpcPlayer npc = hit.GetEntity() as RunicNpcPlayer; + if (npc?.Profile == null || !npc.Profile.IsPassive || npc.Profile.Use == null || npc.IsDead()) + return; + + ulong id = (ulong)player.userID; + float now = Time.realtimeSinceStartup, next; + if (_nextUse.TryGetValue(id, out next) && now < next) + return; + _nextUse[id] = now + 1f; + Use(npc, player); + } + + /// D278: a chat line picked at random, or a window. {player} in either is the player's name. + private void Use(RunicNpcPlayer npc, BasePlayer player) + { + UseBlock u = npc.Profile.Use; + if (u.Mode == "chat") + { + string line = u.Lines[UnityEngine.Random.Range(0, u.Lines.Count)].Replace("{player}", player.displayName); + player.ChatMessage($"{npc.RnName}: {line}"); + } + else + OpenWindow(player, npc, u); + + // One that stands turns to whoever spoke to it. + if (npc.RnBrain != null && npc.RnBrain.Sentry) + npc.SetAimDirection((player.eyes.position - npc.eyes.position).normalized); + + Interface.CallHook("OnRunicNpcUsed", npc, npc.ProfileName, player); + } + + private static void OpenWindow(BasePlayer player, RunicNpcPlayer npc, UseBlock u) + { + CuiHelper.DestroyUi(player, WindowName); + string title = string.IsNullOrWhiteSpace(u.Title) ? npc.RnName : u.Title; + var c = new CuiElementContainer(); + c.Add(new CuiPanel + { + Image = { Color = "0.08 0.08 0.08 0.92" }, + RectTransform = { AnchorMin = "0.32 0.3", AnchorMax = "0.68 0.72" }, + CursorEnabled = true + }, "Overlay", WindowName); + c.Add(new CuiLabel + { + Text = { Text = title.Replace("{player}", player.displayName), FontSize = 18, Align = TextAnchor.MiddleLeft, Color = "0.91 0.76 0.44 1" }, + RectTransform = { AnchorMin = "0.05 0.85", AnchorMax = "0.95 0.96" } + }, WindowName); + c.Add(new CuiLabel + { + Text = { Text = (u.Text ?? "").Replace("{player}", player.displayName), FontSize = 14, Align = TextAnchor.UpperLeft, Color = "0.92 0.92 0.92 1" }, + RectTransform = { AnchorMin = "0.05 0.17", AnchorMax = "0.95 0.82" } + }, WindowName); + c.Add(new CuiButton + { + Button = { Close = WindowName, Color = "0.3 0.3 0.3 0.9" }, + Text = { Text = "Close", FontSize = 14, Align = TextAnchor.MiddleCenter }, + RectTransform = { AnchorMin = "0.4 0.03", AnchorMax = "0.6 0.12" } + }, WindowName); + CuiHelper.AddUi(player, c); + } + + private void OnPlayerDisconnected(BasePlayer player, string reason) + { + MarkHook("OnPlayerDisconnected"); + if (player == null) + return; + ulong id = (ulong)player.userID; + _barShown.Remove(id); + _nextUse.Remove(id); + } + + #endregion + + #region The example profiles (stage 6: D280, D284, D289) + + private const string DataState = "RunicNPC/state"; + + /// The permission every example kit needs, which nobody is granted: no player can claim one by name (D284). + private const string ExamplePermission = "runicnpc.examplekits"; + + private sealed class StateFile + { + /// D284: set on the first load that wrote them, and never cleared. + [JsonProperty("examplesWritten")] public bool ExamplesWritten; + } + + /// Placements wait while Kits reloads with the example kits (D283 reloads RunicNPC with it). + private bool _holdForKits; + + private static JObject ExItem(string shortname, int amount, int position, float condition = 0f) => + JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = condition, MaxCondition = condition, Ammo = 0, Position = position, Frequency = -1 }); + + private static JObject ExKit(string name, JObject[] main, JObject[] wear, JObject[] belt) => new JObject + { + ["Name"] = name, ["Description"] = "A RunicNPC example kit, for its example NPCs", ["RequiredPermission"] = ExamplePermission, + ["MaximumUses"] = 0, ["RequiredAuth"] = 0, ["Cooldown"] = 0, ["Cost"] = 0, ["IsHidden"] = true, + ["CopyPasteFile"] = "", ["KitImage"] = "", + ["MainItems"] = new JArray(main), ["WearItems"] = new JArray(wear), ["BeltItems"] = new JArray(belt) + }; + + private static JObject[] Wear(params string[] items) => items.Select((s, i) => ExItem(s, 1, i)).ToArray(); + + /// D289: the four kits. A short name this Rust does not know is left out with a warning. + private static Dictionary ExampleKits() => new Dictionary + { + ["rnpc_raider"] = ExKit("rnpc_raider", new[] { ExItem("ammo.rifle", 120, 0) }, + Wear("hoodie", "pants", "shoes.boots", "mask.bandana"), new[] { ExItem("rifle.semiauto", 1, 0, 100f) }), + ["rnpc_campguard"] = ExKit("rnpc_campguard", new[] { ExItem("ammo.pistol", 200, 0) }, + Wear("metal.facemask", "metal.plate.torso", "hoodie", "pants", "shoes.boots"), new[] { ExItem("smg.thompson", 1, 0, 100f) }), + ["rnpc_sniper"] = ExKit("rnpc_sniper", new[] { ExItem("ammo.rifle", 60, 0) }, + Wear("hat.boonie", "hoodie", "pants", "shoes.boots"), new[] { ExItem("rifle.bolt", 1, 0, 100f) }), + ["rnpc_juggernaut"] = ExKit("rnpc_juggernaut", new[] { ExItem("ammo.rifle", 300, 0) }, + Wear("heavy.plate.helmet", "heavy.plate.jacket", "heavy.plate.pants", "shoes.boots"), new[] { ExItem("lmg.m249", 1, 0, 100f) }) + }; + + private static JObject ExRanges(float sense, float lose, float chase, float attack) => + JObject.FromObject(new { sense, loseTarget = lose, chase, attack }); + + /// D289: the four profiles. + private static Dictionary ExampleProfiles() => new Dictionary + { + ["raider"] = new JObject + { + ["names"] = new JArray("Raider"), ["kits"] = new JArray("rnpc_raider"), ["role"] = "roamer", + ["movement"] = JObject.FromObject(new { mode = "wander", radius = 40f }), ["health"] = 150f, + ["ranges"] = ExRanges(30f, 40f, 40f, 30f) + }, + ["campguard"] = new JObject + { + ["names"] = new JArray("Camp Guard"), ["kits"] = new JArray("rnpc_campguard"), ["role"] = "guard", + ["movement"] = JObject.FromObject(new { mode = "wander", radius = 10f }), ["health"] = 200f, + ["ranges"] = ExRanges(35f, 45f, 30f, 30f) + }, + ["sniper"] = new JObject + { + ["names"] = new JArray("Sniper"), ["kits"] = new JArray("rnpc_sniper"), ["role"] = "sentry", ["health"] = 120f, + ["aimCone"] = 1f, ["ranges"] = ExRanges(120f, 130f, 0f, 120f) + }, + ["juggernaut"] = new JObject + { + ["names"] = new JArray("Juggernaut"), ["kits"] = new JArray("rnpc_juggernaut"), ["role"] = "roamer", + ["movement"] = JObject.FromObject(new { mode = "wander", radius = 30f }), ["health"] = 2500f, + ["ranges"] = ExRanges(40f, 50f, 40f, 35f), + ["boss"] = JObject.FromObject(new + { + barDistance = 100f, + announce = "chat", + spawnLine = "The Juggernaut has risen!", + deathLine = "The Juggernaut has fallen.", + phases = new object[] + { + new { at = 0.5f, adds = new { profile = "raider", count = 3 }, line = "The Juggernaut calls for help!" }, + new { at = 0.25f, damageDealt = 1.5f, speed = 1.3f, line = "The Juggernaut is enraged!" } + } + }) + } + }; + + /// + /// D284: on the first load only, the four example kits go into Kits' own data file (Kits has no + /// API that creates one, D280) and the four profiles into ours, each only where its name is free. + /// The flag is set before anything is written, so this runs once whatever happens. Kits is then + /// reloaded, which reloads RunicNPC too (D283); placements wait for it. + /// + private void WriteExamplesOnce() + { + StateFile state = ReadData(DataState); + if (state.ExamplesWritten) + return; + state.ExamplesWritten = true; + Interface.Oxide.DataFileSystem.WriteObject(DataState, state); + + var addedKits = new List(); + try + { + JObject file = Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data") ?? new JObject(); + JObject kits = file["_kits"] as JObject; + if (kits == null) + file["_kits"] = kits = new JObject(); + foreach (KeyValuePair kv in ExampleKits()) + { + if (kits.Properties().Any(x => string.Equals(x.Name, kv.Key, StringComparison.OrdinalIgnoreCase))) + continue; + foreach (string list in new[] { "MainItems", "WearItems", "BeltItems" }) + { + var known = new JArray(); + foreach (JToken item in (JArray)kv.Value[list]) + { + if (ItemManager.FindItemDefinition((string)item["Shortname"]) != null) + known.Add(item); + else + PrintWarning($"Example kit {kv.Key}: this Rust has no item '{item["Shortname"]}', so it is left out."); + } + + kv.Value[list] = known; + } + + kits[kv.Key] = kv.Value; + addedKits.Add(kv.Key); + } + + if (addedKits.Count > 0) + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + } + catch (Exception e) + { + PrintError($"The example kits could not be written into Kits' data file: {e.Message}"); + addedKits.Clear(); + } + + var addedProfiles = new List(); + if (!_profileFile.Managed) + { + foreach (KeyValuePair kv in ExampleProfiles()) + { + if (_profileFile.Profiles.ContainsKey(kv.Key)) + continue; + _profileFile.Profiles[kv.Key] = kv.Value.ToObject(); + addedProfiles.Add(kv.Key); + } + + if (addedProfiles.Count > 0) + SaveProfiles(); + } + + Puts($"First load: example kits written into Kits' data file: {(addedKits.Count > 0 ? string.Join(", ", addedKits.ToArray()) : "none (their names are taken)")}; " + + $"example profiles: {(addedProfiles.Count > 0 ? string.Join(", ", addedProfiles.ToArray()) : _profileFile.Managed ? "none (a site manages this server's profiles)" : "none (their names are taken)")}. " + + "They are never written again, even if you delete them."); + + if (addedKits.Count == 0) + return; + + // Kits reads its file only when it loads (stage 6 spike, finding 5). The reload takes RunicNPC down and + // back with it (D283); if this framework does not, OnPluginLoaded("Kits") picks the profiles up. + _holdForKits = true; + timer.Once(60f, () => _holdForKits = false); + string command = Framework() == "carbon" ? "c.reload" : "oxide.reload"; + timer.Once(1f, () => ConsoleSystem.Run(ConsoleSystem.Option.Server, command, "Kits")); + } + + #endregion + #region Lifecycle and hooks private void Init() @@ -3298,12 +4125,14 @@ namespace Oxide.Plugins MarkHook("OnServerInitialized"); ResolveFields(); LoadData(); + WriteExamplesOnce(); RevalidateProfiles(); RebuildPlacements(); TrackWorld(); PatchSentries(); _ready = true; timer.Every(1f, TickPlacements); + timer.Every(0.25f, BarTick); Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " + $"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}"); TickPlacements(); @@ -3338,9 +4167,32 @@ namespace Oxide.Plugins Puts($"{plugin.Name} unloaded: its {n} NPC(s) were removed."); } + /// + /// Kits came back (a reload, or the example kits' own, D284): the profiles' kits are checked again + /// and held placements go on. Where the framework reloads RunicNPC with Kits (D283), this is moot. + /// + private void OnPluginLoaded(Plugin plugin) + { + MarkHook("OnPluginLoaded"); + if (plugin == null || plugin.Name != "Kits" || !_ready) + return; + _holdForKits = false; + RevalidateProfiles(); + TickPlacements(); + } + private void Unload() { _unloading = true; + foreach (BasePlayer p in BasePlayer.activePlayerList) + { + if (p == null || !p.IsConnected) + continue; + CuiHelper.DestroyUi(p, BarName); + CuiHelper.DestroyUi(p, WindowName); + } + + _barShown.Clear(); UnpatchSentries(); Despawn(_npcs.Values); _npcs.Clear(); @@ -3438,6 +4290,8 @@ namespace Oxide.Plugins ["ally"] = n.AllyClan != 0L ? "clan:" + n.AllyClan : n.AllyTeam != 0UL ? "team:" + n.AllyTeam : n.AllyPlayer != 0UL ? "player:" + n.AllyPlayer : null, ["tether"] = n.Tether, ["shotsMarked"] = n.ShotsMarked, + ["role"] = n.Profile?.Role, + ["boss"] = n.IsBoss ? new Dictionary { ["phase"] = n.Phase, ["damagers"] = n.Contributors.Count } : null, ["kitUsed"] = new Dictionary { ["heals"] = n.Heals, ["throws"] = n.Throws, ["rockets"] = n.Rockets, ["meleeHits"] = n.MeleeHits } }; } @@ -3458,6 +4312,9 @@ namespace Oxide.Plugins Dictionary profiles; try { + if (all != null) + foreach (JProperty p in all.Properties()) + NormalizePassive(p.Value as JObject); profiles = all != null ? all.ToObject>() : new Dictionary(); } catch (Exception e) @@ -3513,7 +4370,7 @@ namespace Oxide.Plugins return error; Profile profile; - if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !profile.IsSentry) + if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !Still(profile, data.Movement)) { string off = OffMeshReason(data.Position.V); if (off != null) @@ -4170,7 +5027,7 @@ namespace Oxide.Plugins if (!NavmeshReady()) return "The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly."; - if (!p.IsSentry) + if (!Still(p, data.Movement)) { string off = OffMeshReason(data.Position.V); if (off != null) @@ -4970,6 +5827,20 @@ namespace Oxide.Plugins return null; } + // Stage 6: the boss box and press E switch on with their defaults and off with none; their own + // fields are then set one at a time (boss.spawnLine …, use.mode …), and a list of objects as + // JSON: boss.phases [{"at":0.5,"line":"…"}]. + if ((field == "boss" || field == "use") && (value == "on" || value == "none" || value == "-")) + { + o[field] = value == "on" ? (o[field] is JObject ? o[field] : JObject.FromObject(field == "boss" ? (object)new BossBlock() : new UseBlock { Lines = new List { "Hello." } })) : null; + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + + // D279: a profile made passive starts unhurtable; "hurtByPlayers on" changes that. + if (field == "role" && value == "passive" && (string)o["role"] != "passive") + o["hurtByPlayers"] = false; + JToken target = o.SelectToken(field); if (target == null || target is JObject) return target == null @@ -4978,7 +5849,18 @@ namespace Oxide.Plugins JToken replacement; float f; - if (target is JArray) + if (target is JArray && value.TrimStart().StartsWith("[")) + { + try + { + replacement = JArray.Parse(value); + } + catch (Exception e) + { + return $"'{field}' could not be read as JSON: {e.Message}"; + } + } + else if (target is JArray) { string[] parts = value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray(); if (field == "healthThresholds") diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 753f264..a7fdb5a 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -293,6 +293,7 @@ namespace Oxide.Plugins if (withEach) all["t_each"] = Prof(name: "TEach"); Stage5Profiles(all); + Stage6Profiles(all); return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "no answer" }; } @@ -490,7 +491,7 @@ namespace Oxide.Plugins string route = SetLoop(field + new Vector3(60f, 0f, 0f)); Check("setup.route", route == null, route ?? "t_loop set"); - string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "place" } : new[] { group }; + string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "s6", "place" } : new[] { group }; foreach (string g in groups) { IEnumerator inner = g == "api" ? Api(field) @@ -503,6 +504,7 @@ namespace Oxide.Plugins : g == "floor" ? Floor(field) : g == "place" ? Place(field) : g == "s5" ? Stage5() + : g == "s6" ? Stage6() : null; if (inner == null) { @@ -524,7 +526,7 @@ namespace Oxide.Plugins private IEnumerator Api(Vector3 field) { - Check("api.version", Api("RunicNpc_ApiVersion") == 4, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + Check("api.version", Api("RunicNpc_ApiVersion") == 5, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); JObject profiles = Api("RunicNpc_Profiles"); Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]); @@ -1906,6 +1908,250 @@ namespace Oxide.Plugins Api("RunicNpc_DespawnOwner", Owner); } + // ---- stage 6: bosses, passive NPCs and the examples (D273–D289) ---- + + private readonly List _bossEvents = new List(); + private readonly List _used = new List(); + private readonly Dictionary _bossContributors = new Dictionary(); + + private void OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner) => _bossEvents.Add("spawned:" + profile); + + private void OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at) => _bossEvents.Add($"phase{phase}@{at}"); + + private void OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) + { + _bossEvents.Add("died:" + profile); + _bossContributors.Clear(); + foreach (KeyValuePair kv in contributors) _bossContributors[kv.Key] = kv.Value; + } + + private void OnRunicNpcUsed(BasePlayer npc, string profile, BasePlayer player) => _used.Add(profile + "/" + player.displayName); + + private static void Stage6Profiles(JObject all) + { + JObject boss = Prof(name: "TBoss"); + boss["health"] = 1000f; + boss["damageTaken"] = JObject.FromObject(new { head = 1f, body = 1f, legs = 1f }); + boss["boss"] = JObject.Parse(@"{ + ""barDistance"": 100, ""announce"": ""chat"", + ""spawnLine"": ""{name} rises (rnt)"", ""deathLine"": ""{name} fell to {killer} (rnt)"", + ""phases"": [ + { ""at"": 0.75, ""damageDealt"": 2, ""line"": ""phase one (rnt)"" }, + { ""at"": 0.5, ""adds"": { ""profile"": ""t_roam"", ""count"": 2 }, ""line"": ""adds (rnt)"" }, + { ""at"": 0.25, ""kit"": ""rnhrevolver"", ""speed"": 1.5, ""aimCone"": 4, + ""ranges"": { ""sense"": 50, ""loseTarget"": 60, ""chase"": 50, ""attack"": 40 } } + ]}"); + all["t_boss"] = boss; + + JObject passive = Prof(role: "passive", name: "TPassive"); + passive["movement"] = JObject.FromObject(new { mode = "stand", radius = 1f }); + passive["use"] = JObject.FromObject(new { mode = "chat", lines = new[] { "Hello {player} (rnt)" } }); + all["t_passive"] = passive; + + JObject window = Prof(role: "passive", name: "TWindow"); + window["use"] = JObject.FromObject(new { mode = "window", title = "Notice", text = "Hello {player} (rnt)" }); + all["t_passwin"] = window; + + JObject hurt = Prof(role: "passive", name: "THurt"); + hurt["movement"] = JObject.FromObject(new { mode = "stand", radius = 1f }); + hurt["use"] = JObject.FromObject(new { mode = "chat", lines = new[] { "Ow (rnt)" } }); + hurt["hurtByPlayers"] = true; + all["t_passhurt"] = hurt; + } + + /// A stand-in looks at an NPC's chest, as a player about to press E does (PlayerEyes.HeadRay). + private static void Look(BasePlayer p, BasePlayer at) + { + Vector3 dir = (at.CenterPoint() - p.eyes.position).normalized; + p.eyes.rotation = Quaternion.LookRotation(dir); + } + + /// E, just pressed, through RunicNPC's own OnPlayerInput. + private void PressE(BasePlayer p) + { + var input = new InputState(); + input.current.buttons = (int)BUTTON.USE; + input.previous.buttons = 0; + RunicNPC?.Call("OnPlayerInput", p, input); + } + + /// A stand-in shoots an NPC in 40-damage hits until its health is below a fraction (its kit's armour soaks some). + private static void HurtTo(BasePlayer npc, BasePlayer by, float fraction) + { + for (int i = 0; i < 200 && !Gone(npc) && npc.health / npc.MaxHealth() >= fraction; i++) + npc.Hurt(new HitInfo(by, npc, Rust.DamageType.Bullet, 40f)); + } + + private IEnumerator Stage6() + { + Vector3 f; + if (!FindField(300f, 2026, out f)) + { + Check("s6.setup.field", false, "no third open field on the navmesh"); + yield break; + } + + StandIn(f + new Vector3(0, 0, 60), 50); + + // ---- refusals, through a spawn's overrides (ValidateProfile on the effective profile) ---- + Func refused = (profile, json) => Spawn(f, profile, JObject.Parse(json)) == null; + Check("s6.refuse.passiveBoss", refused("t_passive", "{\"boss\":{}}"), "a passive NPC cannot be a boss"); + Check("s6.refuse.useOnRoamer", refused("t_roam", "{\"use\":{\"mode\":\"chat\",\"lines\":[\"hi\"]}}"), "only a passive NPC answers E"); + Check("s6.refuse.standRoamer", refused("t_roam", "{\"movement\":{\"mode\":\"stand\",\"radius\":1}}"), "stand is a passive NPC's"); + Check("s6.refuse.useMode", refused("t_passive", "{\"use\":{\"mode\":\"shout\"}}"), "use.mode chat or window"); + Check("s6.refuse.chatNoLines", refused("t_passive", "{\"use\":{\"lines\":[]}}"), "a chat answer has lines"); + Check("s6.refuse.phaseAt", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":1.5}]}}"), "at between 0 and 1"); + Check("s6.refuse.phaseTwice", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5},{\"at\":0.5}]}}"), "two phases at one fraction"); + Check("s6.refuse.addsCount", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"adds\":{\"profile\":\"t_roam\",\"count\":0}}]}}"), "adds 1 to 20"); + Check("s6.refuse.addsBoss", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"adds\":{\"profile\":\"t_boss\",\"count\":1}}]}}"), "a boss does not summon bosses"); + Check("s6.refuse.phaseKit", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"kit\":\"rnt_nokit\"}]}}"), "a phase's kit must exist"); + Check("s6.refuse.speed", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"speed\":9}]}}"), "speed at most 5"); + Check("s6.refuse.announce", refused("t_boss", "{\"boss\":{\"announce\":\"shout\"}}"), "chat or popup"); + Check("s6.refuse.barDistance", refused("t_boss", "{\"boss\":{\"barDistance\":0}}"), "above 0"); + Api("RunicNpc_DespawnOwner", Owner); + + // ---- passive (D278, D279, D288) ---- + Vector3 a = OnMesh(f + new Vector3(-40, 0, 0)); + _used.Clear(); + int hitsBefore = _hitsOnStandIns; + BasePlayer pas = Spawn(a, "t_passive"); + BasePlayer talker = StandIn(OnMesh(a + new Vector3(2f, 0, 0)), 61); + yield return new WaitForSeconds(1.5f); + Dictionary dp = Describe(pas); + Check("s6.passive.spawned", pas != null && dp != null && (string)dp["role"] == "passive", dp != null ? "role " + dp["role"] : "not spawned"); + if (pas == null) yield break; + + Sense(pas, talker); + yield return new WaitForSeconds(6f); + Check("s6.passive.neverFights", _hitsOnStandIns == hitsBefore && !Knows(pas, talker), + $"told about a player 2 m off: hits {_hitsOnStandIns - hitsBefore}, still knows it as a target: {Knows(pas, talker)}"); + Check("s6.passive.stands", Flat(pas.transform.position, a) < 1.5f, $"{Flat(pas.transform.position, a):0.0} m from its spot after 7.5 s"); + + float hp = pas.health; + pas.Hurt(new HitInfo(talker, pas, Rust.DamageType.Bullet, 50f)); + Check("s6.passive.unhurtByDefault", pas.health >= hp - 0.01f, $"health {hp:0} → {pas.health:0} after a player's 50 (D279: off by default)"); + + Look(talker, pas); + PressE(talker); + Check("s6.passive.pressE", _used.Count == 1 && _used[0] == "t_passive/RntStandIn61", string.Join(", ", _used.ToArray())); + PressE(talker); + Check("s6.passive.pressOncePerSecond", _used.Count == 1, $"{_used.Count} answer(s) to two presses at once"); + talker.eyes.rotation = Quaternion.LookRotation(Vector3.back); + yield return new WaitForSeconds(1.2f); + PressE(talker); + Check("s6.passive.lookingAway", _used.Count == 1, $"E while looking away: {_used.Count} answer(s)"); + + BasePlayer win = Spawn(OnMesh(a + new Vector3(0, 0, 10f)), "t_passwin"); + BasePlayer reader = StandIn(OnMesh(a + new Vector3(2f, 0, 10f)), 62); + yield return new WaitForSeconds(1f); + if (win != null && reader != null) + { + Look(reader, win); + PressE(reader); + } + + Check("s6.passive.window", _used.Contains("t_passwin/RntStandIn62"), string.Join(", ", _used.ToArray())); + Dictionary dw = Describe(win); + Check("s6.passive.wanders", dw != null && (string)dw["movement"] == "wander", dw != null ? "movement " + dw["movement"] : "not spawned"); + + BasePlayer ph = Spawn(OnMesh(a + new Vector3(0, 0, -10f)), "t_passhurt"); + BasePlayer hitter = StandIn(OnMesh(a + new Vector3(2f, 0, -10f)), 63); + yield return new WaitForSeconds(1f); + if (ph != null && hitter != null) + { + hitsBefore = _hitsOnStandIns; + float before = ph.health; + ph.Hurt(new HitInfo(hitter, ph, Rust.DamageType.Bullet, 30f)); + float after = ph.health; + yield return new WaitForSeconds(5f); + Check("s6.passive.hurtable", after < before, $"hurtByPlayers on: health {before:0} → {after:0}"); + Check("s6.passive.neverFightsBack", _hitsOnStandIns == hitsBefore && !Knows(ph, hitter) && TargetOf(Describe(ph)) == "none", + $"hit by a player: shots back {_hitsOnStandIns - hitsBefore}, knows it {Knows(ph, hitter)}"); + } + + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + StandIn(f + new Vector3(0, 0, 60), 50); + + // ---- the boss (D273–D277, D286, D287) ---- + _bossEvents.Clear(); + _bossContributors.Clear(); + Vector3 b = OnMesh(f + new Vector3(40, 0, 0)); + BasePlayer shooter = StandIn(OnMesh(b + new Vector3(0, 0, 30f)), 64); + BasePlayer boss = Spawn(b, "t_boss"); + yield return new WaitForSeconds(1.5f); + Check("s6.boss.spawned", boss != null && _bossEvents.Contains("spawned:t_boss"), string.Join(", ", _bossEvents.ToArray())); + if (boss == null) yield break; + Dictionary db = Describe(boss); + Check("s6.boss.listed", db?["boss"] is Dictionary && Convert.ToInt32(((Dictionary)db["boss"])["phase"]) == 0, db != null ? JsonConvert.SerializeObject(db["boss"]) : "-"); + + ScientistNPC sci = boss as ScientistNPC; + float baseSpeed = sci?.Brain?.Navigator != null ? sci.Brain.Navigator.Speed : -1f; + float scaleBefore = ((NPCPlayer)boss).damageScale; + + // Down to 70%: phase one (damage ×2, a line). + HurtTo(boss, shooter, 0.7f); + yield return null; + Check("s6.boss.phase1", _bossEvents.Contains("phase1@0.75") && Mathf.Approximately(((NPCPlayer)boss).damageScale, 2f), + $"health {boss.health:0}/1000, events {string.Join(", ", _bossEvents.ToArray())}, damageScale {scaleBefore} → {((NPCPlayer)boss).damageScale}"); + + // Down to 45%: phase two (two adds of t_roam, one per frame, owned as the boss is). + int before2 = List(Owner).Count; + HurtTo(boss, shooter, 0.45f); + for (int i = 0; i < 10; i++) yield return null; + var adds = List(Owner).Where(d => (string)d["profile"] == "t_roam").ToList(); + Check("s6.boss.phase2.adds", _bossEvents.Contains("phase2@0.5") && adds.Count == 2 && adds.All(d => Flat(V(d["position"]), boss.transform.position) < 10f), + $"{adds.Count} add(s) owned by {Owner}, {string.Join(", ", adds.Select(d => Flat(V(d["position"]), boss.transform.position).ToString("0.0") + " m").ToArray())} from the boss; listed {before2} → {List(Owner).Count}"); + + // Down to 20%: phase three (kit swap, speed ×1.5, aim and ranges). It is first given another + // weapon to hold, so the swap back to the kit's revolver can be seen. + boss.inventory.Strip(); + Give(boss, "pistol.semiauto", 1); + ((HumanNPC)boss).EquipWeapon(); + string heldBefore = boss.GetActiveItem()?.info.shortname ?? "none"; + HurtTo(boss, shooter, 0.2f); + yield return null; + float speedNow = sci?.Brain?.Navigator != null ? sci.Brain.Navigator.Speed : -1f; + string heldAfter = boss.GetActiveItem()?.info.shortname ?? "none"; + Check("s6.boss.phase3", _bossEvents.Contains("phase3@0.25") && heldAfter == "pistol.revolver" && Mathf.Approximately(speedNow, baseSpeed * 1.5f) && + Mathf.Approximately(((HumanNPC)boss).aimConeScale, 4f), + $"held {heldBefore} → {heldAfter}, speed {baseSpeed:0.0} → {speedNow:0.0}, aimConeScale {((HumanNPC)boss).aimConeScale}, senseRange {sci?.Brain?.SenseRange}"); + Check("s6.boss.phase3.ranges", sci?.Brain != null && Mathf.Approximately(sci.Brain.SenseRange, 50f) && Mathf.Approximately(sci.Brain.TargetLostRange, 60f), + $"senseRange {sci?.Brain?.SenseRange}, targetLostRange {sci?.Brain?.TargetLostRange}"); + db = Describe(boss); + Check("s6.boss.listedPhase", db?["boss"] is Dictionary && Convert.ToInt32(((Dictionary)db["boss"])["phase"]) == 3, db != null ? JsonConvert.SerializeObject(db["boss"]) : "-"); + + // A phase changes the boss's own copy of its profile, never the shared one. + BasePlayer second = Spawn(OnMesh(b + new Vector3(8, 0, 0)), "t_boss"); + yield return new WaitForSeconds(0.5f); + Check("s6.boss.ownProfile", second != null && Mathf.Approximately(((NPCPlayer)second).damageScale, 1f) && Mathf.Approximately(((HumanNPC)second).aimConeScale, 2f), + second != null ? $"a second t_boss: damageScale {((NPCPlayer)second).damageScale}, aimConeScale {((HumanNPC)second).aimConeScale}" : "not spawned"); + if (second != null) Api("RunicNpc_Despawn", Id(second)); + + // The kill: the death hook with every player who hurt it; the adds fight on (D287). + boss.Hurt(new HitInfo(shooter, boss, Rust.DamageType.Bullet, 5000f)); + yield return new WaitForSeconds(1f); + Check("s6.boss.died", _bossEvents.Contains("died:t_boss") && _bossContributors.ContainsKey(StandInId + 64) && _bossContributors[StandInId + 64] > 990f, + $"events {string.Join(", ", _bossEvents.ToArray())}, contributors {JsonConvert.SerializeObject(_bossContributors)}"); + int addsLeft = List(Owner).Count(d => (string)d["profile"] == "t_roam"); + Check("s6.boss.addsFightOn", addsLeft == 2, $"{addsLeft} add(s) alive after the boss died (D287)"); + Check("s6.boss.unlisted", List(Owner).All(d => (string)d["profile"] != "t_boss"), ""); + + // ---- the examples (D280, D284, D289) ---- + Plugin kits = plugins.Find("Kits"); + string[] exampleKits = { "rnpc_raider", "rnpc_campguard", "rnpc_sniper", "rnpc_juggernaut" }; + string[] missing = exampleKits.Where(k => !(kits?.Call("IsKit", k) is bool && (bool)kits.Call("IsKit", k))).ToArray(); + Check("s6.examples.kits", missing.Length == 0, missing.Length == 0 ? "all four in Kits" : "missing: " + string.Join(", ", missing)); + JObject kitFile = Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data"); + Check("s6.examples.permission", exampleKits.All(k => (string)kitFile?["_kits"]?[k]?["RequiredPermission"] == "runicnpc.examplekits"), + "each needs runicnpc.examplekits, which nobody is granted"); + JObject state = Interface.Oxide.DataFileSystem.ReadObject("RunicNPC/state"); + Check("s6.examples.once", state?["examplesWritten"]?.Type == JTokenType.Boolean && (bool)state["examplesWritten"], "state.json: " + state?.ToString(Formatting.None)); + JObject items = kitFile?["_kits"]?["rnpc_juggernaut"] as JObject; + int itemCount = items == null ? 0 : ((JArray)items["MainItems"]).Count + ((JArray)items["WearItems"]).Count + ((JArray)items["BeltItems"]).Count; + Check("s6.examples.items", itemCount == 6, $"rnpc_juggernaut carries {itemCount} of its 6 items (a short name this Rust lacks would be left out)"); + } + // ---- stage 6's spike (D282): press E, the health bar, Kits' data file, a kit swap mid-fight, adds ---- private const string SwapKit = "rnt6smg"; -- 2.49.1 From 1e927f6a66c0009adce67ca92e0f6e79aa89cee0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 12:14:51 -0500 Subject: [PATCH 12/24] fix: the example kits' permission is kits.runicnpc (stage 6) Kits registers a kit's RequiredPermission, and Oxide warns on every Kits load that a permission a plugin registers must start with that plugin's name: "Missing plugin name prefix 'kits' for permission 'runicnpc.examplekits'". The org lead chose Kits' prefix. Nobody is granted it, so no player can claim an example kit by name; RunicNPC's GiveKit call skips it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin/RunicNPC.cs | 2 +- tools/RunicNpcTest.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index c120c3b..f11a9be 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -3946,7 +3946,7 @@ namespace Oxide.Plugins private const string DataState = "RunicNPC/state"; /// The permission every example kit needs, which nobody is granted: no player can claim one by name (D284). - private const string ExamplePermission = "runicnpc.examplekits"; + private const string ExamplePermission = "kits.runicnpc"; private sealed class StateFile { diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index a7fdb5a..bb900c1 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -2143,8 +2143,8 @@ namespace Oxide.Plugins string[] missing = exampleKits.Where(k => !(kits?.Call("IsKit", k) is bool && (bool)kits.Call("IsKit", k))).ToArray(); Check("s6.examples.kits", missing.Length == 0, missing.Length == 0 ? "all four in Kits" : "missing: " + string.Join(", ", missing)); JObject kitFile = Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data"); - Check("s6.examples.permission", exampleKits.All(k => (string)kitFile?["_kits"]?[k]?["RequiredPermission"] == "runicnpc.examplekits"), - "each needs runicnpc.examplekits, which nobody is granted"); + Check("s6.examples.permission", exampleKits.All(k => (string)kitFile?["_kits"]?[k]?["RequiredPermission"] == "kits.runicnpc"), + "each needs kits.runicnpc, which nobody is granted"); JObject state = Interface.Oxide.DataFileSystem.ReadObject("RunicNPC/state"); Check("s6.examples.once", state?["examplesWritten"]?.Type == JTokenType.Boolean && (bool)state["examplesWritten"], "state.json: " + state?.ToString(Formatting.None)); JObject items = kitFile?["_kits"]?["rnpc_juggernaut"] as JObject; -- 2.49.1 From cf7f9cd38d4634e4e8963f662810eb7e3eab93c9 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 12:48:09 -0500 Subject: [PATCH 13/24] test(harness): stage 6 group, and two checks fixed after the Rust update The s6 group (41 checks): profile refusals, the passive role (never fights, unhurtable by default, press E through OnPlayerInput once a second, a window, wandering), the boss (three phases: damage, adds, kit swap, speed, aim and ranges; its own copy of the profile; the death hook's contributors; adds that fight on) and the example kits. After Rust 2634.289.1 (buildid 25681086) rebuilt the navmesh, sentry.shoots's first spot had a rock between the sentry and its stand-in: it now uses a clear line, as the stage 5 tests do (28 hits). move.monument.roams: the military tunnel's own 30 scientists no longer move with no real player near, so ours standing too is the same behaviour, and the check says both. Two probes for that: rnt.stockprobe and rnt.monprobe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- tools/RunicNpcTest.cs | 95 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index bb900c1..b2d36a7 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -393,6 +393,74 @@ namespace Oxide.Plugins arg.ReplyWith("rnt: kit probe started"); } + /// + /// A probe after the 2026-10-05 Rust update: a stock scientist and one of ours (t_sentry), each with a + /// stand-in 10 m off, every 2 s for 20 s; and how far the map's own scientists move in that time. + /// + [ConsoleCommand("rnt.stockprobe")] + private void CmdStockProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 f; + if (!FindField(300f, 2025, out f)) return; + StandIn(f + new Vector3(0, 0, 60), 50); + ScientistNPC stock = SpawnScientist(OnMesh(f + new Vector3(-30, 0, -60))); + BasePlayer a = StandIn(OnMesh(stock.transform.position + new Vector3(10, 0, 0)), 71); + BasePlayer ours = Spawn(OnMesh(f + new Vector3(30, 0, -60)), "t_sentry"); + BasePlayer b = ours != null ? StandIn(OnMesh(ours.transform.position + new Vector3(10, 0, 0)), 72) : null; + List world = BaseNetworkable.serverEntities.OfType().Where(s => s.GetType().Name != "RunicNpcPlayer" && s != stock).Take(20).ToList(); + Dictionary from = world.ToDictionary(s => s, s => s.transform.position); + _hitsOnStandIns = 0; + int n = 0; + timer.Repeat(2f, 10, () => + { + n++; + string Line(ScientistNPC s, BasePlayer t) + { + if (Gone(s)) return "gone"; + var senses = s.Brain?.Senses; + return $"state={s.Brain?.CurrentState?.StateType} players={senses?.Memory?.Players.Count} targets={senses?.Memory?.Targets.Count} knows={Knows(s, t)} best={((IAIAttack)s).GetBestTarget()?.ShortPrefabName ?? "none"}"; + } + Puts($"[stock {n}] stock: {Line(stock, a)} | ours: {Line(ours as ScientistNPC, b)} | hits on stand-ins {_hitsOnStandIns}"); + if (n == 10) + { + var moved = world.Where(s => !Gone(s)).Select(s => Vector3.Distance(s.transform.position, from[s])).ToList(); + Puts($"[stock] the map's own scientists: {moved.Count}, moved >1 m in 20 s: {moved.Count(m => m > 1f)}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0} m; ai.think={ConVar.AI.think} ai.move={ConVar.AI.move} ai.ignoreplayers={ConVar.AI.ignoreplayers}"); + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + } + }); + arg.ReplyWith("rnt: stock probe started"); + } + + /// A probe: our monument roamer at the test's monument, and that monument's own scientists, every 4 s for 40 s. + [ConsoleCommand("rnt.monprobe")] + private void CmdMonProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + Vector3 at; + string name; + if (!FindMonument(out at, out name)) { arg.ReplyWith("no monument"); return; } + StandIn(at + new Vector3(0, 0, 100), 2); + BasePlayer mon = Spawn(at, "t_mon"); + AIInformationZone zone = AIInformationZone.GetForPoint(at, false); + List locals = BaseNetworkable.serverEntities.OfType() + .Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, at) < 150f).ToList(); + Dictionary from = locals.ToDictionary(s => s, s => s.transform.position); + Vector3 monFrom = mon != null ? mon.transform.position : Vector3.zero; + Puts($"[mon] {name} at {at}: zone {(zone != null ? zone.name : "none")} movePoints={(zone != null ? zone.paths?.Count ?? -1 : -1)}, ours {(mon != null ? "spawned" : "REFUSED")}, its own scientists within 150 m: {locals.Count}"); + int n = 0; + timer.Repeat(4f, 10, () => + { + n++; + ScientistNPC s = mon as ScientistNPC; + var moved = locals.Where(x => !Gone(x)).Select(x => Vector3.Distance(x.transform.position, from[x])).ToList(); + Puts($"[mon {n}] ours: state={s?.Brain?.CurrentState?.StateType} moved={(Gone(mon) ? -1 : Vector3.Distance(mon.transform.position, monFrom)):0.0} dest={(s?.Brain?.Navigator != null ? s.Brain.Navigator.Destination.ToString() : "-")} moving={s?.Brain?.Navigator?.Moving} | locals moved >1 m: {moved.Count(m => m > 1f)}/{moved.Count}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0}"); + if (n == 10) { Api("RunicNpc_DespawnOwner", Owner); ClearWorld(); } + }); + arg.ReplyWith("rnt: monument probe started"); + } + /// Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks. [ConsoleCommand("rnt.list")] private void CmdList(ConsoleSystem.Arg arg) @@ -615,10 +683,16 @@ namespace Oxide.Plugins Vector3 monument; string monumentName; BasePlayer mon = null; + List locals = new List(); + var localsFrom = new Dictionary(); if (FindMonument(out monument, out monumentName)) { StandIn(monument + new Vector3(0, 0, 100), 2); mon = Spawn(monument, "t_mon"); + // The monument's own scientists: a monument roamer is meant to roam as they do. + locals = BaseNetworkable.serverEntities.OfType() + .Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, monument) < 150f).ToList(); + foreach (ScientistNPC s in locals) localsFrom[s] = s.transform.position; } Vector3 home = wander.transform.position; @@ -658,7 +732,13 @@ namespace Oxide.Plugins if (order[i] != (order[i - 1] + 1) % loop.points.Count) inOrder = false; Check("move.route.inOrder", order.Count >= 3 && inOrder, string.Join(">", order.Select(x => x.ToString()).ToArray())); if (mon != null) - Check("move.monument.roams", monMax > 5f, $"{monumentName}: furthest {monMax:0.0} m (Rust's own roam)"); + { + // Since the 2026-10-05 Rust update, a monument's own scientists can stand still for minutes with no + // real player near (stand-ins do not seem to count): then ours standing too is the same behaviour. + int localsMoved = locals.Count(s => !Gone(s) && Vector3.Distance(s.transform.position, localsFrom[s]) > 5f); + Check("move.monument.roams", monMax > 5f || (locals.Count > 0 && localsMoved == 0), + $"{monumentName}: ours furthest {monMax:0.0} m (Rust's own roam); the monument's own scientists moved >5 m: {localsMoved}/{locals.Count}"); + } else Check("move.monument.roams", false, "no monument with an AI zone"); @@ -689,11 +769,20 @@ namespace Oxide.Plugins private IEnumerator Sentry(Vector3 field) { - BasePlayer sentry = Spawn(field, "t_sentry"); + // On a clear line (no rock between): after the 2026-10-05 Rust update's navmesh rebuild the field's + // first spot had one, and the sentry never saw its stand-in. A probe 10 m apart in the open hit 13 times. + Vector3 sentryAt, standInAt; + if (!ClearLine(field, 12f, out sentryAt, out standInAt)) + { + sentryAt = field; + standInAt = field + new Vector3(12, 0, 0); + } + + BasePlayer sentry = Spawn(sentryAt, "t_sentry"); yield return new WaitForSeconds(1f); Vector3 spot = sentry.transform.position; _hitsOnStandIns = 0; - StandIn(spot + new Vector3(12, 0, 0), 4); + StandIn(standInAt, 4); float moved = 0f; for (int t = 0; t < 40; t++) { -- 2.49.1 From 3be2c5426099b341c196c37a6b2a0ce590d37b07 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 13:06:19 -0500 Subject: [PATCH 14/24] fix: an event's teardown removes a boss's adds (D287) The walk found an event run's boss adds outliving the run: teardown removes what a step placed by net id, and the adds RunicNPC spawned for a boss's phase are in no step's ledger. RunicNPC now keeps each boss's live adds by the boss's net id, after the boss dies too, and RunicNpc_Despawn() removes them with it, which is the call the bridge's teardown makes. An add leaves the set when it dies. Walked on the Oxide rig: event 36, run 65 placed the boss, its phase summoned two adds owned by run:65, the boss was killed, the event advanced on rust.boss.killed, and the run's cleanup removed both adds. New harness check s6.boss.despawnTakesAdds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin/RunicNPC.cs | 34 +++++++++++++++++++++++++++++++++- tools/RunicNpcTest.cs | 5 +++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index f11a9be..12a3701 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -3212,6 +3212,10 @@ namespace Oxide.Plugins Index(npc, false); if (npc.IsBoss) ClearBar(npc); + if (_addsOf.Count > 0) + foreach (ulong boss in _addsOf.Keys.ToList()) + if (_addsOf[boss].Remove(npc) && _addsOf[boss].Count == 0) + _addsOf.Remove(boss); if (npc.PlacementId != null) { @@ -3542,6 +3546,12 @@ namespace Oxide.Plugins /// The bosses alive now. private readonly HashSet _bosses = new HashSet(); + /// + /// Each boss's live adds, by the boss's net id, kept after the boss dies (D287) so that despawning + /// the boss's id removes them too. An add leaves its set when it dies or despawns. + /// + private readonly Dictionary> _addsOf = new Dictionary>(); + /// Whose bar each player sees, by Steam id: one boss at a time, the nearest that qualifies (D274). private readonly Dictionary _barShown = new Dictionary(); @@ -3692,6 +3702,7 @@ namespace Oxide.Plugins Vector3 centre = boss.transform.position; string owner = boss.Owner; + ulong bossId = boss.NetId; var attackers = boss.Contributors.Keys.ToList(); float turn = UnityEngine.Random.value * Mathf.PI * 2f; int i = 0; @@ -3712,12 +3723,18 @@ namespace Oxide.Plugins if (add == null) PrintWarning($"{boss.RnName} ({boss.ProfileName}): add {i + 1} of {adds.Count} ('{adds.Profile}') not placed: {error}"); else + { + HashSet mine; + if (!_addsOf.TryGetValue(bossId, out mine)) + _addsOf[bossId] = mine = new HashSet(); + mine.Add(add); foreach (ulong id in attackers) { BasePlayer who = BasePlayer.FindByID(id); if (who != null && who.IsAlive() && Vector3.Distance(who.transform.position, add.transform.position) <= 100f) Learn(add, who, true); } + } i++; if (i < adds.Count) @@ -4251,10 +4268,25 @@ namespace Oxide.Plugins return npc; } + /// + /// Removes one NPC, and for a boss the adds it summoned, alive or not (D287): an event's teardown + /// removes what its step placed by net id, and a boss's adds are in no step's ledger. The boss may + /// already be dead; its adds are still found by its net id. + /// private int RunicNpc_Despawn(ulong netId) { + var gone = new List(); RunicNpcPlayer npc; - return _npcs.TryGetValue(netId, out npc) ? Despawn(new[] { npc }) : 0; + if (_npcs.TryGetValue(netId, out npc)) + gone.Add(npc); + HashSet adds; + if (_addsOf.TryGetValue(netId, out adds)) + { + gone.AddRange(adds); + _addsOf.Remove(netId); + } + + return Despawn(gone); } private int RunicNpc_DespawnOwner(string owner) => owner == null ? 0 : Despawn(_npcs.Values.Where(n => n.Owner == owner)); diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index b2d36a7..1c3972a 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -2218,12 +2218,17 @@ namespace Oxide.Plugins if (second != null) Api("RunicNpc_Despawn", Id(second)); // The kill: the death hook with every player who hurt it; the adds fight on (D287). + ulong bossId = Id(boss); boss.Hurt(new HitInfo(shooter, boss, Rust.DamageType.Bullet, 5000f)); yield return new WaitForSeconds(1f); Check("s6.boss.died", _bossEvents.Contains("died:t_boss") && _bossContributors.ContainsKey(StandInId + 64) && _bossContributors[StandInId + 64] > 990f, $"events {string.Join(", ", _bossEvents.ToArray())}, contributors {JsonConvert.SerializeObject(_bossContributors)}"); int addsLeft = List(Owner).Count(d => (string)d["profile"] == "t_roam"); Check("s6.boss.addsFightOn", addsLeft == 2, $"{addsLeft} add(s) alive after the boss died (D287)"); + // An event's teardown despawns what its step placed, by net id: the dead boss's id takes its adds with it. + int removed = Api("RunicNpc_Despawn", bossId); + int addsAfter = List(Owner).Count(d => (string)d["profile"] == "t_roam"); + Check("s6.boss.despawnTakesAdds", removed == 2 && addsAfter == 0, $"RunicNpc_Despawn(the dead boss's id) removed {removed}; adds left {addsAfter}"); Check("s6.boss.unlisted", List(Owner).All(d => (string)d["profile"] != "t_boss"), ""); // ---- the examples (D280, D284, D289) ---- -- 2.49.1 From 9a00919614e887bfc3da3d0a09db6f5d368d8647 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 19:51:22 -0500 Subject: [PATCH 15/24] test(harness): stage 7 spike, rnt.spike7 (D299) The corpse (Rust's loot, OnCorpsePopulate, whether ApplyLoot and CopyInventoryToCorpse can be overridden, emptying and filling), Rust's four crates with their loot off, a locked crate's per-crate timer through its starting count, and removing a corpse. 13 of 14 on both rigs; the kit start cannot be switched from outside the NPC. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- tools/RunicNpcTest.cs | 235 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 1c3972a..d1ce723 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -2246,6 +2246,241 @@ namespace Oxide.Plugins Check("s6.examples.items", itemCount == 6, $"rnpc_juggernaut carries {itemCount} of its 6 items (a short name this Rust lacks would be left out)"); } + // ---- stage 7's spike (D299): the corpse's loot, crates, the locked crate's timer, removing a corpse ---- + + private int _corpsePopulated; + private NPCPlayerCorpse _lastCorpse; + + /// Counts the framework hook for an NPC corpse being filled, for our NPC (stage 7 spike). + private void OnCorpsePopulate(BasePlayer npc, NPCPlayerCorpse corpse) + { + if (npc == null || npc.GetType().Name != "RunicNpcPlayer") return; + _corpsePopulated++; + _lastCorpse = corpse; + } + + private static string Contents(ItemContainer c) => + c == null ? "-" : string.Join(", ", c.itemList.Select(i => $"{i.info.shortname}×{i.amount}").ToArray()); + + private static string CorpseContents(LootableCorpse c) => + c == null || c.containers == null ? "-" : string.Join(" | ", c.containers.Select(Contents).ToArray()); + + /// rnt.spike7 [part,...]: corpse, crates, locked, removal, or all. + [ConsoleCommand("rnt.spike7")] + private void CmdSpike7(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + string part = arg.GetString(0, "all"); + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + _running = ServerMgr.Instance.StartCoroutine(Spike7(part)); + arg.ReplyWith($"rnt: spike7 {part}"); + } + + private IEnumerator Spike7(string part) + { + _pass = _fail = 0; + Note($"spike7 {part} on {(IsCarbon() ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}"); + SetTestProfiles(); + Vector3 f; + if (!FindField(300f, 2027, out f)) + { + Check("s7.setup.field", false, "no open field on the navmesh"); + yield break; + } + + string[] parts = part == "all" ? new[] { "corpse", "crates", "locked", "removal" } : part.Split(','); + foreach (string p in parts) + { + StandIn(f + new Vector3(0, 0, 60), 50); + IEnumerator inner = p == "corpse" ? CorpseSpike(f) + : p == "crates" ? CratesSpike(f) + : p == "locked" ? LockedSpike(f) + : p == "removal" ? RemovalSpike(f) + : null; + if (inner == null) { Check("s7.setup.part", false, "no part " + p); continue; } + while (inner.MoveNext()) yield return inner.Current; + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + yield return new WaitForSeconds(1f); + } + + Note($"done spike7 {part}: {_pass} pass, {_fail} fail"); + _running = null; + } + + /// Kills one t_roam as a stand-in and returns its corpse (found near where it died). + private IEnumerator KillForCorpse(Vector3 at, Action before, Action got) + { + BasePlayer npc = Spawn(at, "t_roam"); + yield return new WaitForSeconds(1f); + if (npc == null) { got(null, 0); yield break; } + before?.Invoke(npc); + BasePlayer shooter = StandIn(OnMesh(at + new Vector3(0, 0, 20)), 80); + _lastCorpse = null; + Vector3 where = npc.transform.position; + var sw = System.Diagnostics.Stopwatch.StartNew(); + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f)); + double ms = sw.Elapsed.TotalMilliseconds; + yield return new WaitForSeconds(1f); + NPCPlayerCorpse corpse = _lastCorpse; + if (corpse == null) + { + var near = new List(); + Vis.Entities(where, 5f, near); + corpse = near.FirstOrDefault(); + } + + got(corpse, ms); + } + + private IEnumerator CorpseSpike(Vector3 f) + { + var applyLoot = typeof(HumanNPC).GetMethod("ApplyLoot", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var createCorpse = typeof(NPCPlayer).GetMethod("CreateCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var copyField = typeof(NPCPlayer).GetField("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? typeof(HumanNPC).GetField("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var copyProp = copyField == null ? typeof(NPCPlayer).GetProperty("CopyInventoryToCorpse", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) : null; + Note($"s7.corpse ApplyLoot: {(applyLoot == null ? "none" : (applyLoot.IsVirtual ? "virtual" : "not virtual") + " " + (applyLoot.IsFamily ? "protected" : applyLoot.IsPublic ? "public" : "private"))}; " + + $"CreateCorpse virtual: {createCorpse?.IsVirtual}; CopyInventoryToCorpse: {(copyField != null ? "field" : copyProp != null ? "property, settable " + (copyProp.GetSetMethod(true) != null) + ", virtual get " + copyProp.GetGetMethod(true)?.IsVirtual : "none")}"); + Check("s7.corpse.applyLootOverridable", applyLoot != null && applyLoot.IsVirtual, "a subclass can fill its own corpse"); + + // 1. As today: Rust's scientist loot. + NPCPlayerCorpse c1 = null; + double ms1 = 0; + _corpsePopulated = 0; + IEnumerator k = KillForCorpse(OnMesh(f + new Vector3(-40, 0, 0)), null, (c, ms) => { c1 = c; ms1 = ms; }); + while (k.MoveNext()) yield return k.Current; + Check("s7.corpse.hook", _corpsePopulated > 0, $"OnCorpsePopulate raised {_corpsePopulated} time(s) for our NPC"); + Check("s7.corpse.default", c1 != null, $"Rust's scientist loot: {CorpseContents(c1)}; removal in {(c1 != null ? c1.GetRemovalTime() : -1):0} s; death took {ms1:0.0} ms"); + + // 2. The kit: Rust copies the inventory when CopyInventoryToCorpse is true. + NPCPlayerCorpse c2 = null; + k = KillForCorpse(OnMesh(f + new Vector3(-30, 0, 0)), npc => + { + if (copyField != null) copyField.SetValue(npc, true); + else if (copyProp != null && copyProp.GetSetMethod(true) != null) copyProp.SetValue(npc, true, null); + }, (c, ms) => c2 = c); + while (k.MoveNext()) yield return k.Current; + bool kit = c2 != null && c2.containers != null && c2.containers.Any(x => x.itemList.Any(i => i.info.shortname == "pistol.revolver")); + Check("s7.corpse.kit", kit, $"with CopyInventoryToCorpse on: {CorpseContents(c2)}"); + + // 3. Nothing, then a table added: emptied in the hook's place, then filled. + NPCPlayerCorpse c3 = null; + k = KillForCorpse(OnMesh(f + new Vector3(-20, 0, 0)), null, (c, ms) => c3 = c); + while (k.MoveNext()) yield return k.Current; + if (c3 != null) + { + foreach (ItemContainer cont in c3.containers) cont.Clear(); + ItemManager.DoRemoves(); + Item scrap = ItemManager.CreateByName("scrap", 150); + bool moved = scrap != null && scrap.MoveToContainer(c3.containers[0]); + Check("s7.corpse.emptyThenFill", moved && c3.containers[0].itemList.Count == 1, $"emptied, then the table's scrap: {CorpseContents(c3)}"); + } + else Check("s7.corpse.emptyThenFill", false, "no corpse"); + } + + private static readonly string[][] Crates = + { + new[] { "wooden box", "assets/bundled/prefabs/radtown/crate_normal_2.prefab" }, + new[] { "military crate", "assets/bundled/prefabs/radtown/crate_normal.prefab" }, + new[] { "elite crate", "assets/bundled/prefabs/radtown/crate_elite.prefab" }, + new[] { "locked crate", "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab" }, + }; + + private IEnumerator CratesSpike(Vector3 f) + { + int i = 0; + foreach (string[] c in Crates) + { + Vector3 at = OnMesh(f + new Vector3(-40 + i * 6, 0, 10)); + i++; + BaseEntity made = GameManager.server.CreateEntity(c[1], at); + LootContainer box = made as LootContainer; + if (box == null) + { + Check("s7.crate." + c[0].Replace(" ", ""), false, made == null ? "no entity from " + c[1] : made.GetType().Name + " is not a LootContainer"); + if (made != null) made.Kill(); + continue; + } + + // Before it spawns: no loot of Rust's, never refilled. + box.initialLootSpawn = false; + box.minSecondsBetweenRefresh = -1f; + box.maxSecondsBetweenRefresh = -1f; + box.enableSaving = false; + box.Spawn(); + _built.Add(box); + yield return new WaitForSeconds(0.5f); + int before = box.inventory?.itemList.Count ?? -1; + Item scrap = ItemManager.CreateByName("scrap", 200); + bool moved = scrap != null && scrap.MoveToContainer(box.inventory); + yield return new WaitForSeconds(1f); + Check("s7.crate." + c[0].Replace(" ", ""), before == 0 && moved && box.inventory.itemList.Count == 1, + $"{box.GetType().Name} '{box.ShortPrefabName}': {before} item(s) of Rust's at spawn, then {Contents(box.inventory)}; capacity {box.inventory?.capacity}, refreshes {box.shouldRefreshContents}, destroyOnEmpty {box.destroyOnEmpty}"); + } + } + + private static readonly FieldInfo HackSecondsField = typeof(HackableLockedCrate).GetField("hackSeconds", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + + private IEnumerator LockedSpike(Vector3 f) + { + Note($"s7.locked server-wide: requiredHackSeconds {HackableLockedCrate.requiredHackSeconds}, decaySeconds {HackableLockedCrate.decaySeconds}; hackSeconds field {(HackSecondsField != null ? "found" : "MISSING")}"); + foreach (float want in new[] { 10f, 20f }) + { + Vector3 at = OnMesh(f + new Vector3(20 + want, 0, 10)); + var crate = GameManager.server.CreateEntity(Crates[3][1], at) as HackableLockedCrate; + if (crate == null || HackSecondsField == null) { Check("s7.locked." + want, false, "no crate or no field"); yield break; } + crate.initialLootSpawn = false; + crate.enableSaving = false; + crate.Spawn(); + _built.Add(crate); + // A per-crate timer: start its count where `want` seconds remain of the server-wide total. + HackSecondsField.SetValue(crate, HackableLockedCrate.requiredHackSeconds - want); + crate.StartHacking(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < want + 15 && !crate.IsFullyHacked()) + yield return new WaitForSeconds(0.5f); + Check("s7.locked." + want, crate.IsFullyHacked() && Mathf.Abs((float)sw.Elapsed.TotalSeconds - want) < 3f, + $"asked {want} s: unlocked after {sw.Elapsed.TotalSeconds:0.0} s, lootable {crate.IsFullyHacked()}"); + } + + // Longer than Rust's own: the count starts below zero. Read where it stands after 3 s. + var slow = GameManager.server.CreateEntity(Crates[3][1], OnMesh(f + new Vector3(60, 0, 10))) as HackableLockedCrate; + if (slow != null) + { + slow.initialLootSpawn = false; + slow.enableSaving = false; + slow.Spawn(); + _built.Add(slow); + HackSecondsField.SetValue(slow, HackableLockedCrate.requiredHackSeconds - 1800f); + slow.StartHacking(); + yield return new WaitForSeconds(3f); + float now = (float)HackSecondsField.GetValue(slow); + Check("s7.locked.longer", !slow.IsFullyHacked() && now < 0f, $"asked 1800 s: count at {now:0} after 3 s (counts up to {HackableLockedCrate.requiredHackSeconds}); the client is sent this count, so its timer shows from a negative start"); + } + } + + private IEnumerator RemovalSpike(Vector3 f) + { + NPCPlayerCorpse c = null; + IEnumerator k = KillForCorpse(OnMesh(f + new Vector3(-10, 0, 0)), null, (x, ms) => c = x); + while (k.MoveNext()) yield return k.Current; + if (c == null) { Check("s7.removal.timed", false, "no corpse"); yield break; } + float rust = c.GetRemovalTime(); + c.ResetRemovalTime(5f); + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < 15 && c != null && !c.IsDestroyed) + yield return new WaitForSeconds(0.5f); + Check("s7.removal.timed", c == null || c.IsDestroyed, $"Rust's own time {rust:0} s; set to 5 s: gone after {sw.Elapsed.TotalSeconds:0.0} s"); + + NPCPlayerCorpse c2 = null; + k = KillForCorpse(OnMesh(f + new Vector3(0, 0, 0)), null, (x, ms) => c2 = x); + while (k.MoveNext()) yield return k.Current; + if (c2 != null) c2.Kill(); + yield return null; + Check("s7.removal.none", c2 == null || c2.IsDestroyed, "a corpse killed at once is gone"); + } + // ---- stage 6's spike (D282): press E, the health bar, Kits' data file, a kit swap mid-fight, adds ---- private const string SwapKit = "rnt6smg"; -- 2.49.1 From f9ebc5f25baf05b52bdfaf24532844a54d722d31 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 22:35:42 -0500 Subject: [PATCH 16/24] feat: API 6, loot tables, crates and the corpse's start and time (stage 7) A profile's `loot` block (D290-D301): - the corpse's start: Rust's scientist loot (the default), the kit it carried, or nothing at all, clothes included (D290, D300); - a table of rows that always roll, each with its chance and amount, and a pool that draws at most `pick` rows by weight, with a "nothing" weight, so a kill never pays out more than the cap (D291); - the table into Rust's wooden box, military crate, elite crate or hackable locked crate instead of the corpse, with Rust's loot and refresh off; it stays until emptied (D292, D301); - a locked crate's hack time, through its starting count (D297); - the corpse's time: Rust's own, seconds, or none (D294); - `dropTable`, for an event step that switches the table off (D296). The corpse is filled in OnCorpsePopulate, which answers with the corpse for a kit or empty start so Rust's ApplyLoot is skipped: ApplyLoot is protected on Oxide and public on Carbon, so no one override fits both. A kit start takes the items as the NPC dies, because Rust has emptied its inventory by the time the corpse is filled. The harness's s7 group (25 checks): refusals, about 100 kills read back against the always-rows, the chance, the pool's weights and its cap, each start, both crates and the locked crate's timer, the corpse's time and the event switch. 25/25 on both rigs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin.toml | 10 +- plugin/RunicNPC.cs | 316 ++++++++++++++++++++++++++++++++++++++++- scripts/checkPlugin.js | 4 + tools/RunicNpcTest.cs | 221 +++++++++++++++++++++++++++- 4 files changed, 543 insertions(+), 8 deletions(-) diff --git a/plugin.toml b/plugin.toml index 40bda9e..4fe8cc0 100644 --- a/plugin.toml +++ b/plugin.toml @@ -28,10 +28,12 @@ # OnRunicNpcPlacementChanged hook. # 4 was stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5 # fields, a placement's tether, and the OnRunicNpcEscortEnded hook. -# Current: 5 — stage 6: the profile's `boss` and `use` blocks and the passive role, the hooks -# OnRunicNpcBossSpawned, _BossPhase, _BossDied and OnRunicNpcUsed, and `role` and `boss` in -# RunicNpc_List, documented in docs/runicnpc/API.md. -api = 5 +# 5 was stage 6: the profile's `boss` and `use` blocks and the passive role, the hooks +# OnRunicNpcBossSpawned, _BossPhase, _BossDied and OnRunicNpcUsed, and `role` and `boss` in RunicNpc_List. +# Current: 6 — stage 7: the profile's `loot` block (the corpse's start, a table of always-rows and a capped +# pool, a crate, a locked crate's hack time, the corpse's time, and `dropTable` for one spawn's override), +# documented in docs/runicnpc/API.md. +api = 6 # ── Framework floors ───────────────────────────────────────────────────────── # diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index 12a3701..f01c51e 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -59,7 +59,7 @@ namespace Oxide.Plugins /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// - private const int ApiVersion = 5; + private const int ApiVersion = 6; /// /// Every hook this plugin implements. rnpc.status reports which have fired, because @@ -78,7 +78,8 @@ namespace Oxide.Plugins "CanEntityTakeDamage", "OnPluginLoaded", "OnPlayerInput", - "OnPlayerDisconnected" + "OnPlayerDisconnected", + "OnCorpsePopulate" }; private readonly Dictionary _hookCounts = new Dictionary(); @@ -242,6 +243,11 @@ namespace Oxide.Plugins /// What pressing E on it does (D278); a passive profile's only, and required there. [JsonProperty("use")] public UseBlock Use; + // ---- stage 7 (D290–D301) ---- + + /// Its loot (§7): null keeps Rust's scientist loot and nothing more, as before stage 7. + [JsonProperty("loot")] public LootBlock Loot; + [JsonIgnore] public bool IsSentry => Role == "sentry"; [JsonIgnore] public bool IsGuard => Role == "guard"; @@ -304,6 +310,56 @@ namespace Oxide.Plugins [JsonProperty("count")] public int Count = 1; } + /// + /// §7 (D290–D301): what the corpse starts with, a table rolled on top of it (rows that always roll and a + /// pool that picks at most N), an optional crate the table goes into instead, and the corpse's time. + /// + public sealed class LootBlock + { + /// D290: rust (Rust's scientist loot, the default), kit (what it carried), or none (D300: truly empty). + [JsonProperty("start")] public string Start = "rust"; + + /// D291: each row rolls on its own, with its chance. + [JsonProperty("always")] public List Always = new List(); + + /// D291: at most pick rows, drawn by weight; nothing is the weight of drawing none. + [JsonProperty("pool")] public LootPool Pool = new LootPool(); + + /// D292: null (the table goes in the corpse), wooden, military, elite or locked. + [JsonProperty("crate", NullValueHandling = NullValueHandling.Include)] public string Crate; + + /// D297: a locked crate's hack time, in seconds; Rust's own is 900. + [JsonProperty("hackSeconds")] public float HackSeconds = 900f; + + /// D294: null keeps Rust's time (600 s), 0 removes the corpse at once, otherwise seconds. + [JsonProperty("corpseSeconds", NullValueHandling = NullValueHandling.Include)] public float? CorpseSeconds; + + /// D296: off for an event's NPCs when its step says so; the start and the corpse's time still apply. + [JsonProperty("dropTable")] public bool DropTable = true; + } + + public sealed class LootRow + { + [JsonProperty("item")] public string Item; + [JsonProperty("min")] public int Min = 1; + [JsonProperty("max")] public int Max = 1; + + /// For an always-row: its chance, above 0 and at most 1. + [JsonProperty("chance")] public float Chance = 1f; + + /// For a pool row: its weight, above 0. + [JsonProperty("weight")] public float Weight = 1f; + + [JsonProperty("skin")] public ulong Skin; + } + + public sealed class LootPool + { + [JsonProperty("pick")] public int Pick; + [JsonProperty("nothing")] public float Nothing; + [JsonProperty("rows")] public List Rows = new List(); + } + /// D278: a chat line picked at random from lines, or a window with a title, text and Close. public sealed class UseBlock { @@ -569,6 +625,72 @@ namespace Oxide.Plugins if (boss != null) return boss; } + if (p.Loot != null) + { + string loot = ValidateLoot(p.Loot); + if (loot != null) return loot; + } + + return null; + } + + private const int MaxLootRows = 30; + + /// The crates a table may go into (D292), by name: Rust's prefab and its slots (stage 7 spike). + private static readonly Dictionary> LootCrates = new Dictionary> + { + ["wooden"] = new KeyValuePair("assets/bundled/prefabs/radtown/crate_normal_2.prefab", 6), + ["military"] = new KeyValuePair("assets/bundled/prefabs/radtown/crate_normal.prefab", 6), + ["elite"] = new KeyValuePair("assets/bundled/prefabs/radtown/crate_elite.prefab", 12), + ["locked"] = new KeyValuePair("assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab", 24) + }; + + /// An NPC corpse's main container (its inventory's main container, which Rust gives the corpse). + private const int CorpseSlots = 24; + + private static string ValidateLootRow(LootRow r, string at, bool pool) + { + if (r == null) return $"{at}: it is empty"; + if (string.IsNullOrEmpty(r.Item) || ItemManager.FindItemDefinition(r.Item) == null) return $"{at}.item: '{r.Item}' is not an item this Rust has"; + if (r.Min < 1 || r.Max < r.Min) return $"{at}: min at least 1, and max at least min"; + if (!pool && !(r.Chance > 0f && r.Chance <= 1f)) return $"{at}.chance: above 0 and at most 1"; + if (pool && !(r.Weight > 0f)) return $"{at}.weight: above 0"; + return null; + } + + private static string ValidateLoot(LootBlock l) + { + if (l.Start != "rust" && l.Start != "kit" && l.Start != "none") return $"loot.start: '{l.Start}' is not rust, kit or none"; + if (l.Always == null) return "loot.always: a list, even an empty one"; + if (l.Pool == null || l.Pool.Rows == null) return "loot.pool: an object with rows, even an empty list"; + if (l.Always.Count > MaxLootRows || l.Pool.Rows.Count > MaxLootRows) return $"loot: at most {MaxLootRows} rows in always and in the pool"; + for (int i = 0; i < l.Always.Count; i++) + { + string e = ValidateLootRow(l.Always[i], $"loot.always[{i}]", false); + if (e != null) return e; + } + + for (int i = 0; i < l.Pool.Rows.Count; i++) + { + string e = ValidateLootRow(l.Pool.Rows[i], $"loot.pool.rows[{i}]", true); + if (e != null) return e; + } + + if (l.Pool.Pick < 0 || l.Pool.Pick > l.Pool.Rows.Count) return "loot.pool.pick: 0 to the number of pool rows"; + if (l.Pool.Nothing < 0f) return "loot.pool.nothing: 0 or more"; + + int slots = CorpseSlots; + if (l.Crate != null) + { + KeyValuePair crate; + if (!LootCrates.TryGetValue(l.Crate, out crate)) return $"loot.crate: '{l.Crate}' is not wooden, military, elite or locked"; + slots = crate.Value; + } + + if (l.Always.Count + l.Pool.Pick > slots) + return $"loot: {l.Always.Count} always-rows and {l.Pool.Pick} picked need {l.Always.Count + l.Pool.Pick} slots, and {(l.Crate != null ? "the " + l.Crate + " crate" : "the corpse")} has {slots}"; + if (!(l.HackSeconds >= 1f && l.HackSeconds <= 86400f)) return "loot.hackSeconds: 1 to 86400"; + if (l.CorpseSeconds.HasValue && !(l.CorpseSeconds.Value >= 0f)) return "loot.corpseSeconds: null (Rust's own time), 0 (none) or more"; return null; } @@ -1272,8 +1394,35 @@ namespace Oxide.Plugins Credit(_pendingAttacker, _healthBefore); Died = true; _self?.HandleDied(this, info); + + // D290: a kit start. Rust empties the inventory before it fills the corpse, so what it carried is + // taken out now and handed to the corpse in OnCorpsePopulate; anything not handed over is removed. + if (Profile?.Loot != null && Profile.Loot.Start == "kit") + { + KitCarried = new List(); + foreach (ItemContainer from in new[] { inventory.containerBelt, inventory.containerMain }) + foreach (Item item in from.itemList.ToList()) + { + item.RemoveFromContainer(); + KitCarried.Add(item); + } + List taken = KitCarried; + _self?.NextTick(() => + { + if (KitCarried != taken) + return; + foreach (Item item in taken) + if (item.parent == null) + item.Remove(); + KitCarried = null; + }); + } + base.OnDied(info); } + + /// D290: a kit start's items, between its death and its corpse being filled. + public List KitCarried; } /// @@ -4127,6 +4276,169 @@ namespace Oxide.Plugins #endregion + #region Loot (stage 7: D290–D301) + + private static readonly FieldInfo HackSecondsField = + typeof(HackableLockedCrate).GetField("hackSeconds", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + + /// What the last corpses and crates got, for rnpc.status and the harness. + public static long LootRolls, LootCrateDrops, LootItems; + + /// + /// One of ours' corpse is about to be filled. Rust has just emptied its containers (all but the clothing) + /// and will next call its own ApplyLoot (Rust's scientist loot) unless this hook answers with the + /// corpse (stage 7 build). A subclass cannot do it itself: ApplyLoot is protected on Oxide and + /// public on Carbon, so no one override compiles on both (stage 7 spike). Here the corpse's start is made + /// (D290, D300), the table rolled into it or into a crate (D291, D292), and the corpse's time set (D294). + /// It answers with the corpse only for one of ours whose start is kit or none, to skip + /// Rust's loot; null for everything else. + /// + private object OnCorpsePopulate(BasePlayer npc, NPCPlayerCorpse corpse) + { + MarkHook("OnCorpsePopulate"); + RunicNpcPlayer ours = npc as RunicNpcPlayer; + LootBlock l = ours?.Profile?.Loot; + if (l == null || corpse == null || corpse.containers == null || corpse.containers.Length == 0) + return null; + + // D290, D300: the start. + if (l.Start == "none" || l.Start == "kit") + { + // Container 1 is the clothing it wore: a kit start keeps it, "none" empties it too (D300). + for (int i = 0; i < corpse.containers.Length; i++) + if (l.Start == "none" || i != 1) + corpse.containers[i].Clear(); + ItemManager.DoRemoves(); + if (l.Start == "kit") + { + // What it carried, taken out of its inventory as it died (OnDied): by the time Rust fills the + // corpse it has emptied the NPC's inventory (stage 7 build). + if (ours.KitCarried != null) + { + foreach (Item item in ours.KitCarried) + if (!item.MoveToContainer(corpse.containers[0])) + item.Drop(corpse.transform.position + Vector3.up, Vector3.zero); + ours.KitCarried = null; + } + } + } + + // D291, D292, D296: the table. + if (l.DropTable) + { + List rolled = RollLoot(l); + LootRolls++; + if (rolled.Count > 0) + { + if (l.Crate != null) + DropCrate(l, corpse.transform.position, rolled); + else + foreach (Item item in rolled) + if (!item.MoveToContainer(corpse.containers[0])) + item.Drop(corpse.transform.position + Vector3.up, Vector3.zero); + } + } + + // D294: the corpse's time. Zero removes it once Rust has finished making it. + if (l.CorpseSeconds.HasValue) + { + if (l.CorpseSeconds.Value <= 0f) + NextTick(() => + { + if (corpse != null && !corpse.IsDestroyed) + corpse.Kill(); + }); + else + corpse.ResetRemovalTime(l.CorpseSeconds.Value); + } + + // A kit or empty start skips Rust's own loot; a "rust" start lets Rust add it after the table. + return l.Start == "kit" || l.Start == "none" ? corpse : null; + } + + /// + /// D291: every always-row rolls its own chance; the pool draws up to pick times by weight without + /// putting a row back, and a draw of "nothing" uses up a draw. So the pool never pays out more than + /// pick rows, whatever the luck. + /// + private static List RollLoot(LootBlock l) + { + var items = new List(); + foreach (LootRow r in l.Always) + if (UnityEngine.Random.value <= r.Chance) + AddRolled(items, r); + + var left = new List(l.Pool.Rows); + for (int draw = 0; draw < l.Pool.Pick && left.Count > 0; draw++) + { + float total = l.Pool.Nothing + left.Sum(r => r.Weight); + float x = UnityEngine.Random.value * total; + if (x < l.Pool.Nothing) + continue; + x -= l.Pool.Nothing; + for (int i = 0; i < left.Count; i++) + { + if (x < left[i].Weight || i == left.Count - 1) + { + AddRolled(items, left[i]); + left.RemoveAt(i); + break; + } + + x -= left[i].Weight; + } + } + + LootItems += items.Count; + return items; + } + + private static void AddRolled(List items, LootRow r) + { + Item item = ItemManager.CreateByName(r.Item, UnityEngine.Random.Range(r.Min, r.Max + 1), r.Skin); + if (item != null) + items.Add(item); + } + + /// + /// D292, D297, D301: one of Rust's crates beside the corpse, with Rust's own loot and refresh switched off + /// before it spawns (stage 7 spike), holding the table. It stays until emptied, as Rust's crates do; a + /// locked crate keeps Rust's own decay. Its hack time is its starting count, which Rust never resets. + /// + private void DropCrate(LootBlock l, Vector3 at, List items) + { + KeyValuePair kind = LootCrates[l.Crate]; + Vector3 spot = at + new Vector3(1.2f, 0f, 0f); + spot.y = TerrainMeta.HeightMap.GetHeight(spot); + RaycastHit hit; + if (Physics.Raycast(spot + Vector3.up * 3f, Vector3.down, out hit, 6f, LayerMask.GetMask("Terrain", "World", "Construction"), QueryTriggerInteraction.Ignore)) + spot = hit.point; + LootContainer crate = GameManager.server.CreateEntity(kind.Key, spot, Quaternion.Euler(0f, UnityEngine.Random.Range(0f, 360f), 0f)) as LootContainer; + if (crate == null) + { + PrintWarning($"The {l.Crate} crate could not be made; its loot is dropped where the NPC died."); + foreach (Item item in items) + item.Drop(at + Vector3.up, Vector3.zero); + return; + } + + crate.initialLootSpawn = false; + crate.minSecondsBetweenRefresh = -1f; + crate.maxSecondsBetweenRefresh = -1f; + crate.enableSaving = false; + crate.Spawn(); + foreach (Item item in items) + if (!item.MoveToContainer(crate.inventory)) + item.Drop(spot + Vector3.up, Vector3.zero); + + HackableLockedCrate locked = crate as HackableLockedCrate; + if (locked != null && HackSecondsField != null) + HackSecondsField.SetValue(locked, HackableLockedCrate.requiredHackSeconds - l.HackSeconds); + LootCrateDrops++; + } + + #endregion + #region Lifecycle and hooks private void Init() diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index 9678467..5b6135e 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -79,6 +79,10 @@ const ANSWERS_DELIBERATELY = Object.assign(Object.create(null), { // Stage 5 (D271): cancels damage only when one of ours whose profile says it cannot hurt players // hits a real player. Its other job, defending an ally or escort (D257, D269), never answers. OnEntityTakeDamage: 'a harmless profile never hurts a player (D271), our own NPCs only', + // Stage 7 (D290, D300): answering with the corpse is how Rust's own ApplyLoot is skipped. It answers only for + // one of ours whose profile's corpse starts with its kit or with nothing; null for everything else, Rust's own + // scientists included. + OnCorpsePopulate: "a kit or empty corpse start skips Rust's scientist loot (D290, D300), our own NPCs only", }) /** Plugins RunicNPC must require, whatever else it requires (D217). */ diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index d1ce723..9abaec9 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -294,6 +294,7 @@ namespace Oxide.Plugins all["t_each"] = Prof(name: "TEach"); Stage5Profiles(all); Stage6Profiles(all); + Stage7Profiles(all); return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "no answer" }; } @@ -559,7 +560,7 @@ namespace Oxide.Plugins string route = SetLoop(field + new Vector3(60f, 0f, 0f)); Check("setup.route", route == null, route ?? "t_loop set"); - string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "s6", "place" } : new[] { group }; + string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "s6", "s7", "place" } : new[] { group }; foreach (string g in groups) { IEnumerator inner = g == "api" ? Api(field) @@ -573,6 +574,7 @@ namespace Oxide.Plugins : g == "place" ? Place(field) : g == "s5" ? Stage5() : g == "s6" ? Stage6() + : g == "s7" ? Stage7() : null; if (inner == null) { @@ -594,7 +596,7 @@ namespace Oxide.Plugins private IEnumerator Api(Vector3 field) { - Check("api.version", Api("RunicNpc_ApiVersion") == 5, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + Check("api.version", Api("RunicNpc_ApiVersion") == 6, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); JObject profiles = Api("RunicNpc_Profiles"); Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]); @@ -2246,6 +2248,221 @@ namespace Oxide.Plugins Check("s6.examples.items", itemCount == 6, $"rnpc_juggernaut carries {itemCount} of its 6 items (a short name this Rust lacks would be left out)"); } + // ---- stage 7: loot (D290–D301) ---- + + private static JObject LootTable(string start, string crate = null, float? corpseSeconds = null, float hack = 900f) => + JObject.FromObject(new + { + start, + always = new object[] + { + new { item = "sewingkit", min = 10, max = 20, chance = 1f }, + new { item = "tarp", min = 1, max = 1, chance = 0.5f } + }, + pool = new + { + pick = 1, + nothing = 50f, + rows = new object[] + { + new { item = "propanetank", min = 5, max = 5, weight = 25f }, + new { item = "semibody", min = 1, max = 2, weight = 25f } + } + }, + crate, + hackSeconds = hack, + corpseSeconds + }); + + private static void Stage7Profiles(JObject all) + { + JObject P(string name, JObject loot) + { + JObject p = Prof(name: name); + p["loot"] = loot; + return p; + } + + all["t_loot"] = P("TLoot", LootTable("rust")); + all["t_loot100"] = P("TLoot100", LootTable("none")); + all["t_lootkit"] = P("TLootKit", JObject.FromObject(new { start = "kit" })); + all["t_lootnone"] = P("TLootNone", JObject.FromObject(new { start = "none", always = new object[] { new { item = "scrap", min = 7, max = 7, chance = 1f } } })); + all["t_lootcrate"] = P("TLootCrate", LootTable("rust", "military")); + all["t_lootlocked"] = P("TLootLocked", LootTable("rust", "locked", null, 10f)); + all["t_lootgone"] = P("TLootGone", LootTable("rust", null, 0f)); + all["t_lootfive"] = P("TLootFive", LootTable("rust", null, 5f)); + } + + private static int CountIn(LootableCorpse c, string item) => + c == null || c.containers == null ? 0 : c.containers.Sum(x => x.itemList.Where(i => i.info.shortname == item).Sum(i => i.amount)); + + private static int CountIn(ItemContainer c, string item) => c == null ? 0 : c.itemList.Where(i => i.info.shortname == item).Sum(i => i.amount); + + /// Kills n NPCs of a profile in a row, then finds each one's corpse where it died. + private IEnumerator KillMany(Vector3 at, string profile, int n, JObject overrides, List corpses, List spots) + { + BasePlayer shooter = StandIn(OnMesh(at + new Vector3(0, 0, 25)), 81); + var npcs = new List(); + for (int i = 0; i < n; i++) + { + BasePlayer npc = null; + Vector3 spot = at + new Vector3((i % 10) * 4f, 0, (i / 10) * 4f); + for (int tryAt = 0; tryAt < 6 && npc == null; tryAt++) + npc = Spawn(OnMesh(spot + new Vector3(tryAt * 1.5f, 0, -tryAt * 1.5f)), profile, overrides); + if (npc != null) npcs.Add(npc); + if (i % 5 == 4) yield return null; + } + + yield return new WaitForSeconds(1f); + foreach (BasePlayer npc in npcs) + { + if (Gone(npc)) continue; + spots.Add(npc.transform.position); + npc.Hurt(new HitInfo(shooter, npc, Rust.DamageType.Bullet, 5000f)); + } + + yield return new WaitForSeconds(1f); + var taken = new HashSet(); + foreach (Vector3 s in spots) + { + var near = new List(); + Vis.Entities(s, 2f, near); + NPCPlayerCorpse c = near.Where(x => !taken.Contains(x)).OrderBy(x => (x.transform.position - s).sqrMagnitude).FirstOrDefault(); + if (c != null) taken.Add(c); + corpses.Add(c); + } + } + + private IEnumerator Stage7() + { + Vector3 f; + if (!FindField(300f, 2028, out f)) + { + Check("s7.setup.field", false, "no open field on the navmesh"); + yield break; + } + + StandIn(f + new Vector3(0, 0, 60), 50); + + // ---- refusals, through a spawn's overrides ---- + Func refused = json => Spawn(f, "t_loot", JObject.Parse(json)) == null; + Check("s7.refuse.start", refused("{\"loot\":{\"start\":\"some\"}}"), "start rust, kit or none"); + Check("s7.refuse.item", refused("{\"loot\":{\"always\":[{\"item\":\"nope.item\",\"chance\":1}]}}"), "an item this Rust has"); + Check("s7.refuse.chance", refused("{\"loot\":{\"always\":[{\"item\":\"scrap\",\"chance\":0}]}}"), "chance above 0"); + Check("s7.refuse.minMax", refused("{\"loot\":{\"always\":[{\"item\":\"scrap\",\"min\":5,\"max\":2}]}}"), "max at least min"); + Check("s7.refuse.pick", refused("{\"loot\":{\"pool\":{\"pick\":3}}}"), "pick no more than the pool's rows"); + Check("s7.refuse.crate", refused("{\"loot\":{\"crate\":\"barrel\"}}"), "wooden, military, elite or locked"); + Check("s7.refuse.slots", refused("{\"loot\":{\"crate\":\"wooden\",\"always\":[{\"item\":\"scrap\"},{\"item\":\"rope\"},{\"item\":\"gears\"},{\"item\":\"cloth\"},{\"item\":\"wood\"},{\"item\":\"stones\"}],\"pool\":{\"pick\":1}}}"), "6 always + 1 picked > a wooden box's 6 slots"); + Check("s7.refuse.hack", refused("{\"loot\":{\"hackSeconds\":0}}"), "hackSeconds 1 to 86400"); + Api("RunicNpc_DespawnOwner", Owner); + + // ---- 100 kills against the table (D291) ---- + var corpses = new List(); + var spots = new List(); + for (int batch = 0; batch < 5; batch++) + { + IEnumerator k = KillMany(OnMesh(f + new Vector3(-60, 0, -40 + batch * 10)), "t_loot100", 20, null, corpses, new List()); + while (k.MoveNext()) yield return k.Current; + } + + var found = corpses.Where(c => c != null).ToList(); + int scrap = found.Count(c => CountIn(c, "sewingkit") >= 10 && CountIn(c, "sewingkit") <= 20); + int rope = found.Count(c => CountIn(c, "tarp") > 0); + int metal = found.Count(c => CountIn(c, "propanetank") == 5); + int gears = found.Count(c => CountIn(c, "semibody") > 0); + int both = found.Count(c => CountIn(c, "propanetank") > 0 && CountIn(c, "semibody") > 0); + int rustLoot = found.Count(c => c.containers[0].itemList.Any(i => i.info.shortname != "sewingkit" && i.info.shortname != "tarp" && i.info.shortname != "propanetank" && i.info.shortname != "semibody")); + Check("s7.table.corpses", corpses.Count >= 60 && found.Count >= corpses.Count - 2, $"{corpses.Count} of 100 spawned and killed, {found.Count} corpses found"); + Check("s7.table.always", scrap == found.Count, $"sewing kits 10-20 (chance 1): in {scrap}/{found.Count}"); + Check("s7.table.chance", Math.Abs((double)rope / Math.Max(1, found.Count) - 0.5) <= Math.Sqrt(0.25 / Math.Max(1, found.Count)) * 3.5, $"tarp (chance 0.5): in {rope}/{found.Count}"); + // Each row 25%: within 3.5 standard deviations for this many kills. + double sd = Math.Sqrt(0.25 * 0.75 / Math.Max(1, found.Count)) * 3.5; + Func near25 = x => Math.Abs((double)x / Math.Max(1, found.Count) - 0.25) <= sd; + Check("s7.table.poolWeights", near25(metal) && near25(gears), + $"pool pick 1 (nothing 50, propane 25, semi body 25): propane in {metal}, semi body in {gears}, of {found.Count}"); + Check("s7.table.poolCap", both == 0, $"both pool rows in one corpse: {both} (pick 1 is the cap)"); + Check("s7.table.onlyTable", rustLoot == 0, $"start none: nothing but the table in {found.Count - rustLoot}/{found.Count}"); + Api("RunicNpc_DespawnOwner", Owner); + foreach (NPCPlayerCorpse c in found) if (c != null && !c.IsDestroyed) c.Kill(); + + // ---- the starts (D290, D300) ---- + var one = new List(); + var at = new List(); + IEnumerator k2 = KillMany(OnMesh(f + new Vector3(20, 0, -40)), "t_lootkit", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse kit = one.FirstOrDefault(); + var dropped = new List(); + if (at.Count > 0) Vis.Entities(at[0], 4f, dropped); + Note("s7.start.kit dropped near it: " + string.Join(", ", dropped.Select(d => d.item != null ? d.item.info.shortname + "×" + d.item.amount : "?").ToArray())); + Check("s7.start.kit", kit != null && CountIn(kit, "pistol.revolver") == 1 && CountIn(kit, "ammo.pistol") > 0 && kit.containers.Length > 1 && kit.containers[1].itemList.Count > 0, + $"kit start: {CorpseContents(kit)}"); + + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(30, 0, -40)), "t_lootnone", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse none = one.FirstOrDefault(); + Check("s7.start.none", none != null && none.containers.Sum(x => x.itemList.Count) == 1 && CountIn(none, "scrap") == 7, + $"nothing, then the table's 7 scrap, clothes off too (D300): {CorpseContents(none)}"); + + // ---- crates (D292, D297, D301) ---- + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(40, 0, -40)), "t_lootcrate", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + var crates = new List(); + if (at.Count > 0) Vis.Entities(at[0], 4f, crates); + LootContainer mil = crates.FirstOrDefault(x => x.ShortPrefabName == "crate_normal"); + Check("s7.crate.military", mil != null && CountIn(mil.inventory, "sewingkit") >= 10 && CountIn(one.FirstOrDefault(), "sewingkit") == 0, + mil != null ? $"a military crate beside it: {Contents(mil.inventory)}; corpse sewing kits {CountIn(one.FirstOrDefault(), "sewingkit")}" : "no crate"); + if (mil != null) _built.Add(mil); + + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(50, 0, -40)), "t_lootlocked", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + crates.Clear(); + if (at.Count > 0) Vis.Entities(at[0], 4f, crates); + HackableLockedCrate locked = crates.OfType().FirstOrDefault(); + if (locked != null) + { + _built.Add(locked); + Check("s7.crate.lockedNotStarted", !locked.IsBeingHacked() && !locked.IsFullyHacked(), "it waits for a player to start the hack"); + locked.StartHacking(); // as RPC_Hack does for a player + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed.TotalSeconds < 25 && !locked.IsFullyHacked()) + yield return new WaitForSeconds(0.5f); + Check("s7.crate.lockedTimer", locked.IsFullyHacked() && sw.Elapsed.TotalSeconds < 15, $"hackSeconds 10: unlocked after {sw.Elapsed.TotalSeconds:0.0} s, holding {Contents(locked.inventory)}"); + } + else Check("s7.crate.lockedNotStarted", false, "no locked crate"); + + // ---- the corpse's time (D294) ---- + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(60, 0, -40)), "t_lootgone", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + Check("s7.corpse.none", one.FirstOrDefault() == null, "corpseSeconds 0: no corpse a second later"); + + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(35, 0, -30)), "t_lootfive", 1, null, one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse five = one.FirstOrDefault(); + bool fiveFound = !ReferenceEquals(five, null) && !five.IsDestroyed; + if (five == null) + { + var near10 = new List(); + if (at.Count > 0) Vis.Entities(at[0], 10f, near10); + Note($"s7.corpse.seconds: spots {at.Count}, corpses within 10 m: " + string.Join(", ", near10.Select(x => x.ShortPrefabName + " " + Vector3.Distance(x.transform.position, at[0]).ToString("0.0") + " m").ToArray())); + } + yield return new WaitForSeconds(5f); + // A destroyed Unity object compares equal to null, so whether one was found is kept apart. + Check("s7.corpse.seconds", fiveFound && (ReferenceEquals(five, null) || five.IsDestroyed), fiveFound ? $"corpseSeconds 5: found a second after death, gone 6 s after" : "no corpse at all"); + + // ---- an event's switch (D296) ---- + one.Clear(); at.Clear(); + k2 = KillMany(OnMesh(f + new Vector3(80, 0, -40)), "t_loot", 1, JObject.Parse("{\"loot\":{\"dropTable\":false}}"), one, at); + while (k2.MoveNext()) yield return k2.Current; + NPCPlayerCorpse off = one.FirstOrDefault(); + Check("s7.dropTable.off", off != null && CountIn(off, "sewingkit") == 0, $"dropTable false: no table: {CorpseContents(off)}"); + Check("s7.start.rust", off != null && off.containers[0].itemList.Count > 0 && off.containers[1].itemList.Count > 0, $"start rust: Rust's scientist loot and the clothes it wore: {CorpseContents(off)}"); + } + // ---- stage 7's spike (D299): the corpse's loot, crates, the locked crate's timer, removing a corpse ---- private int _corpsePopulated; -- 2.49.1 From 6a384d0a289a15a07fa63b5352c75530ebf2c4f0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 17:33:28 -0500 Subject: [PATCH 17/24] docs, test: the update surface in the README, and rnt.cost (stage 9, D306) - README: status is stage 9; the installer and egg install it now; API 6; the swap reports 55/55 npc fields; COMMANDS.md and INSTALL.md are linked; a new "update surface" table lists every Rust type and member the swap and the brain depend on, where, and what catches a change (compile, the field list, or only a running server). The staging drill checks exactly that list. - tools/RunicNpcTest.cs 0.7.0: rnt.cost measures 100 of RunicNPC's own NPCs against the same session's empty server, idle (awake) and fighting 20 invulnerable stand-ins, with a second empty baseline at the end and the bar taken against the lower. `idle` skips the fight, `sentry` makes them stand still, `asleep` leaves out the keeper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 66 ++++++++++++++---- tools/RunicNpcTest.cs | 159 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a2acee8..62c6d16 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,11 @@ works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicG website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is [`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). -> **Status: stage 4.** The NPC, its profiles, placements and routes, the API other plugins call, and -> the `/rnpc` commands admins use in game. On a Runic Gateway server, the website takes over the -> profiles and lists, edits and creates placements through the bridge (API 3). +> **Status: stage 9, hardening for v1.0.0.** The NPC, its profiles, placements and routes; factions, +> fights, escorts and tethers; bosses and passive NPCs; loot. Admins use the `/rnpc` commands, other +> plugins the API (version 6). On a Runic Gateway server RunicNPC is **required** (D310): the website +> authors its profiles and faction table, edits its placements, and every NPC an event places is +> RunicNPC's. ## Requirements @@ -22,8 +24,8 @@ website authors its NPC profiles and events use its NPCs. The plan of record, st ## Installing it On a Runic Gateway server, the [installer](https://gitea.whitlocktech.com/RunicGateway/installer) -and the Pterodactyl egg will install it from the Rust bundle, pinned and checksummed (D224, from -stage 4). Until then, and on any other server: +and the Pterodactyl egg install it from the Rust bundle, pinned and checksummed (D224). On any other +server: 1. Download `runicnpc-.tar.gz` and `SHA256SUMS` from this repository's [releases](https://gitea.whitlocktech.com/RunicGateway/runicnpc-rust/releases), and check the @@ -61,8 +63,10 @@ renamed it, because neither framework reports a hook that matches nothing. `place` uses the spot you look at, `here` the spot you stand on; the server console gives `at=x,y,z`. A roamer must stand on Rust's navmesh; only a sentry may stand off it. Every placement answers with what the server's NPCs cost. On a standalone server, profiles are made from a console -with `rnpc.profile create|set|delete`. The full list is in -[PLAN.md §5](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). +with `rnpc.profile create|set|delete`. Every command and its permission is in +[`docs/runicnpc/COMMANDS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/COMMANDS.md), +and installing and running it in +[`docs/runicnpc/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/INSTALL.md). ## For other plugins @@ -74,10 +78,11 @@ Every call is prefixed `RunicNpc_` and reached through `Call`: int api = RunicNPC?.Call("RunicNpc_ApiVersion") ?? 0; ``` -The API is version 3, documented in +The API is version 6, documented in [`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md): -spawning and removing NPCs by owner, profiles, placements (created and named as in game, from a map point), -routes, the cost warning, and the hooks it raises. +spawning and removing NPCs by owner, profiles and the faction table, placements (created and named as in +game, from a map point), routes, escorts, allies and tethers, bosses, loot, the cost warning, and the hooks it +raises. The API version moves when a call or a raised hook changes shape, not on every release. ## Repository layout @@ -87,12 +92,12 @@ The API version moves when a call or a raised hook changes shape, not on every r | `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. | | `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). | -| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2, 3 and 4 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | +| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 and 5 measurement plugin; `RunicNpcTest.cs`, the test harness (`rnt.run all`, then `rnt.after` after a reload or restart) and stage 9's performance check (`rnt.cost`, below); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | ## After a Rust update: the swap's field list Our NPC is Rust's scientist with two components swapped, and the swap copies a fixed list of fields (D232), -generated from Rust's unmodified assembly. `rnpc.status` reports it as `swap fields: npc=64/64 brain=32/32 +generated from Rust's unmodified assembly. `rnpc.status` reports it as `swap fields: npc=55/55 brain=32/32 missing=- added=-`. On Carbon, where the assembly is unmodified, `added` names any field Rust has added since the list was made. To regenerate it: @@ -104,6 +109,43 @@ dotnet run --project tools/fieldlist -- # rewrites the block Use the Carbon rig: Oxide's patcher makes Rust's private fields public, so its assembly no longer says which fields Rust itself serialises. +## The update surface: what a Rust update can break + +Everything RunicNPC takes from Rust's own classes for the swap and the brain, in one place. A Rust update +that changes any of it breaks RunicNPC; the staging drill (PLAN.md stage 9, D308) checks exactly this list +against every new staging build. **Caught by** says how: *compile* means the plugin no longer compiles +against the new assembly; *field list* means `tools/fieldlist`'s regenerated list differs from the +plugin's (and `rnpc.status` says `missing=` or `added=` at run time); *run time* means only a running server +shows it, so the drill's live half and `rnpc.status` are what catch it. + +| Rust type | What RunicNPC depends on | Where (`plugin/RunicNPC.cs`) | Caught by | +|---|---|---|---| +| `ScientistNPC` (prefabs `scientistnpc_*`) | Subclassed by `RunicNpcPlayer`; the swap replaces the prefab's component before spawn and copies `SwapNpcFields` (55 fields up its chain to `BaseNetworkable`) | `#region The swap`, `CreateNpc` | compile; field list | +| `ScientistBrain` | Subclassed by `RunicNpcBrain`; the swap copies `SwapBrainFields` (32 fields of `BaseAIBrain`) | `#region The swap` | compile; field list | +| `ScientistNPC` / `HumanNPC` virtuals | `displayName`, `AttackerInfo(PlayerLifeStory.DeathInfo)`, `ShotTest(float)`, `TriggerDown()`, `Hurt(HitInfo)`, `OnDied(HitInfo)` overridden | `RunicNpcPlayer` | compile | +| `IAIAttack`, `IAISenses` | Re-implemented on `RunicNpcPlayer`: `IsTargetInRange`, `EngagementRange`, `GetBestTarget`, `AttackTick`, `IsTarget`, `IsThreat`, `IsFriendly`. Rust's `HumanNPC.IsTarget` is not virtual, which is why | `RunicNpcPlayer` | compile; a changed meaning only at run time | +| `BaseAIBrain` | `AddStates()` and `Think(float)` overridden; `states`, `CurrentState`, `Navigator`, `Senses`, `Events` used; `AIState` values `Roam`, `Chase`, `Combat`, `TakeCover`, `Cover`, `MoveTowards`, `MoveToVector3`, `FollowPath`, `NavigateHome`, `Flee`, `Blinded` replaced or held | `RunicNpcBrain` | compile; a state Rust stops adding only at run time | +| `BaseAIBrain.BasicAIState` | Subclassed six times (`WanderState`, `RnRoamState`, `RnGuardState`, `RouteState`, `PursueState`, `HoldState`): `StateEnter`, `StateThink`, `StateLeave` | `#region The NPC` | compile | +| `BaseNavigator` | `SetDestination`, `Stop`, `Moving`, `Agent`, `IsOnNavMeshLink`, `NavigationSpeed` | the states | compile | +| `AIMemory` / `AIBrainSenses` | `Memory.Targets`, `SetKnown`, `IsLOS`, `Entity`; `DelaySenseUpdate` | the states, the fight | compile | +| `BaseCombatEntity.faction` | Our NPC's target is set to `Faction.Horror` for the length of our own shot, because `BaseProjectile.ServerUse` drops NPC-on-NPC hits otherwise (stage 5) | `RunicNpcPlayer.Mark` | run time (the drill's fight checks) | +| `NPCAutoTurret` | Harmony prefixes on the private `Ignore(BasePlayer)` and `IsEntityHostile(BaseCombatEntity)`, found by reflection (D267) | `#region Factions`, `SentryPatch` | run time: `rnpc.status` says whether the patch applied | +| `HackableLockedCrate` | The private `hackSeconds`, set by reflection for a loot table's locked crate (stage 7) | `#region Loot` | run time | +| `RelationshipManager`, `ClanManager` | Teams and clans for allies (D257) | `#region Factions` | compile | +| `Rust.Ai.Gen2.RustNavMeshHelpers` | `SamplePosition` for every placement's navmesh check | `#region Rust's facts` | compile | + +The **hooks** RunicNPC listens to are listed by `rnpc.status`, which names any that has never fired; one +that stays silent after an update has probably been renamed (neither framework reports a hook that matches +nothing). + +## Performance: `rnt.cost` + +Stage 9's check (D306): 100 of RunicNPC's NPCs, idle and fighting 20 stand-in players, against the same +session's empty server, on a 6000 map. It passes when they add no more than stage 1 measured, +1.6 ms idle +and +5.2 ms fighting to the median frame. Load `tools/RunicNpcTest.cs` on a rig (it needs the hidden Kits kit +`rnhrevolver`), back up `data/RunicNPC/` (it replaces the profiles), stop the other rig, and run +`rnt.cost [seconds]`; the result is in `data/RunicNpcTest.json`. + ## Releases Work lands on `edge` and is cut over to `main`; every releasable push to `main` tags and publishes a diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 9abaec9..63f7f75 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -45,8 +45,8 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [Info("RunicNpcTest", "Runic Gateway", "0.6.0")] - [Description("RunicNPC stage 2, 3, 4, 5 tests and the stage 6 spike. A developer tool: never ship it.")] + [Info("RunicNpcTest", "Runic Gateway", "0.7.0")] + [Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { [PluginReference] private Plugin RunicNPC; @@ -543,6 +543,161 @@ namespace Oxide.Plugins arg.ReplyWith("rnt: cleared"); } + // ---- cost: stage 9's performance check (D306) ---- + + /// + /// Stage 9's performance check (D306): what 100 of RunicNPC's own NPCs add to the median + /// server frame, idle and fighting, over this session's empty baseline. Stage 1 measured its + /// harness's stand-in NPC; this measures the NPC RunicNPC ships, brain, fight layer and all. + /// Same shape as stage 1's rnh.cost: a baseline, 100 awake but idle (a stand-in 85 m + /// away keeps them from sleeping and is beyond their senses), then 100 shooting 20 + /// invulnerable stand-ins 12 m in front of the grid. It replaces RunicNPC's profiles with + /// one, t_cost: back up data/RunicNPC/ first, and stop any site that pushes + /// profiles. rnt.cost [seconds=60] [idle [sentry|asleep]]: idle skips the fight, + /// sentry makes the 100 stand still, and asleep leaves out the keeper, so nobody is + /// within their sleep distance. Each run ends with a second empty baseline, and the bar is + /// measured against the lower of the two. + /// + [ConsoleCommand("rnt.cost")] + private void CmdCost(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) return; + if (_running != null) ServerMgr.Instance.StopCoroutine(_running); + _running = ServerMgr.Instance.StartCoroutine(Cost(arg.GetFloat(0, 60f), arg.GetString(1) == "idle", arg.GetString(2) == "sentry", arg.GetString(2) == "asleep")); + arg.ReplyWith("rnt: measuring cost"); + } + + /// The worst stage 1 measured on either framework (PLAN.md stage 1, the cost table): D306's bar. + private const double IdleBudgetMs = 1.6, FightBudgetMs = 5.2; + + private IEnumerator Cost(float window, bool idleOnly = false, bool still = false, bool asleep = false) + { + _pass = _fail = 0; + Note($"cost on {(AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name.StartsWith("Carbon")) ? "carbon" : "oxide")}, map {World.Size}/{World.Seed}, " + + $"players {BasePlayer.activePlayerList.Count}, {window:0} s a phase, ai budget {AIThinkManager.framebudgetms} ms"); + + Vector3 field; + if (!FindField(400f, 606, out field)) + { + Check("cost.field", false, "no open field on the navmesh"); + _running = null; + yield break; + } + + // `rnt.cost idle sentry`: the same NPCs standing still, to tell movement from thinking. + JObject prof = still ? Prof(role: "sentry", name: "TCost") : Prof(radius: 10f, name: "TCost"); + prof["ranges"] = JObject.FromObject(new { sense = 50f, loseTarget = 60f, chase = 40f, attack = 40f }); + Dictionary refused = Api>("RunicNpc_SetProfiles", new JObject { ["t_cost"] = prof }); + if (refused == null || refused.Count > 0) + { + Check("cost.profile", false, refused == null ? "no answer" : string.Join("; ", refused.Select(kv => kv.Key + ": " + kv.Value).ToArray())); + _running = null; + yield break; + } + + var meterHost = new GameObject("RunicNpcTest.FrameMeter"); + FrameMeter meter = meterHost.AddComponent(); + var medians = new Dictionary(); + var npcs = new List(); + + IEnumerator m = MeasurePhase(meter, window, "baseline", medians); + while (m.MoveNext()) yield return m.Current; + + // `rnt.cost idle asleep`: no keeper, so with nobody within 160 m they walk home and sleep (D235). + if (!asleep) + StandIn(OnMesh(field + new Vector3(13f, 0f, 110f)), 90); + for (int i = 0; i < 100; i++) + { + BasePlayer npc = Spawn(OnMesh(field + new Vector3((i % 10) * 3f, 0f, (i / 10) * 3f)), "t_cost"); + if (npc != null) npcs.Add(npc); + if (i % 10 == 9) yield return null; + } + + Check("cost.spawned", npcs.Count == 100, $"{npcs.Count} of 100"); + yield return new WaitForSeconds(10f); + m = MeasurePhase(meter, window, "idle100", medians); + while (m.MoveNext()) yield return m.Current; + + // `rnt.cost idle` stops here: a quicker run for finding what idle NPCs cost. + if (!idleOnly) + { + _hitsOnStandIns = 0; + for (int i = 1; i <= 20; i++) + StandIn(OnMesh(field + new Vector3((i - 1) * 1.5f, 0f, -12f)), 90 + i); + yield return new WaitForSeconds(10f); + int hitsBefore = _hitsOnStandIns; + m = MeasurePhase(meter, window, "fight100", medians); + while (m.MoveNext()) yield return m.Current; + Note($"cost.fight100 hits on stand-ins while measured: {_hitsOnStandIns - hitsBefore}"); + Check("cost.fighting", _hitsOnStandIns - hitsBefore > 0, $"{_hitsOnStandIns - hitsBefore} hits"); + } + + Api("RunicNpc_DespawnOwner", Owner); + ClearWorld(); + + // A second empty window after everything is gone: one baseline at the start can carry a hitch + // (the first run on the Oxide rig did: a 2.4 s frame, and idle read below it). The bar is + // measured against the worse — the lower — of the two, so noise can only make it stricter. + yield return new WaitForSeconds(10f); + m = MeasurePhase(meter, window, "baseline.after", medians); + while (m.MoveNext()) yield return m.Current; + UnityEngine.Object.Destroy(meterHost); + + double baseline = Math.Min(medians["baseline"], medians["baseline.after"]); + double idle = medians["idle100"] - baseline; + double fight = idleOnly ? double.NaN : medians["fight100"] - baseline; + Note($"cost baselines {medians["baseline"]:0.00} and {medians["baseline.after"]:0.00} ms; the lower is the bar's"); + Check("cost.idle", idle <= IdleBudgetMs, $"{idle:+0.00;-0.00} ms median over the baseline (bar {IdleBudgetMs} ms)"); + if (!idleOnly) Check("cost.fight", fight <= FightBudgetMs, $"{fight:+0.00;-0.00} ms median over the baseline (bar {FightBudgetMs} ms)"); + Note($"done cost: {_pass} pass, {_fail} fail"); + _running = null; + } + + private IEnumerator MeasurePhase(FrameMeter meter, float window, string phase, Dictionary medians) + { + int queue0 = AIThinkManager._processQueue.Count; + meter.Begin(); + yield return new WaitForSeconds(window); + List ms = meter.End(); + if (ms.Count == 0) + { + Check("cost." + phase, false, "no frames"); + medians[phase] = double.NaN; + yield break; + } + + ms.Sort(); + Func pct = p => ms[Math.Min(ms.Count - 1, (int)Math.Ceiling(p * ms.Count) - 1)]; + double mean = ms.Average(); + medians[phase] = pct(0.5); + Note($"cost.{phase} frames={ms.Count} p50={pct(0.5):0.00} ms fps={1000.0 / mean:0.0} p95={pct(0.95):0.00} p99={pct(0.99):0.00} " + + $"max={ms[ms.Count - 1]:0.00} aiQueue={queue0}->{AIThinkManager._processQueue.Count}"); + } + + public class FrameMeter : MonoBehaviour + { + private readonly List _frames = new List(20000); + private bool _recording; + + public void Begin() + { + _frames.Clear(); + _recording = true; + } + + public List End() + { + _recording = false; + return new List(_frames); + } + + private void Update() + { + if (_recording) + _frames.Add(Time.unscaledDeltaTime * 1000f); + } + } + private IEnumerator Run(string group) { _pass = _fail = 0; -- 2.49.1 From b5e7ecc4ed580f7843361c956696c3064b9c543c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 21:33:49 -0500 Subject: [PATCH 18/24] feat: the cost warning says what stage 9 measured (D306) Stage 9 measured the released NPC on both frameworks (6000 map): about 3 ms of median frame per 100 idle while a player keeps them awake, 0.5 ms once they sleep with nobody near, and 9 ms per 100 fighting, with each NPC reacting about every 3 s under Rust's shared AI budget. The warning now says idle twice (awake and asleep) and uses those figures, the worst of the two rigs rounded up. The awake-idle overhead over the bare swap is runicnpc-rust#13. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin/RunicNPC.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index f01c51e..c406d32 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -3661,9 +3661,11 @@ namespace Oxide.Plugins #region The cost warning (D227) /// - /// What NPCs cost the server, from stage 1's measurements on both - /// frameworks (PLAN.md §9, stage 1, Q5). The rig ran about 50 fps empty, so the warning - /// says what NPCs ADD to a frame, not an fps. + /// What NPCs cost the server, from stage 9's measurements of the + /// released NPC on both frameworks (PLAN.md §9, stage 9, D306; stage 1, Q5, first): the worst + /// of the two rigs, rounded up. Idle is said twice, because an NPC with no player within its + /// sleep distance sleeps (D235) and costs a sixth of an awake one. The warning says what NPCs + /// ADD to a frame, not an fps, since a server's own frame time varies far more than that. /// private static string CostWarning(int total) { @@ -3672,9 +3674,10 @@ namespace Oxide.Plugins float hundreds = total / 100f; string estimate = total > 100 ? " (measured up to 100; beyond that this is an estimate)" : ""; return $"RunicNPC: {total} NPC(s) on this server{estimate}. Measured on a test server, they add about " + - $"{Mathf.Max(0.1f, hundreds * 1.5f):0.#} ms to every server frame while idle. If all fight at once they add about " + - $"{hundreds * 5f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " + - $"{Mathf.Max(0.3f, hundreds * 2.5f):0.#} s. Fighting other NPCs (a faction setting) was measured to cost about " + + $"{Mathf.Max(0.1f, hundreds * 3f):0.#} ms to every server frame while idle with a player near enough to wake them, " + + $"and about {Mathf.Max(0.1f, hundreds * 0.5f):0.#} ms once they sleep with nobody near. If all fight at once they add about " + + $"{hundreds * 9f:0.#} ms, and Rust's shared 2 ms AI budget then lets each react only every " + + $"{Mathf.Max(0.3f, hundreds * 3f):0.#} s. Fighting other NPCs (a faction setting) was measured to cost about " + $"what fighting players does; on a map whose own AI is busy, each then reacts only every " + $"{Mathf.Max(0.5f, hundreds * 7f):0.#} s."; } -- 2.49.1 From c25f73c525dcb696fecab4e8e181f3e247eb3c65 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 7 Oct 2026 01:29:45 -0500 Subject: [PATCH 19/24] feat(drill): the staging drill, the week before each forced wipe (stage 9c, D307-D309, D317-D319) A Gitea job that checks each new Rust staging build in the seven days before the monthly forced wipe: - tools/drill/static.sh: staging's managed assemblies (DepotDownloader) and Oxide's staging build; whether Oxide has caught up (fieldlist --oxide-behind), the swap's field list compared, and the plugin compiled. - tools/drill/drill.js: window (D318), gate (one check per depot manifest), live (rust-staging runs rnt.run all; rust-carbon is stopped and restarted when both 6000-map rigs run, D309) and report (one issue per build, state on the drill branch; a build waits up to three days for Oxide, D317). - tools/drill/rig-start.sh: the rig's startup; installs the branch and Oxide's build for it, and refuses to start a mismatched pair. - RunicNpcTest 0.7.2: stage 5's five fights look for a field with room for their 280 m line (D319), and the spawn check names what did not spawn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitattributes | 2 + .gitea/workflows/staging-drill.yml | 149 ++++++++ README.md | 38 +- tools/RunicNpcTest.cs | 32 +- tools/drill/drill.js | 588 +++++++++++++++++++++++++++++ tools/drill/rig-start.sh | 68 ++++ tools/drill/static.sh | 145 +++++++ tools/fieldlist/Program.cs | 41 ++ tools/rigs.example.json | 3 +- 9 files changed, 1058 insertions(+), 8 deletions(-) create mode 100644 .gitattributes create mode 100644 .gitea/workflows/staging-drill.yml create mode 100644 tools/drill/drill.js create mode 100644 tools/drill/rig-start.sh create mode 100644 tools/drill/static.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2653889 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts run on Linux (the CI runner, the staging rig): a CRLF checkout breaks them. +*.sh text eol=lf diff --git a/.gitea/workflows/staging-drill.yml b/.gitea/workflows/staging-drill.yml new file mode 100644 index 0000000..e97b88b --- /dev/null +++ b/.gitea/workflows/staging-drill.yml @@ -0,0 +1,149 @@ +# The staging drill (docs/runicnpc/PLAN.md stage 9c, D307-D309, D317, D318). +# +# Rust changes under RunicNPC on every forced wipe, and what breaks first is the subclass swap and +# the brain (README "The update surface"). Facepunch publishes each build on Steam's `staging` +# branch days before it ships. The forced wipe is monthly, the first Thursday, so the job does +# something only in the seven days before it, which leaves a week to fix what it finds (D318). It +# is scheduled daily and stops at its first step outside that week. In the week, it checks every +# new staging build: +# +# window (tools/drill/drill.js window): the week before a forced wipe? (A run by hand always is.) +# static half (tools/drill/static.sh, about a minute, no server): +# Rust's managed assemblies from Steam's staging branch and Oxide's staging build → +# has Oxide caught up · the swap's field list regenerated and compared · RunicNPC compiled. +# gate (tools/drill/drill.js gate): stop here when this build (the manifest of Rust's Linux +# depot) was already checked. +# live half (drill.js live): the `rust-staging` server installs the same pair at start, then +# RunicNPC, Kits and the test harness are loaded and `rnt.run all` runs. When both 6000-map rigs +# are running, `rust-carbon` is stopped for it and started again (D309). Skipped while Oxide is +# behind Rust: an Oxide server on that pair dies at boot. +# report (drill.js report): anything that fails or differs opens ONE issue for the build, labelled +# `staging-drill` (a later run on the same build comments on it). The result is kept on the +# `drill` branch (drill.json), like the installer's `bundles` branch. +# +# While Oxide's staging build is behind Rust's, a build waits up to three days, opening nothing; +# then it is reported as "Oxide has not caught up" (D317). +# +# Run by hand with `branch: public` to rehearse it on a pair known to match: it reports in the job +# summary only, and changes no state and opens no issue. +# +# The schedule runs only from the default branch (`main`), so it starts with the stage 9 cutover; +# before then, run it by hand on `edge`. +# +# Secrets (Settings → Actions → Secrets, the organisation's): +# PTERODACTYL_DRILL_KEY — the panel client key made for this job alone (D309). Never printed. +# REGISTRY_USER / REGISTRY_TOKEN — as release.yml: push the `drill` branch and open issues. + +name: Staging drill + +on: + schedule: + - cron: '23 10 * * *' + workflow_dispatch: + inputs: + branch: + description: 'Steam branch to drill (public = a rehearsal: job summary only)' + default: staging + force: + description: 'Check the build again even if it was checked' + type: boolean + default: false + +concurrency: + group: staging-drill + cancel-in-progress: false + +env: + GITEA_HOST: gitea.whitlocktech.com + REPO: RunicGateway/runicnpc-rust + +jobs: + drill: + runs-on: ubuntu-latest + # Rust's update, a 3500 map's boot and navmesh (about 18 minutes when a new Rust build + # regenerates the map), and the harness: under an hour. + timeout-minutes: 180 + env: + BRANCH: ${{ github.event.inputs.branch || 'staging' }} + WORK: ${{ github.workspace }}/drill-work + steps: + - uses: actions/checkout@v4 + + # 22 or later: drill.js talks to the panel's console over the global WebSocket. + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: The week before a forced wipe? + id: window + run: node tools/drill/drill.js window ${{ github.event_name == 'workflow_dispatch' && '--force' || '' }} + + # tools/fieldlist and the compiler (Roslyn's csc.dll ships in the SDK). + - name: .NET SDK + if: ${{ steps.window.outputs.open == 'true' }} + run: | + set -euo pipefail + curl -sSL -o "$RUNNER_TEMP/dotnet-install.sh" https://builds.dotnet.microsoft.com/dotnet/scripts/v1/dotnet-install.sh + bash "$RUNNER_TEMP/dotnet-install.sh" --channel 9.0 --install-dir "$HOME/.dotnet" > /dev/null + echo "$HOME/.dotnet" >> "$GITHUB_PATH" + echo "DOTNET_ROOT=$HOME/.dotnet" >> "$GITHUB_ENV" + + - name: The drill branch (the last build checked) + if: ${{ steps.window.outputs.open == 'true' }} + run: | + set -euo pipefail + git config user.name "runicnpc-ci" + git config user.email "ci@whitlocktech.com" + rm -rf drill-state + git worktree prune + if git ls-remote --exit-code --heads origin drill > /dev/null 2>&1; then + git fetch origin drill + git worktree add -B drill drill-state origin/drill + else + # First run: a root commit with an empty tree, so the branch inherits no code history. + ROOT="$(git commit-tree "$(git hash-object -t tree /dev/null)" -m 'chore(drill): start the drill branch')" + git worktree add -B drill drill-state "$ROOT" + fi + [ -f drill-state/drill.json ] || echo '{}' > drill-state/drill.json + cat drill-state/drill.json + + - name: Static half + if: ${{ steps.window.outputs.open == 'true' }} + run: bash tools/drill/static.sh + + - name: A build not checked yet? + id: gate + if: ${{ steps.window.outputs.open == 'true' }} + run: node tools/drill/drill.js gate --state drill-state/drill.json --work "$WORK" ${{ github.event.inputs.force == 'true' && '--force' || '' }} + + - name: Live half (rust-staging) + id: live + if: ${{ steps.gate.outputs.run == 'true' }} + continue-on-error: true + env: + PANEL_KEY: ${{ secrets.PTERODACTYL_DRILL_KEY }} + run: node tools/drill/drill.js live --work "$WORK" --branch "$BRANCH" + + - name: Report + id: report + if: ${{ always() && steps.gate.outputs.run == 'true' }} + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: node tools/drill/drill.js report --state drill-state/drill.json --work "$WORK" + + - name: Keep the result on the drill branch + if: ${{ always() && steps.gate.outputs.run == 'true' && env.BRANCH == 'staging' }} + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" + cd drill-state + git add -A + if git diff --cached --quiet; then echo "no change"; exit 0; fi + node -e "const s=require('./drill.json').lastRun||{};console.log('chore(drill): '+(s.manifest||'?')+' '+(s.outcome||'')+' [skip ci]')" > ../drill-msg.txt + git commit -q -F ../drill-msg.txt + git push origin drill diff --git a/README.md b/README.md index 62c6d16..8c9e4e7 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,8 @@ The API version moves when a call or a raised hook changes shape, not on every r | `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. | | `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). | -| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 and 5 measurement plugin; `RunicNpcTest.cs`, the test harness (`rnt.run all`, then `rnt.after` after a reload or restart) and stage 9's performance check (`rnt.cost`, below); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | +| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 and 5 measurement plugin; `RunicNpcTest.cs`, the test harness (`rnt.run all`, then `rnt.after` after a reload or restart) and stage 9's performance check (`rnt.cost`, below); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). `drill/` is the staging drill's (below). | +| `.gitea/workflows/staging-drill.yml` | The staging drill, the week before each forced wipe. | ## After a Rust update: the swap's field list @@ -112,7 +113,7 @@ fields Rust itself serialises. ## The update surface: what a Rust update can break Everything RunicNPC takes from Rust's own classes for the swap and the brain, in one place. A Rust update -that changes any of it breaks RunicNPC; the staging drill (PLAN.md stage 9, D308) checks exactly this list +that changes any of it breaks RunicNPC; the staging drill (PLAN.md stage 9c, below) checks exactly this list against every new staging build. **Caught by** says how: *compile* means the plugin no longer compiles against the new assembly; *field list* means `tools/fieldlist`'s regenerated list differs from the plugin's (and `rnpc.status` says `missing=` or `added=` at run time); *run time* means only a running server @@ -138,6 +139,39 @@ The **hooks** RunicNPC listens to are listed by `rnpc.status`, which names any t that stays silent after an update has probably been renamed (neither framework reports a hook that matches nothing). +## The staging drill + +Facepunch puts each Rust build on Steam's `staging` branch days before it ships, and Rust changes on the +monthly forced wipe, the first Thursday. `.gitea/workflows/staging-drill.yml` works in the seven days +before each forced wipe, which leaves a week to fix what it finds, and checks every new staging build in +that week (PLAN.md stage 9c, D307–D309, D317, D318). It is scheduled daily and stops at once outside +the week. A run by hand always goes ahead. + +- **Static, about a minute, no server** (`tools/drill/static.sh`): Rust's managed assemblies + from the staging branch and Oxide's staging build. It asks whether Oxide has caught up with Rust + (`tools/fieldlist --oxide-behind`), regenerates the swap's field list and compares it, and compiles the + plugin against them. +- **Live, once per build** (`tools/drill/drill.js live`): the `rust-staging` server (Oxide, a 3500 map) + installs the same pair at start (`tools/drill/rig-start.sh`), loads RunicNPC, Kits and + `tools/RunicNpcTest.cs`, and runs `rnt.run all`. When both 6000-map rigs are running, `rust-carbon` is + stopped for it and started again. +- **Report:** anything that fails or differs opens one issue for that build, labelled `staging-drill`. + The last build checked is kept on the `drill` branch. + +A build is the manifest of Rust's Linux depot, as Steam names it. Oxide's build for a branch carries its +own patched `Assembly-CSharp.dll`, and while it is older than Rust's, an Oxide server on that pair dies +at boot. So a build waits, opening nothing, until Oxide catches up, for up to three days. After that it is +reported as "Oxide has not caught up". To rehearse it on a pair known to match, run the workflow by hand +with `branch: public`: it reports in the job summary only. + +By hand, from Git Bash or Linux: + +```bash +BRANCH=public DEPOTDOWNLOADER=/path/to/DepotDownloader bash tools/drill/static.sh # → drill-work/static.json +PANEL_KEY_FILE=/path/to/key node tools/drill/drill.js live --branch public # → drill-work/live.json +node tools/drill/drill.js report --state /tmp/drill.json # public: prints only +``` + ## Performance: `rnt.cost` Stage 9's check (D306): 100 of RunicNPC's NPCs, idle and fighting 20 stand-in players, against the same diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 63f7f75..98e0800 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -45,7 +45,7 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [Info("RunicNpcTest", "Runic Gateway", "0.7.0")] + [Info("RunicNpcTest", "Runic Gateway", "0.7.2")] [Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { @@ -147,11 +147,17 @@ namespace Oxide.Plugins private static MonumentInfo[] Monuments() => _monuments ?? (_monuments = UnityEngine.Object.FindObjectsOfType()); /// A flat, dry spot on the navmesh far from every monument; fixed seed, so reruns land in the same field. - private static bool FindField(float clear, int seed, out Vector3 at) + /// + /// An open field on the navmesh. With , the points every line / 2 m out to + /// ± along x, and 25 m past each, must be on it too: stage 5's five fights stand + /// that far apart. On a 6000 map the first field had room; on the staging drill's maps it is rare (D319). + /// + private static bool FindField(float clear, int seed, out Vector3 at, float line = 0f) { var rng = new System.Random(seed); float half = World.Size / 2f - 300f; - for (int i = 0; i < 4000; i++) + // The line is rare on a small map: 5 samples in 20000 have it on the drill's 3500 map (D319). + for (int i = 0; i < (line > 0f ? 20000 : 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); @@ -168,6 +174,16 @@ namespace Oxide.Plugins open = SampleNavmesh(hit.position + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * 35f, 2f, out h2); } + for (int k = -2; k <= 2 && open && line > 0f; k++) + { + Vector3 q = hit.position + new Vector3(k * line / 2f, 0f, 0f); + Vector3 r = q + new Vector3(0f, 0f, 25f); + q.y = TerrainMeta.HeightMap.GetHeight(q); + r.y = TerrainMeta.HeightMap.GetHeight(r); + NavMeshHit h2; + open = SampleNavmesh(q, 3f, out h2) && SampleNavmesh(r, 3f, out h2); + } + if (!open) continue; at = hit.position; return true; @@ -1710,7 +1726,7 @@ namespace Oxide.Plugins private IEnumerator Stage5() { Vector3 f; - if (!FindField(300f, 2025, out f)) + if (!FindField(300f, 2025, out f, 140f)) { Check("s5.setup.field", false, "no second open field on the navmesh"); yield break; @@ -1755,7 +1771,13 @@ namespace Oxide.Plugins ScientistNPC far = SpawnScientist(d + new Vector3(0, 0, 25)); BasePlayer victim = Spawn(e, "t_roam"); BasePlayer bully = Spawn(OnMesh(e + new Vector3(0, 0, 15)), "t_bully"); - Check("s5.fight.spawned", red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully, ""); + var fighters = new Dictionary + { + { "red", red }, { "blue", blue }, { "hunter", hunter }, { "prey", prey }, { "neutral", neutral }, + { "bystander", bystander }, { "leashed", leashed }, { "far", far }, { "victim", victim }, { "bully", bully }, + }; + string[] unspawned = fighters.Where(kv => kv.Value == null).Select(kv => kv.Key).ToArray(); + Check("s5.fight.spawned", unspawned.Length == 0, unspawned.Length == 0 ? "" : $"not spawned: {string.Join(", ", unspawned)} (field {f}, map {World.Size})"); if (!(red && blue && hunter && prey && neutral && bystander && leashed && far && victim && bully)) yield break; float farHealth = far.health, bystanderHealth = bystander.health, preyHealth = prey.health; diff --git a/tools/drill/drill.js b/tools/drill/drill.js new file mode 100644 index 0000000..294250c --- /dev/null +++ b/tools/drill/drill.js @@ -0,0 +1,588 @@ +// The staging drill (docs/runicnpc/PLAN.md stage 9c, D307-D309, D317, D318). Never shipped. +// +// .gitea/workflows/staging-drill.yml runs it daily, and it does something only in the week before the +// monthly forced wipe (D318): +// +// node tools/drill/drill.js window [--today YYYY-MM-DD] in the drill week? +// tools/drill/static.sh the static half +// node tools/drill/drill.js gate --state --work a build not checked yet? +// node tools/drill/drill.js live --work [--branch staging] the live half, on rust-staging +// node tools/drill/drill.js report --state --work one issue per build; the state +// +// A build is the manifest of Rust's Linux depot, as Steam names it to static.sh. +// +// Environment: PANEL_KEY (or PANEL_KEY_FILE, a file with a `user: ptlc_…` line) is the drill's own +// Pterodactyl client key; PANEL_URL the panel. The live half finds its servers by name: DRILL_RIG +// (rust-staging) and the two 6000-map rigs, of which it stops DRILL_STOPPABLE (rust-carbon) when both +// run, because the node has no memory for three (D309). It leaves every rig as it found it. +// `report` needs GITEA_TOKEN, and GITHUB_SERVER_URL + GITHUB_REPOSITORY as Gitea Actions sets them. +// +// The key is never printed, and neither is the server's console start line (it carries the RCON +// password): only lines this script picks are ever written out. + +const fs = require('fs') +const path = require('path') + +const ROOT = path.resolve(__dirname, '..', '..') +const PANEL = (process.env.PANEL_URL || 'http://192.168.0.12').replace(/\/+$/, '') +const RIG = process.env.DRILL_RIG || 'rust-staging' +const BIG = (process.env.DRILL_BIG_RIGS || 'rust-oxide,rust-carbon').split(',') +const STOPPABLE = process.env.DRILL_STOPPABLE || 'rust-carbon' +const KITS_URL = 'https://umod.org/plugins/Kits.cs' +const WAIT_DAYS = 3 // D317: how long a build waits for Oxide to catch up + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const now = () => new Date().toISOString() +const log = (...a) => console.log(`[drill ${now().slice(11, 19)}]`, ...a) + +function args() { + const out = { _: [] } + const argv = process.argv.slice(2) + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + const k = argv[i].slice(2) + out[k] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true + } else out._.push(argv[i]) + } + return out +} + +const readJson = (f, fallback) => { + try { + return JSON.parse(fs.readFileSync(f, 'utf8')) + } catch { + return fallback + } +} + +function output(name, value) { + if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`) +} + +// ---------------------------------------------------------------- the panel + +function panelKey() { + if (process.env.PANEL_KEY) return process.env.PANEL_KEY.trim() + const file = process.env.PANEL_KEY_FILE + if (!file) throw new Error('PANEL_KEY (or PANEL_KEY_FILE) is not set') + const line = fs.readFileSync(file, 'utf8').split(/\r?\n/).find((l) => l.startsWith('user:')) + if (!line) throw new Error(`${file} has no "user:" line`) + return line.slice(5).trim() +} + +function panel() { + const key = panelKey() + const headers = (type = 'application/json') => ({ Authorization: 'Bearer ' + key, Accept: 'application/json', 'Content-Type': type }) + const api = async (method, p, body, type) => { + const r = await fetch(`${PANEL}/api/client${p}`, { method, headers: headers(type), body }) + const text = await r.text() + if (!r.ok) throw new Error(`${method} ${p.split('?')[0]}: ${r.status} ${text.slice(0, 200)}`) + return text + } + + const p = { + async servers() { + const d = JSON.parse(await api('GET', '/?per_page=100')) + return Object.fromEntries(d.data.map((s) => [s.attributes.name, s.attributes.identifier])) + }, + async state(id) { + return JSON.parse(await api('GET', `/servers/${id}/resources`)).attributes.current_state + }, + power: (id, signal) => api('POST', `/servers/${id}/power`, JSON.stringify({ signal })), + command: (id, command) => api('POST', `/servers/${id}/command`, JSON.stringify({ command })), + read: (id, file) => api('GET', `/servers/${id}/files/contents?file=${encodeURIComponent(file)}`), + async readOr(id, file, fallback = null) { + try { + return await p.read(id, file) + } catch { + return fallback + } + }, + write: (id, file, content) => api('POST', `/servers/${id}/files/write?file=${encodeURIComponent(file)}`, content, 'text/plain'), + async rm(id, file) { + const dir = file.slice(0, file.lastIndexOf('/')) || '/' + try { + await api('POST', `/servers/${id}/files/delete`, JSON.stringify({ root: dir, files: [file.slice(file.lastIndexOf('/') + 1)] })) + } catch { + // already gone + } + }, + + /** Sends one console command and returns the console lines that follow, for `secs` seconds. */ + async console(id, command, secs = 6) { + const { data } = JSON.parse(await api('GET', `/servers/${id}/websocket`)) + const strip = (s) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') + return new Promise((resolve) => { + const lines = [] + let sent = false + const ws = new WebSocket(data.socket, { headers: { Origin: PANEL } }) + const done = () => { + try { + ws.close() + } catch {} + resolve(lines) + } + ws.onopen = () => ws.send(JSON.stringify({ event: 'auth', args: [data.token] })) + ws.onmessage = (m) => { + const msg = JSON.parse(m.data) + if (msg.event === 'auth success' && !sent) { + sent = true + // The panel replays recent history on connect; let it pass, then send. + setTimeout(() => { + lines.length = 0 + ws.send(JSON.stringify({ event: 'send command', args: [command] })) + }, 800) + setTimeout(done, secs * 1000) + } else if (msg.event === 'console output' && sent) { + lines.push(...strip(String(msg.args[0])).split('\n')) + } + } + ws.onerror = () => done() + }) + }, + + async waitState(id, want, timeoutMs) { + const end = Date.now() + timeoutMs + while (Date.now() < end) { + if ((await p.state(id)) === want) return true + await sleep(5000) + } + return false + }, + + /** Stops a server: `stop`, then `kill` if it has not stopped in time. */ + async stop(id, graceMs = 180000) { + if ((await p.state(id)) === 'offline') return + await p.power(id, 'stop') + if (!(await p.waitState(id, 'offline', graceMs))) { + await p.power(id, 'kill') + await p.waitState(id, 'offline', 60000) + } + }, + } + return p +} + +// ---------------------------------------------------------------- window: the week before a forced wipe + +// Rust changes on the monthly forced wipe, the first Thursday (D318). The drill runs only in the seven +// days before it, which leaves a week to fix what it finds; outside them the job stops at once. +function nextWipe(today) { + const firstThursday = (y, m) => { + const d = new Date(Date.UTC(y, m, 1)) + d.setUTCDate(1 + ((4 - d.getUTCDay() + 7) % 7)) + return d + } + const t = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) + const thisMonth = firstThursday(today.getUTCFullYear(), today.getUTCMonth()) + return thisMonth.getTime() > t ? thisMonth : firstThursday(today.getUTCFullYear(), today.getUTCMonth() + 1) +} + +function windowDays(today = new Date()) { + const t = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) + return Math.round((nextWipe(today).getTime() - t) / 86400000) +} + +async function window_(a) { + const days = windowDays(a.today ? new Date(a.today) : new Date()) + const wipe = nextWipe(a.today ? new Date(a.today) : new Date()).toISOString().slice(0, 10) + const open = days >= 1 && days <= 7 + log(`the next forced wipe is ${wipe}, in ${days} day(s): ${open ? 'in the drill week' : 'outside the drill week, nothing to do'}`) + output('open', open || a.force ? 'true' : 'false') +} + +// ---------------------------------------------------------------- gate: is there anything to check? + +// A build is Steam's manifest of the Linux depot, which static.sh reads from Steam itself. +async function gate(a) { + const state = readJson(a.state, {}) + const stat = readJson(path.join(path.resolve(a.work || 'drill-work'), 'static.json'), {}) + const checked = state.checked && state.checked.manifest + const isNew = !stat.manifest || stat.manifest !== checked + log(`Rust ${stat.branch || 'staging'} is manifest ${stat.manifest || '?'} (${stat.manifestDate || '?'}); last checked ${checked || 'none'}`) + output('run', isNew || a.force ? 'true' : 'false') + if (!isNew && !a.force) log('already checked: nothing to do') +} + +// ---------------------------------------------------------------- live: the harness on rust-staging + +async function live(a) { + const work = path.resolve(a.work || 'drill-work') + const branch = a.branch || 'staging' + const result = { branch, at: now(), ran: false, findings: [], notes: [] } + const save = () => fs.writeFileSync(path.join(work, 'live.json'), JSON.stringify(result, null, 2)) + fs.mkdirSync(work, { recursive: true }) + + const stat = readJson(path.join(work, 'static.json'), null) + if (!stat || stat.managed !== 'ok') { + result.skipped = 'the static half did not run' + save() + return log(result.skipped) + } + if (stat.oxideBehind !== 0) { + result.skipped = `Oxide's ${branch} build is ${stat.oxideBehind} member(s) behind Rust's, so an Oxide server cannot boot on it` + save() + return log(result.skipped) + } + + const p = panel() + const ids = await p.servers() + const rig = ids[RIG] + if (!rig) throw new Error(`no server named ${RIG} on the panel`) + + let restart = null + const finding = (f) => { + result.findings.push(f) + log('FINDING', f) + } + + try { + // Room for it (D309): with both 6000-map rigs running the node's memory is full. + const running = [] + for (const name of BIG) if (ids[name] && (await p.state(ids[name])) === 'running') running.push(name) + if (running.length === BIG.length && ids[STOPPABLE]) { + log(`both ${BIG.join(' and ')} are running: saving and stopping ${STOPPABLE}`) + await p.command(ids[STOPPABLE], 'server.save').catch(() => {}) + await sleep(15000) + await p.stop(ids[STOPPABLE]) + restart = STOPPABLE + result.notes.push(`${STOPPABLE} was stopped for the drill and started again afterwards`) + } + + await p.stop(rig, 60000) + + // What runs: this commit's plugin and harness, and the startup that installs the branch. + await p.write(rig, '/staging-drill.sh', fs.readFileSync(path.join(__dirname, 'rig-start.sh'), 'utf8').replace(/\r\n/g, '\n')) + await p.write(rig, '/staging-drill.branch', branch + '\n') + await p.write(rig, '/oxide/plugins/RunicNPC.cs', fs.readFileSync(path.join(ROOT, 'plugin', 'RunicNPC.cs'), 'utf8')) + const kits = await fetch(KITS_URL) + if (!kits.ok) throw new Error(`Kits from uMod: ${kits.status}`) + await p.write(rig, '/oxide/plugins/Kits.cs', await kits.text()) + await p.rm(rig, '/oxide/plugins/RunicNpcTest.cs') + await p.rm(rig, '/oxide/data/RunicNpcTest.json') + await p.rm(rig, '/staging-drill.json') + + log(`starting ${RIG} on ${branch}`) + const started = Date.now() + await p.power(rig, 'start') + const up = await waitRunning(p, rig, 45 * 60000) + result.installed = readJsonText(await p.readOr(rig, '/staging-drill.json', null)) + if (!up) { + finding(`The server did not start on Rust ${branch} ${result.installed?.buildid || ''} with Oxide's ${branch} build.`) + result.consoleTail = safeTail(await p.readOr(rig, '/latest.log', ''), 30) + return + } + result.bootMinutes = Math.round((Date.now() - started) / 6000) / 10 + log(`running after ${result.bootMinutes} min:`, JSON.stringify(result.installed)) + + // RunicNPC loaded, and the navmesh ready. + const status = await waitStatus(p, rig, 30 * 60000) + if (!status) { + finding('RunicNPC did not load: `rnpc.status` never answered.') + result.oxideErrors = await oxideErrors(p, rig) + return + } + result.status = status + log('rnpc.status:', status.find((l) => l.includes('navmesh='))) + const fieldsLine = status.find((l) => l.includes('swap fields:')) || '' + const missing = /missing=(\S+)/.exec(fieldsLine) + if (missing && missing[1] !== '-') finding(`The swap's fields are missing on this build: ${missing[1]}`) + if (!fieldsLine.includes('navmesh=ready')) { + finding('The navmesh was not ready in 30 minutes.') + return + } + + // The harness needs the hidden Kits kit rnhrevolver (tools/RunicNpcTest.cs). + await ensureKit(p, rig) + + await p.write(rig, '/oxide/plugins/RunicNpcTest.cs', fs.readFileSync(path.join(ROOT, 'tools', 'RunicNpcTest.cs'), 'utf8')) + let accepted = false + for (let i = 0; i < 18 && !accepted; i++) { + await sleep(10000) + accepted = (await p.console(rig, 'rnt.run all', 5)).some((l) => l.includes('rnt: running all')) + } + if (!accepted) { + finding('The test harness did not load (`rnt.run all` was not answered).') + result.oxideErrors = await oxideErrors(p, rig) + return + } + result.ran = true + log('rnt.run all: running') + + const lines = await waitHarness(p, rig, 45 * 60000) + result.harness = summarise(lines) + if (!result.harness.done) finding(`The harness did not finish in 45 minutes (${result.harness.pass} pass, ${result.harness.fail} fail so far).`) + for (const f of result.harness.failed) finding(`Harness: ${f}`) + log(`harness: ${result.harness.pass} pass, ${result.harness.fail} fail`) + result.oxideErrors = await oxideErrors(p, rig) + if (result.oxideErrors.length) finding(`RunicNPC logged ${result.oxideErrors.length} error line(s) in Oxide's log during the run.`) + await p.command(rig, 'rnt.clear').catch(() => {}) + } catch (e) { + finding(`The live half stopped: ${e.message}`) + } finally { + try { + await p.stop(rig) + await p.rm(rig, '/oxide/plugins/RunicNpcTest.cs') + } catch (e) { + result.notes.push(`${RIG} could not be stopped cleanly: ${e.message}`) + } + if (restart) { + try { + await p.power(ids[restart], 'start') + log(`started ${restart} again`) + } catch (e) { + result.findings.push(`${restart} was stopped for the drill and could NOT be started again: ${e.message}`) + } + } + save() + } +} + +async function waitRunning(p, id, timeoutMs) { + const end = Date.now() + timeoutMs + let seenStarting = false + while (Date.now() < end) { + const s = await p.state(id) + if (s === 'running') return true + if (s === 'starting') seenStarting = true + // Wings restarts a crashed server a few times; offline after starting is a crash it gave up on. + if (s === 'offline' && seenStarting) return false + await sleep(15000) + } + return false +} + +async function waitStatus(p, id, timeoutMs) { + const end = Date.now() + timeoutMs + let last = null + while (Date.now() < end) { + const lines = (await p.console(id, 'rnpc.status', 6)).filter((l) => l.trim()) + const at = lines.findIndex((l) => l.startsWith('RunicNPC ')) + if (at >= 0) { + last = lines.slice(at, at + 14) + if (last.some((l) => l.includes('navmesh=ready'))) return last + } + await sleep(30000) + } + return last +} + +async function ensureKit(p, id) { + const file = '/oxide/data/Kits/kits_data.json' + let data = null + for (let i = 0; i < 12 && !data; i++) { + data = readJsonText(await p.readOr(id, file, null)) + if (!data) await sleep(5000) + } + if (!data) throw new Error(`Kits wrote no ${file}`) + data._kits = data._kits || {} + if (data._kits.rnhrevolver) return + const item = (Shortname, Amount, Position, Condition = 0) => ({ Shortname, Skin: 0, Amount, Condition, MaxCondition: Condition, Ammo: 0, Position, Frequency: -1 }) + data._kits.rnhrevolver = { + Name: 'rnhrevolver', + Description: 'RunicNPC test harness: revolver + burlap (staging drill)', + RequiredPermission: '', + MaximumUses: 0, + RequiredAuth: 0, + Cooldown: 0, + Cost: 0, + IsHidden: true, + CopyPasteFile: '', + KitImage: '', + MainItems: [item('ammo.pistol', 120, 0)], + WearItems: [item('burlap.shirt', 1, 0), item('burlap.trousers', 1, 1), item('hat.boonie', 1, 2)], + BeltItems: [item('pistol.revolver', 1, 0, 100)], + } + await p.write(id, file, JSON.stringify(data, null, 2)) + await p.command(id, 'oxide.reload Kits') + await sleep(10000) + log('added the rnhrevolver kit and reloaded Kits') +} + +function readJsonText(t) { + try { + return t ? JSON.parse(t) : null + } catch { + return null + } +} + +async function waitHarness(p, id, timeoutMs) { + const end = Date.now() + timeoutMs + let lines = [] + while (Date.now() < end) { + await sleep(30000) + lines = readJsonText(await p.readOr(id, '/oxide/data/RunicNpcTest.json', null)) || lines + if (lines.some((l) => /^# done all:/.test(l))) break + } + return lines +} + +function summarise(lines) { + const failed = lines.filter((l) => / FAIL( |$)/.test(l)) + const passed = lines.filter((l) => / PASS( |$)/.test(l)) + const done = lines.find((l) => /^# done all:/.test(l)) + return { done: !!done, summary: done || null, pass: passed.length, fail: failed.length, failed } +} + +/** RunicNPC's and the harness's error lines from today's Oxide log. */ +async function oxideErrors(p, id) { + const day = now().slice(0, 10) + const text = (await p.readOr(id, `/oxide/logs/oxide_${day}.txt`, '')) || '' + return text + .split('\n') + .filter((l) => /RunicNPC|RunicNpcTest/.test(l) && /\[Error\]|Exception|Failed|error CS/.test(l)) + .slice(-40) +} + +/** The console's last lines, without the startup line (it carries the RCON password). */ +function safeTail(text, n) { + return (text || '') + .split('\n') + .filter((l) => !/rcon\.password|\+rcon/i.test(l)) + .slice(-n) +} + +// ---------------------------------------------------------------- report: one issue per build + +async function report(a) { + const work = path.resolve(a.work || 'drill-work') + const statePath = a.state + const state = readJson(statePath, {}) + const stat = readJson(path.join(work, 'static.json'), null) + const liv = readJson(path.join(work, 'live.json'), null) + const branch = (stat && stat.branch) || 'staging' + const manifest = (stat && stat.manifest) || '' + const buildid = (liv && liv.installed && liv.installed.buildid) || '' + // How a build is named: Steam's build id when the rig installed it, and always the manifest's date. + const build = `${buildid ? `build ${buildid}, ` : ''}manifest ${manifest || '?'} of ${(stat && stat.manifestDate) || '?'}` + const read = (f, n = 80) => { + try { + return fs.readFileSync(path.join(work, f), 'utf8').replace(/\r/g, '').split('\n').slice(0, n).join('\n').trim() + } catch { + return '' + } + } + + const findings = [] + const sections = [] + let outcome + let markChecked = true + + if (!stat || stat.managed !== 'ok' || stat.fields === 'error' || stat.compile === 'error') { + outcome = 'could not run' + markChecked = false + findings.push(`The static half could not run: ${(stat && stat.note) || 'no static.json'}`) + if (stat && stat.fields === 'error') sections.push(['tools/fieldlist', read('fields.diff')]) + } else if (stat.oxideBehind !== 0) { + const waiting = state.waiting && state.waiting.manifest === manifest ? state.waiting : { manifest, since: now() } + const days = (Date.now() - Date.parse(waiting.since)) / 86400000 + if (days < WAIT_DAYS) { + outcome = `waiting for Oxide (${stat.oxideBehind} behind, day ${Math.floor(days) + 1} of ${WAIT_DAYS})` + if (branch === 'staging') { + state.waiting = { ...waiting, behind: stat.oxideBehind, lastSeen: now() } + markChecked = false + } + } else { + outcome = 'Oxide has not caught up' + findings.push( + `Oxide's staging build is still ${stat.oxideBehind} member(s) behind Rust's staging build after ${WAIT_DAYS} days, ` + + 'so an Oxide server cannot boot on it and the live half did not run. The static half ran against Oxide\'s older build.' + ) + sections.push(['What Oxide lacks (first 20)', read('oxide-behind.txt', 21)]) + } + } + + if (!outcome || outcome === 'Oxide has not caught up') { + if (stat.fields === 'differs') { + findings.push("The swap's field list differs from the one the plugin carries (D232): regenerate it with tools/fieldlist and check what changed.") + sections.push(['Field list (carried → staging)', read('fields.diff', 120)]) + } + if (stat.compile === 'errors') { + findings.push("RunicNPC does not compile against this build with Oxide's staging build.") + sections.push(['Compiler output', read('compile.txt', 60)]) + } + if (!liv && stat.oxideBehind === 0) findings.push('The live half did not run: see the job log (is PTERODACTYL_DRILL_KEY set?).') + if (liv && !liv.skipped) { + findings.push(...liv.findings) + if (liv.consoleTail && liv.consoleTail.length) sections.push(['Console, last lines', liv.consoleTail.join('\n')]) + if (liv.oxideErrors && liv.oxideErrors.length) sections.push(["Oxide's log (RunicNPC lines)", liv.oxideErrors.join('\n')]) + if (liv.status) sections.push(['rnpc.status', liv.status.join('\n')]) + } + if (!outcome) outcome = findings.length ? 'fail' : 'pass' + if (branch === 'staging' && markChecked) delete state.waiting + } + + const installed = liv && liv.installed ? `Rust ${liv.installed.branch} ${liv.installed.buildid}, Oxide build of ${liv.installed.oxideModified}` : '' + const lines = [ + `Static: Oxide behind ${stat ? stat.oxideBehind : '?'}, fields ${stat ? stat.fields : '?'}, compile ${stat ? stat.compile : '?'}.`, + liv ? (liv.skipped ? `Live: skipped (${liv.skipped}).` : `Live: ${liv.harness ? `${liv.harness.pass} pass, ${liv.harness.fail} fail` : 'did not reach the harness'}${installed ? `, on ${installed}` : ''}${liv.bootMinutes ? `, booted in ${liv.bootMinutes} min` : ''}.`) : 'Live: did not run.', + ] + log(`${build} on ${branch}: ${outcome}`) + lines.forEach((l) => log(l)) + findings.forEach((f) => log('-', f)) + if (process.env.GITHUB_STEP_SUMMARY) { + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Staging drill: ${build}: ${outcome}\n\n${lines.join('\n\n')}\n\n${findings.map((f) => `- ${f}`).join('\n')}\n`) + } + + // A rehearsal on another branch reports here and changes nothing. + if (branch !== 'staging') return log('not the staging branch: no issue, no state') + + let issue = null + if (findings.length) { + const body = [ + `The staging drill (docs/runicnpc/PLAN.md stage 9c) checked Rust's staging branch (**${build}**) on ${now().slice(0, 10)}: **${outcome}**.`, + '', + ...findings.map((f) => `- ${f}`), + '', + ...lines, + ...sections.flatMap(([title, text]) => (text ? ['', `### ${title}`, '', '```', text, '```'] : [])), + '', + process.env.GITHUB_RUN_NUMBER ? `Run: ${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_NUMBER}` : '', + ].join('\n') + issue = await fileIssue(`Staging drill: Rust staging manifest ${manifest} (${(stat && stat.manifestDate) || '?'})`, body) + } + + if (markChecked) state.checked = { manifest, buildid, at: now(), outcome, issue, oxideBehind: stat ? stat.oxideBehind : null } + state.lastRun = { at: now(), manifest, buildid, outcome, issue } + fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n') + output('outcome', outcome) +} + +async function fileIssue(title, body) { + const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}` + const headers = { Authorization: 'token ' + process.env.GITEA_TOKEN, 'Content-Type': 'application/json', Accept: 'application/json' } + const call = async (method, p, data) => { + const r = await fetch(base + p, { method, headers, body: data ? JSON.stringify(data) : undefined }) + if (!r.ok) throw new Error(`${method} ${p}: ${r.status} ${(await r.text()).slice(0, 200)}`) + return r.json() + } + + const open = await call('GET', `/issues?state=open&type=issues&labels=staging-drill&limit=50`) + const same = open.find((i) => i.title === title) + if (same) { + await call('POST', `/issues/${same.number}/comments`, { body }) + log(`commented on #${same.number}`) + return same.number + } + const labels = await call('GET', '/labels?limit=100') + let label = labels.find((l) => l.name === 'staging-drill') + if (!label) label = await call('POST', '/labels', { name: 'staging-drill', color: '#d93f0b', description: "Rust's staging branch breaks RunicNPC (the daily staging drill)" }) + const created = await call('POST', '/issues', { title, body, labels: [label.id] }) + log(`opened #${created.number}`) + return created.number +} + +// ---------------------------------------------------------------- main + +const a = args() +const commands = { window: window_, gate, live, report } +const command = commands[a._[0]] +if (!command) { + console.error('usage: node tools/drill/drill.js window|gate|live|report [--state f] [--work dir] [--branch b] [--force]') + process.exit(2) +} +command(a).catch((e) => { + console.error(e.message || e) + process.exit(1) +}) diff --git a/tools/drill/rig-start.sh b/tools/drill/rig-start.sh new file mode 100644 index 0000000..c0cd013 --- /dev/null +++ b/tools/drill/rig-start.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# The staging drill rig's startup (docs/runicnpc/PLAN.md D307). Never shipped. +# +# rust-staging runs the published egg in its vanilla mode. That image's entrypoint always runs +# `app_update 258550` before the startup, and the egg cannot be told otherwise, so the server's +# startup is this script in front of the egg's own command line: +# +# bash ./staging-drill.sh ./RustDedicated -batchmode … +# +# It moves the install to a Steam branch, lays Oxide's build for that branch over it, writes down what +# it installed (staging-drill.json), and becomes the server. The branch is `staging` unless the file +# staging-drill.branch names another (`public` rehearses the drill on a pair known to match). +# tools/drill/drill.js writes this file to the rig as /staging-drill.sh before every start, so the +# copy in git is the one that runs. +# +# Steam keeps the branch: the entrypoint's own app_update, which names none, then answers "already +# up to date" and leaves a staging install on staging (seen 2026-10-06). + +cd /home/container || exit 1 + +branch=staging +if [ -s staging-drill.branch ]; then branch=$(tr -dc 'a-z0-9-' < staging-drill.branch); fi +if [ "$branch" = public ]; then + OXIDE_URL=https://github.com/OxideMod/Oxide.Rust/releases/latest/download/Oxide.Rust-linux.zip +else + OXIDE_URL=https://downloads.oxidemod.com/artifacts/Oxide.Rust/$branch/Oxide.Rust-linux.zip +fi + +# The branch Steam has INSTALLED: MountedConfig's key (UserConfig's is only the one asked for). The +# public branch has no key. +mounted() { + sed -n '/"MountedConfig"/,/}/s/^[[:space:]]*"BetaKey"[[:space:]]*"\([^"]*\)".*/\1/p' steamapps/appmanifest_258550.acf | head -1 +} + +# A branch switch downloads the whole server (5.4 GB), and one can fail ("UpdateResult" 13 on +# 2026-10-06, the next attempt fine). Rust on one branch with Oxide's build for another dies at boot, +# so the server is not started on a pair that does not match. +want=$branch +[ "$want" = public ] && want="" +for attempt in 1 2; do + echo "[staging-drill] Rust: app_update 258550 -beta $branch (attempt $attempt)" + ./steamcmd/steamcmd.sh +force_install_dir /home/container +login anonymous +app_update 258550 -beta "$branch" +quit + installed=$(mounted) + [ "${installed:-public}" = "${want:-public}" ] && break +done +if [ "${installed:-public}" != "${want:-public}" ]; then + echo "[staging-drill] Steam installed '${installed:-public}', not '$branch': not starting" + exit 1 +fi + +# Oxide's build carries its own patched Assembly-CSharp.dll, so it goes on AFTER Rust. +echo "[staging-drill] Oxide: $OXIDE_URL" +oxide_modified=$(curl -sSIL "$OXIDE_URL" | tr -d '\r' | sed -n 's/^[Ll]ast-[Mm]odified: //p' | tail -1) +if curl -sSL --fail -o oxide-drill.zip "$OXIDE_URL" && unzip -o -q oxide-drill.zip; then + oxide_ok=true +else + oxide_ok=false + echo "[staging-drill] Oxide's $branch build could not be installed" +fi +rm -f oxide-drill.zip + +buildid=$(sed -n 's/^[[:space:]]*"buildid"[[:space:]]*"\([0-9]*\)".*/\1/p' steamapps/appmanifest_258550.acf | head -1) +printf '{"buildid":"%s","branch":"%s","oxideModified":"%s","oxideInstalled":%s,"at":"%s"}\n' \ + "$buildid" "${installed:-public}" "$oxide_modified" "$oxide_ok" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > staging-drill.json +echo "[staging-drill] $(cat staging-drill.json)" +if [ "$oxide_ok" != true ]; then exit 1; fi + +exec "$@" diff --git a/tools/drill/static.sh b/tools/drill/static.sh new file mode 100644 index 0000000..5cf7d47 --- /dev/null +++ b/tools/drill/static.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# The staging drill's static half (docs/runicnpc/PLAN.md D308). Never shipped. +# +# tools/drill/static.sh (WORK=drill-work BRANCH=staging by default) +# +# Needs no server. It downloads only RustDedicated_Data/Managed/*.dll from a Steam branch, and +# Oxide's build for that branch, and: +# +# 1. asks whether Oxide's build has caught up with Rust's (tools/fieldlist --oxide-behind). Until it +# has, an Oxide server on that branch dies at boot, so the drill waits for it (D317); +# 2. regenerates the swap's field list (tools/fieldlist, D232) from Rust's own Assembly-CSharp.dll +# and compares it with the one plugin/RunicNPC.cs carries; +# 3. compiles plugin/RunicNPC.cs against those assemblies with Oxide's laid over them, as an Oxide +# server would. Oxide's patched Assembly-CSharp.dll is needed: the plugin uses members Rust +# ships non-public (IAISenses, for one). +# +# It writes $WORK/static.json and, beside it, oxide-behind.txt, fields.diff and compile.txt. In +# static.json, oxideBehind is a count (-1: not measured), fields "same"/"differs", compile +# "ok"/"errors", and either is "error" when its step could not run. The script exits 0 whenever it +# ran; what it found is in static.json. DEPOTDOWNLOADER and DOTNET point at other copies (on +# Windows, DEPOTDOWNLOADER=…/DepotDownloader.exe from Git Bash). + +set -uo pipefail + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +WORK=${WORK:-$ROOT/drill-work} +BRANCH=${BRANCH:-staging} +if [ "$BRANCH" = public ]; then + OXIDE_URL=${OXIDE_URL:-https://github.com/OxideMod/Oxide.Rust/releases/latest/download/Oxide.Rust-linux.zip} +else + OXIDE_URL=${OXIDE_URL:-https://downloads.oxidemod.com/artifacts/Oxide.Rust/$BRANCH/Oxide.Rust-linux.zip} +fi +DD_URL=https://github.com/SteamRE/DepotDownloader/releases/download/DepotDownloader_3.4.0/DepotDownloader-linux-x64.zip +DOTNET=${DOTNET:-dotnet} + +mkdir -p "$WORK" +rm -rf "$WORK/depot" "$WORK/oxide" "$WORK/refs" "$WORK/fields" +rm -f "$WORK"/static.json "$WORK"/oxide-behind.txt "$WORK"/fields.diff "$WORK"/compile.txt + +managed=error +behind=-1 +fields=error +compile=error +note="" +oxide_modified="" +manifest="" +manifest_date="" + +finish() { + printf '{"branch":"%s","manifest":"%s","manifestDate":"%s","managed":"%s","oxideBehind":%s,"fields":"%s","compile":"%s","oxideModified":"%s","note":"%s"}\n' \ + "$BRANCH" "$manifest" "$manifest_date" "$managed" "$behind" "$fields" "$compile" "$oxide_modified" "$note" > "$WORK/static.json" + cat "$WORK/static.json" + exit 0 +} + +# Git Bash does not translate paths inside a response file; elsewhere this is the path as it is. +native() { if command -v cygpath > /dev/null; then cygpath -m "$1"; else printf '%s' "$1"; fi; } + +# ---- Rust's managed assemblies, from Steam (no login: the dedicated server is anonymous) ---- + +DD=${DEPOTDOWNLOADER:-} +if [ -z "$DD" ]; then + curl -sSL --fail -o "$WORK/dd.zip" "$DD_URL" && unzip -oq "$WORK/dd.zip" -d "$WORK/dd" && chmod +x "$WORK/dd/DepotDownloader" \ + || { note="DepotDownloader could not be fetched"; finish; } + DD=$WORK/dd/DepotDownloader +fi + +printf 'regex:^RustDedicated_Data/Managed/[^/]+\\.dll$\n' > "$WORK/files.txt" +"$DD" -app 258550 -depot 258552 -branch "$BRANCH" -filelist "$WORK/files.txt" -dir "$WORK/depot" -os linux \ + > "$WORK/depot.txt" 2>&1 +MANAGED=$WORK/depot/RustDedicated_Data/Managed +# Which build this is, from Steam itself: the Linux depot's manifest and its date. (api.steamcmd.net +# answered a build id a day stale on 2026-10-06, so the drill does not ask it.) +manifest=$(tr -d '\r' < "$WORK/depot.txt" | sed -n 's/^Manifest \([0-9]*\) (\(.*\))$/\1/p' | head -1) +manifest_date=$(tr -d '\r' < "$WORK/depot.txt" | sed -n 's/^Manifest \([0-9]*\) (\(.*\))$/\2/p' | head -1) +if [ ! -f "$MANAGED/Assembly-CSharp.dll" ]; then + note="no Assembly-CSharp.dll from Steam's $BRANCH branch: $(tail -3 "$WORK/depot.txt" | tr '\n"' ' ')" + finish +fi +managed=ok + +# ---- Oxide's build for the branch ---- + +oxide_modified=$(curl -sSIL "$OXIDE_URL" | tr -d '\r' | sed -n 's/^[Ll]ast-[Mm]odified: //p' | tail -1) +if ! { curl -sSL --fail -o "$WORK/oxide.zip" "$OXIDE_URL" && unzip -oq "$WORK/oxide.zip" -d "$WORK/oxide"; }; then + note="Oxide's $BRANCH build could not be downloaded" + finish +fi +OXIDE_MANAGED=$WORK/oxide/RustDedicated_Data/Managed + +# The tool is built once, here, so a build failure is not mistaken for anything it reports. +if ! "$DOTNET" build "$ROOT/tools/fieldlist" -c Release -o "$WORK/fieldlist" > "$WORK/fieldlist-build.txt" 2>&1; then + note="tools/fieldlist did not build: $(grep -m1 'error' "$WORK/fieldlist-build.txt" | tr '"' ' ')" + finish +fi +FIELDLIST=("$DOTNET" "$WORK/fieldlist/FieldList.dll") + +# ---- 1. Has Oxide caught up? ---- + +if "${FIELDLIST[@]}" --oxide-behind "$MANAGED" "$OXIDE_MANAGED/Assembly-CSharp.dll" > "$WORK/oxide-behind.txt" 2>&1; then + behind=$(sed -n 's/^behind \([0-9]*\)$/\1/p' "$WORK/oxide-behind.txt" | head -1) + behind=${behind:--1} +fi + +# ---- 2. The swap's field list (D232) ---- + +mkdir -p "$WORK/fields" +cp "$ROOT/plugin/RunicNPC.cs" "$WORK/fields/RunicNPC.cs" +block() { sed -n '/\/\/ /,/\/\/ <\/fieldlist>/p' "$1" | grep -v "Generated by tools/fieldlist" | tr -d '\r'; } +if "${FIELDLIST[@]}" "$MANAGED" "$WORK/fields/RunicNPC.cs" > "$WORK/fields/run.txt" 2>&1; then + if diff -u --label carried <(block "$ROOT/plugin/RunicNPC.cs") --label "$BRANCH" <(block "$WORK/fields/RunicNPC.cs") > "$WORK/fields.diff"; then + fields=same + else + fields=differs + fi +else + cp "$WORK/fields/run.txt" "$WORK/fields.diff" +fi + +# ---- 3. Compile against the branch with Oxide's build laid over ---- + +mkdir -p "$WORK/refs" +cp "$MANAGED"/*.dll "$WORK/refs/" +cp "$OXIDE_MANAGED"/*.dll "$WORK/refs/" +# Oxide.References.dll bundles its own older copy of Newtonsoft.Json, which Oxide's compiler never +# references; with it, every JObject is ambiguous. +rm -f "$WORK/refs/Oxide.References.dll" + +DOTNET_DIR=$(dirname "$(readlink -f "$(command -v "$DOTNET")")") +CSC=$(ls "$DOTNET_DIR"/sdk/*/Roslyn/bincore/csc.dll 2>/dev/null | tail -1) +if [ -z "$CSC" ]; then + note="no csc.dll under $DOTNET_DIR/sdk" + finish +fi + +# A response file: some 250 references outgrow a Windows command line. +for f in "$WORK/refs"/*.dll; do printf '"-r:%s"\n' "$(native "$f")"; done > "$WORK/refs.rsp" +if "$DOTNET" "$CSC" -nologo -noconfig -nostdlib -target:library -nowarn:1701,1702 -warn:0 \ + -out:"$WORK/RunicNPC.dll" "@$WORK/refs.rsp" "$ROOT/plugin/RunicNPC.cs" > "$WORK/compile.txt" 2>&1; then + compile=ok +else + compile=errors +fi + +finish diff --git a/tools/fieldlist/Program.cs b/tools/fieldlist/Program.cs index a76a5df..de8dd63 100644 --- a/tools/fieldlist/Program.cs +++ b/tools/fieldlist/Program.cs @@ -12,12 +12,28 @@ // the unmodified assembly, it picks the same fields on both frameworks. The result replaces the // block between the two marker lines in the plugin, and the plugin resolves each name at load. +// +// The staging drill (D308) also asks it whether Oxide's build has caught up with Rust's: +// +// dotnet run --project tools/fieldlist -- --oxide-behind +// +// Oxide ships its own patched Assembly-CSharp.dll, which replaces Rust's. Built from an older Rust, +// it lacks what Rust has added since, and the server dies at boot ("The referenced script +// (ItemModFoodVisual) on this Behaviour is missing!"). Oxide's patcher only ever adds, so every +// type and member Rust's own assembly declares, compiler-generated ones aside, must be in Oxide's. +// It prints "behind " and the first of them; 0 means caught up. Measured 2026-10-06: public Rust +// and public Oxide, 0; staging Rust 25766353 and Oxide's staging build of 2026-10-05, 482. + using System.Reflection; using System.Text; +if (args.Length == 3 && args[0] == "--oxide-behind") + return OxideBehind(args[1], args[2]); + if (args.Length < 1) { Console.Error.WriteLine("usage: dotnet run --project tools/fieldlist -- [plugin/RunicNPC.cs]"); + Console.Error.WriteLine(" dotnet run --project tools/fieldlist -- --oxide-behind "); return 2; } @@ -96,3 +112,28 @@ string replacement = block.ToString().Replace("\r\n", "\n").Replace("\n", nl); File.WriteAllText(plugin, text[..start] + replacement + text[(end + Close.Length)..]); Console.WriteLine($"{plugin}: {npc.Count} NPC fields, {brain.Count} brain fields (Assembly-CSharp module {mvid})."); return 0; + +static int OxideBehind(string managed, string oxideAssembly) +{ + HashSet Declared(string assembly) + { + IEnumerable others = Directory.GetFiles(managed, "*.dll").Where(f => Path.GetFileName(f) != "Assembly-CSharp.dll"); + using var context = new MetadataLoadContext(new PathAssemblyResolver(others.Append(assembly)), "mscorlib"); + var names = new HashSet(); + foreach (Type t in context.LoadFromAssemblyPath(assembly).GetTypes()) + { + names.Add(t.FullName!); + foreach (MemberInfo m in t.GetMembers(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + names.Add($"{t.FullName}::{m.Name}"); + } + // Compiler-generated names (lambdas, closures, iterators) are renumbered by every build. + names.RemoveWhere(n => n.Contains('<')); + return names; + } + + List missing = Declared(Path.Combine(managed, "Assembly-CSharp.dll")).Except(Declared(oxideAssembly)).OrderBy(n => n).ToList(); + Console.WriteLine($"behind {missing.Count}"); + foreach (string n in missing.Take(20)) + Console.WriteLine(" " + n); + return 0; +} diff --git a/tools/rigs.example.json b/tools/rigs.example.json index 5837e15..0018fa3 100644 --- a/tools/rigs.example.json +++ b/tools/rigs.example.json @@ -3,6 +3,7 @@ "tokenFile": "/path/to/pterodactyl_api_token", "rigs": { "oxide": "00000000", - "carbon": "00000000" + "carbon": "00000000", + "staging": "00000000" } } -- 2.49.1 From d7ba93530213dab62a748565535e71f21b1c40e4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 7 Oct 2026 01:32:22 -0500 Subject: [PATCH 20/24] fix(drill): a wait from an earlier week starts over; say when a run by hand goes ahead Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- tools/drill/drill.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/drill/drill.js b/tools/drill/drill.js index 294250c..d7bf19e 100644 --- a/tools/drill/drill.js +++ b/tools/drill/drill.js @@ -187,7 +187,8 @@ async function window_(a) { const days = windowDays(a.today ? new Date(a.today) : new Date()) const wipe = nextWipe(a.today ? new Date(a.today) : new Date()).toISOString().slice(0, 10) const open = days >= 1 && days <= 7 - log(`the next forced wipe is ${wipe}, in ${days} day(s): ${open ? 'in the drill week' : 'outside the drill week, nothing to do'}`) + const verdict = open ? 'in the drill week' : a.force ? 'outside the drill week, but run by hand: going ahead' : 'outside the drill week, nothing to do' + log(`the next forced wipe is ${wipe}, in ${days} day(s): ${verdict}`) output('open', open || a.force ? 'true' : 'false') } @@ -475,7 +476,9 @@ async function report(a) { findings.push(`The static half could not run: ${(stat && stat.note) || 'no static.json'}`) if (stat && stat.fields === 'error') sections.push(['tools/fieldlist', read('fields.diff')]) } else if (stat.oxideBehind !== 0) { - const waiting = state.waiting && state.waiting.manifest === manifest ? state.waiting : { manifest, since: now() } + // A wait from an earlier drill week (or a run by hand outside one) starts over. + const current = state.waiting && state.waiting.manifest === manifest && Date.now() - Date.parse(state.waiting.since) < 7 * 86400000 + const waiting = current ? state.waiting : { manifest, since: now() } const days = (Date.now() - Date.parse(waiting.since)) / 86400000 if (days < WAIT_DAYS) { outcome = `waiting for Oxide (${stat.oxideBehind} behind, day ${Math.floor(days) + 1} of ${WAIT_DAYS})` -- 2.49.1 From 6b15d68071903026b8c76d98b785e12c13336635 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 8 Oct 2026 23:53:01 -0500 Subject: [PATCH 21/24] fix(examples): each example NPC its own outfit, every item at full durability (D323, D324) The 9d player walk found the example kits looked alike and shipped broken armour. - D323: the raider wears scrap armour, the camp guard metal, the sniper hide, the Juggernaut heavy plate. Weapons are unchanged. - D324: Kits sets an item's condition straight from its file, and the writer put 0 on armour (heavy plate 0 of 1000) and a flat 100 on weapons (an M249's maximum is 500). Each item is now written at this Rust's own maximum for it, read from the item definition; 0 where the item has none. - The harness checks it: s6.examples.fullCondition. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin/RunicNPC.cs | 37 +++++++++++++++++++++++++++---------- tools/RunicNpcTest.cs | 12 ++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index c406d32..25a8737 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -4126,8 +4126,9 @@ namespace Oxide.Plugins /// Placements wait while Kits reloads with the example kits (D283 reloads RunicNPC with it). private bool _holdForKits; - private static JObject ExItem(string shortname, int amount, int position, float condition = 0f) => - JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = condition, MaxCondition = condition, Ammo = 0, Position = position, Frequency = -1 }); + /// Condition is filled in when the kit is written, from this Rust's item definition (D324). + private static JObject ExItem(string shortname, int amount, int position) => + JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = 0f, MaxCondition = 0f, Ammo = 0, Position = position, Frequency = -1 }); private static JObject ExKit(string name, JObject[] main, JObject[] wear, JObject[] belt) => new JObject { @@ -4139,17 +4140,24 @@ namespace Oxide.Plugins private static JObject[] Wear(params string[] items) => items.Select((s, i) => ExItem(s, 1, i)).ToArray(); - /// D289: the four kits. A short name this Rust does not know is left out with a warning. + /// + /// D289: the four kits, each in its own outfit (D323): the raider in scrap armour, the camp guard in metal, + /// the sniper in hide, the Juggernaut in heavy plate. A short name this Rust does not know is left out with + /// a warning. + /// private static Dictionary ExampleKits() => new Dictionary { ["rnpc_raider"] = ExKit("rnpc_raider", new[] { ExItem("ammo.rifle", 120, 0) }, - Wear("hoodie", "pants", "shoes.boots", "mask.bandana"), new[] { ExItem("rifle.semiauto", 1, 0, 100f) }), + Wear("coffeecan.helmet", "roadsign.jacket", "roadsign.kilt", "hoodie", "pants", "shoes.boots", "burlap.gloves"), + new[] { ExItem("rifle.semiauto", 1, 0) }), ["rnpc_campguard"] = ExKit("rnpc_campguard", new[] { ExItem("ammo.pistol", 200, 0) }, - Wear("metal.facemask", "metal.plate.torso", "hoodie", "pants", "shoes.boots"), new[] { ExItem("smg.thompson", 1, 0, 100f) }), + Wear("metal.facemask", "metal.plate.torso", "tshirt.long", "pants", "shoes.boots", "roadsign.gloves"), + new[] { ExItem("smg.thompson", 1, 0) }), ["rnpc_sniper"] = ExKit("rnpc_sniper", new[] { ExItem("ammo.rifle", 60, 0) }, - Wear("hat.boonie", "hoodie", "pants", "shoes.boots"), new[] { ExItem("rifle.bolt", 1, 0, 100f) }), + Wear("hat.boonie", "mask.balaclava", "attire.hide.poncho", "attire.hide.pants", "attire.hide.boots"), + new[] { ExItem("rifle.bolt", 1, 0) }), ["rnpc_juggernaut"] = ExKit("rnpc_juggernaut", new[] { ExItem("ammo.rifle", 300, 0) }, - Wear("heavy.plate.helmet", "heavy.plate.jacket", "heavy.plate.pants", "shoes.boots"), new[] { ExItem("lmg.m249", 1, 0, 100f) }) + Wear("heavy.plate.helmet", "heavy.plate.jacket", "heavy.plate.pants", "shoes.boots"), new[] { ExItem("lmg.m249", 1, 0) }) }; private static JObject ExRanges(float sense, float lose, float chase, float attack) => @@ -4225,10 +4233,19 @@ namespace Oxide.Plugins var known = new JArray(); foreach (JToken item in (JArray)kv.Value[list]) { - if (ItemManager.FindItemDefinition((string)item["Shortname"]) != null) - known.Add(item); - else + ItemDefinition def = ItemManager.FindItemDefinition((string)item["Shortname"]); + if (def == null) + { PrintWarning($"Example kit {kv.Key}: this Rust has no item '{item["Shortname"]}', so it is left out."); + continue; + } + + // D324: Kits sets an item's condition and maximum straight from the file, so each is + // written at this Rust's full durability for it (0 where the item has none). + float max = def.condition.enabled ? def.condition.max : 0f; + item["Condition"] = max; + item["MaxCondition"] = max; + known.Add(item); } kv.Value[list] = known; diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 98e0800..1a7f52b 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -2423,6 +2423,18 @@ namespace Oxide.Plugins JObject items = kitFile?["_kits"]?["rnpc_juggernaut"] as JObject; int itemCount = items == null ? 0 : ((JArray)items["MainItems"]).Count + ((JArray)items["WearItems"]).Count + ((JArray)items["BeltItems"]).Count; Check("s6.examples.items", itemCount == 6, $"rnpc_juggernaut carries {itemCount} of its 6 items (a short name this Rust lacks would be left out)"); + // D324: every item is written at this Rust's full durability for it, 0 where it has none. + var worn = new List(); + foreach (string k in exampleKits) + foreach (string list in new[] { "MainItems", "WearItems", "BeltItems" }) + foreach (JToken it in (kitFile?["_kits"]?[k]?[list] as JArray) ?? new JArray()) + { + ItemDefinition def = ItemManager.FindItemDefinition((string)it["Shortname"]); + float want = def != null && def.condition.enabled ? def.condition.max : 0f; + if ((float)it["Condition"] != want || (float)it["MaxCondition"] != want) + worn.Add($"{k}/{it["Shortname"]} {it["Condition"]}/{it["MaxCondition"]} (want {want})"); + } + Check("s6.examples.fullCondition", worn.Count == 0, worn.Count == 0 ? "every item at its full durability" : string.Join("; ", worn.ToArray())); } // ---- stage 7: loot (D290–D301) ---- -- 2.49.1 From 246ad17c60b577748cf324bde6f2cbaf31efcf62 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 9 Oct 2026 00:11:41 -0500 Subject: [PATCH 22/24] feat(commands): rnpc here stands the NPC a metre in front, facing you (D325) At the admin's feet it spawned inside them, looking away (9d player walk). It now stands 1 m ahead on the ground, turned to face the admin. If something is in the way at chest height, there is no ground within 2 m below, or a walker would have no navmesh, it falls back to the admin's own spot and the reply says so. The harness's cmd.chat.here now checks the metre. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin/RunicNPC.cs | 40 ++++++++++++++++++++++++++++++++++++++-- tools/RunicNpcTest.cs | 4 +++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index 25a8737..5b0de53 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -5179,6 +5179,39 @@ namespace Oxide.Plugins // ---- where an admin stands and looks ---- /// The ground, a floor or a roof the admin is looking at, within . + /// D325: how far in front of the admin rnpc here stands the NPC, centre to centre. + private const float HereAhead = 1f; + + /// + /// D325: the spot in front of the admin, on the ground, facing them. False, with the + /// admin's own spot and facing, when it does not fit: something in the way at chest height, no ground within + /// below their feet, or, for one that walks, no navmesh there. + /// + private static bool HereSpot(BasePlayer player, bool sentry, out Vector3 spot, out float yaw) + { + Vector3 feet = player.transform.position; + Vector3 ahead = player.eyes.HeadForward(); + ahead.y = 0f; + if (ahead.sqrMagnitude < 0.01f) + ahead = new Vector3(player.transform.forward.x, 0f, player.transform.forward.z); + ahead.Normalize(); + + Vector3 chest = feet + Vector3.up; + RaycastHit hit; + if (!Physics.SphereCast(chest, 0.3f, ahead, out hit, HereAhead, GroundMask, QueryTriggerInteraction.Ignore) && + Physics.Raycast(chest + ahead * HereAhead, Vector3.down, out hit, 1f + MaxDrop, GroundMask, QueryTriggerInteraction.Ignore) && + (sentry || OffMeshReason(hit.point) == null)) + { + spot = hit.point; + yaw = Quaternion.LookRotation(-ahead).eulerAngles.y; + return true; + } + + spot = feet; + yaw = player.eyes.rotation.eulerAngles.y; + return false; + } + private static bool LookSpot(BasePlayer player, out Vector3 spot) { RaycastHit hit; @@ -5433,6 +5466,7 @@ namespace Oxide.Plugins Vector3 spot; float yaw; + string hereNote = null; if (o.At.HasValue) { spot = o.At.Value; @@ -5445,8 +5479,8 @@ namespace Oxide.Plugins } else if (here) { - spot = c.Player.transform.position; - yaw = c.Player.eyes.rotation.eulerAngles.y; + if (!HereSpot(c.Player, p.IsSentry, out spot, out yaw)) + hereNote = $"There is no room {HereAhead:0} m in front of you, so it stands where you do."; } else { @@ -5511,6 +5545,8 @@ namespace Oxide.Plugins Movement m = movement ?? p.Movement; c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " + $"respawn {data.Respawn:0} s, {data.RespawnMode}{(data.Tether != null ? $", inside zone {data.Tether}" : "")}. Rename it with rnpc rename {id} ."); + if (hereNote != null) + c.Say(hereNote); if (built) c.Say("This spot is on a player-built structure. If it is destroyed, the NPC falls back to the nearest navmesh."); c.Say(CostWarning(PlannedTotal())); diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 1a7f52b..ed7b7ba 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -1297,7 +1297,9 @@ namespace Oxide.Plugins permission.GrantUserPermission(uid, "runicnpc.place", null); RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "here", "t_roam", "count=1" }); JObject chatPlaced = PlacementData("t_roam-2"); - Check("cmd.chat.here", chatPlaced != null && Flat(V(chatPlaced["position"].ToObject>()), b) < 1f, "" + chatPlaced?.ToString(Formatting.None)); + // D325: a metre in front of the admin, not at their feet. + float hereAway = chatPlaced == null ? -1f : Flat(V(chatPlaced["position"].ToObject>()), admin.transform.position); + Check("cmd.chat.here", hereAway > 0.8f && hereAway < 1.2f, $"{hereAway:0.00} m from the admin; " + chatPlaced?.ToString(Formatting.None)); RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "respawn", "t_roam-2" }); Check("cmd.chat.adminNeedsAdmin", Row("t_roam-2") != null, "respawn is refused with runicnpc.place alone (no error)"); RunicNPC.Call("CmdChat", admin, "rnpc", new[] { "remove", "t_roam-2" }); -- 2.49.1 From f9210e3a4fce604268b16967c645eb65b444dabc Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 9 Oct 2026 02:07:14 -0500 Subject: [PATCH 23/24] fix(examples): a Kits reload owed for the example kits survives an interrupted load (D329) The first load writes the example kits into Kits' file and asked for a Kits reload on a one-second timer. A timer dies with its plugin, so a second load of RunicNPC inside that second (the 9d session's, on Carbon) lost the reload, and examplesWritten was already set: the examples stayed refused ("no kit rnpc_juggernaut") until Kits was reloaded by hand. The kits written are now owed a reload in data/RunicNPC/state.json (kitsReloadOwed). SettleKitsReload, on every load and when Kits comes back, clears the debt once Kits knows every kit owed, and otherwise asks for the reload again, at most three times, then warns with the command to run. Oxide never showed it: loading RunicNPC recompiles and reloads Kits there (// Requires: Kits). Carbon does not. Walked on both rigs (Rust 25797215, Oxide 2.0.7815, Carbon 2.0.262): a first install landing on a running server, the interrupted state, and a debt for a kit that never exists (three reloads, the warning, no loop). The harness gains s6.examples.reloadSettled (0.7.3; compiled on the Carbon rig). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin/RunicNPC.cs | 52 ++++++++++++++++++++++++++++++++++++++----- tools/RunicNpcTest.cs | 5 ++++- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index 5b0de53..67b29a8 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -4121,8 +4121,16 @@ namespace Oxide.Plugins { /// D284: set on the first load that wrote them, and never cleared. [JsonProperty("examplesWritten")] public bool ExamplesWritten; + + /// D329: the example kits written that Kits has not loaded yet. The reload is owed until it has. + [JsonProperty("kitsReloadOwed")] public List KitsReloadOwed = new List(); + + /// D329: the loads that have asked for that reload. At most three, so a kit Kits never takes cannot loop. + [JsonProperty("kitsReloadTries")] public int KitsReloadTries; } + private const int KitsReloadTriesMax = 3; + /// Placements wait while Kits reloads with the example kits (D283 reloads RunicNPC with it). private bool _holdForKits; @@ -4206,8 +4214,8 @@ namespace Oxide.Plugins /// /// D284: on the first load only, the four example kits go into Kits' own data file (Kits has no /// API that creates one, D280) and the four profiles into ours, each only where its name is free. - /// The flag is set before anything is written, so this runs once whatever happens. Kits is then - /// reloaded, which reloads RunicNPC too (D283); placements wait for it. + /// The flag is set before anything is written, so this runs once whatever happens. The kits it wrote + /// are then owed a Kits reload (D329), which SettleKitsReload asks for; it reloads RunicNPC too (D283). /// private void WriteExamplesOnce() { @@ -4286,11 +4294,43 @@ namespace Oxide.Plugins if (addedKits.Count == 0) return; - // Kits reads its file only when it loads (stage 6 spike, finding 5). The reload takes RunicNPC down and - // back with it (D283); if this framework does not, OnPluginLoaded("Kits") picks the profiles up. + // Kits reads its file only when it loads (stage 6 spike, finding 5), so a reload is owed; SettleKitsReload asks for it. + state.KitsReloadOwed = addedKits; + state.KitsReloadTries = 0; + Interface.Oxide.DataFileSystem.WriteObject(DataState, state); + } + + /// + /// D329: asks Kits to reload while the example kits written are still owed, and clears the debt once Kits + /// knows them all. The reload runs on a timer, which dies with RunicNPC: in the 9d session a second load of + /// RunicNPC within that second lost it, and the examples stayed refused until Kits was reloaded by hand. + /// Owing it in the state file lets any later load (or Kits coming back) finish the job. + /// + private void SettleKitsReload() + { + StateFile state = ReadData(DataState); + if (state.KitsReloadOwed == null || state.KitsReloadOwed.Count == 0) + return; + + string command = Framework() == "carbon" ? "c.reload" : "oxide.reload"; + if (state.KitsReloadOwed.All(KitExists) || state.KitsReloadTries >= KitsReloadTriesMax) + { + if (state.KitsReloadTries >= KitsReloadTriesMax && !state.KitsReloadOwed.All(KitExists)) + PrintWarning($"Kits has still not loaded the example kits ({string.Join(", ", state.KitsReloadOwed.ToArray())}) after " + + $"{KitsReloadTriesMax} reloads. Run `{command} Kits` once."); + state.KitsReloadOwed = new List(); + state.KitsReloadTries = 0; + Interface.Oxide.DataFileSystem.WriteObject(DataState, state); + return; + } + + state.KitsReloadTries++; + Interface.Oxide.DataFileSystem.WriteObject(DataState, state); + + // The reload takes RunicNPC down and back with it (D283); if this framework does not, OnPluginLoaded("Kits") + // picks the profiles up. Placements wait for it. _holdForKits = true; timer.Once(60f, () => _holdForKits = false); - string command = Framework() == "carbon" ? "c.reload" : "oxide.reload"; timer.Once(1f, () => ConsoleSystem.Run(ConsoleSystem.Option.Server, command, "Kits")); } @@ -4475,6 +4515,7 @@ namespace Oxide.Plugins ResolveFields(); LoadData(); WriteExamplesOnce(); + SettleKitsReload(); RevalidateProfiles(); RebuildPlacements(); TrackWorld(); @@ -4526,6 +4567,7 @@ namespace Oxide.Plugins if (plugin == null || plugin.Name != "Kits" || !_ready) return; _holdForKits = false; + SettleKitsReload(); RevalidateProfiles(); TickPlacements(); } diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index ed7b7ba..a16a4a2 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -45,7 +45,7 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [Info("RunicNpcTest", "Runic Gateway", "0.7.2")] + [Info("RunicNpcTest", "Runic Gateway", "0.7.3")] [Description("RunicNPC stage 2 to 7 tests, the stage 6 spike and stage 9's cost check. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { @@ -2422,6 +2422,9 @@ namespace Oxide.Plugins "each needs kits.runicnpc, which nobody is granted"); JObject state = Interface.Oxide.DataFileSystem.ReadObject("RunicNPC/state"); Check("s6.examples.once", state?["examplesWritten"]?.Type == JTokenType.Boolean && (bool)state["examplesWritten"], "state.json: " + state?.ToString(Formatting.None)); + // D329: once Kits knows the examples, no reload is owed (a state from before D329 has no list at all). + JArray owed = state?["kitsReloadOwed"] as JArray; + Check("s6.examples.reloadSettled", owed == null || owed.Count == 0, "kitsReloadOwed: " + (owed?.ToString(Formatting.None) ?? "absent")); JObject items = kitFile?["_kits"]?["rnpc_juggernaut"] as JObject; int itemCount = items == null ? 0 : ((JArray)items["MainItems"]).Count + ((JArray)items["WearItems"]).Count + ((JArray)items["BeltItems"]).Count; Check("s6.examples.items", itemCount == 6, $"rnpc_juggernaut carries {itemCount} of its 6 items (a short name this Rust lacks would be left out)"); -- 2.49.1 From 89df052d7daf7548e93907886eef4a6ce4be53aa Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 9 Oct 2026 02:14:13 -0500 Subject: [PATCH 24/24] feat!: RunicNPC 1.0.0, the release module-rust requires (runicnpc 9e) Stage 9's release (D311, D313). Every stage before it landed as `feat:`, so the release engine, which reads non-merge subjects since v0.1.0, would cut v0.2.0 at the cutover. This commit's `!` makes it v1.0.0, as the plan and the README say (the org lead's choice, 2026-10-09). The README's status line now says v1.0.0, and its Releases section says how the number was reached. No code changes. BREAKING CHANGE: 1.0.0 is the release a Runic Gateway server requires. module-rust refuses every Place NPCs step on a server without RunicNPC (D310), and the bridge declares API 6 as its floor. Nothing in the API itself changes shape here: it stays version 6. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8c9e4e7..891a161 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicG website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is [`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). -> **Status: stage 9, hardening for v1.0.0.** The NPC, its profiles, placements and routes; factions, +> **Status: v1.0.0, stage 9's release.** The NPC, its profiles, placements and routes; factions, > fights, escorts and tethers; bosses and passive NPCs; loot. Admins use the `/rnpc` commands, other > plugins the API (version 6). On a Runic Gateway server RunicNPC is **required** (D310): the website > authors its profiles and faction table, edits its placements, and every NPC an event places is @@ -184,7 +184,8 @@ and +5.2 ms fighting to the median frame. Load `tools/RunicNpcTest.cs` on a rig Work lands on `edge` and is cut over to `main`; every releasable push to `main` tags and publishes a release (`.gitea/workflows/release.yml`). The version comes from Conventional Commits since the -last tag. `v1.0.0` is stage 9's release, the one `module-rust` then requires. +last tag. `v1.0.0` is stage 9's release, the one `module-rust` requires; it was cut by a `feat!:` +commit, because the stages before it were all `feat:` and would have made it `v0.2.0`. ## Contributing -- 2.49.1