From 0a57d783ca900e2822ea008316cb3a8d7e715bda Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 03:17:43 -0500 Subject: [PATCH] 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