feat: stage 3, the /rnpc commands in game
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 7s

The chat command /rnpc and the same verbs as rnpc.<verb> 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 <profile>-<n> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-30 03:17:43 -05:00
parent 15120ef80d
commit 0a57d783ca
3 changed files with 1460 additions and 21 deletions

View File

@@ -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 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). [`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. > **Status: stage 3.** The NPC, its profiles, placements and routes, the API other plugins call, and
> The in-game `/rnpc` commands arrive in stage 3; until then profiles are edited in > the `/rnpc` commands admins use in game. Runic Gateway's website takes over the profiles in stage 4.
> `data/RunicNPC/profiles.json` (then `rnpc.reload`), and placements and routes are made through the API.
## Requirements ## 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 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. renamed it, because neither framework reports a hook that matches nothing.
## In game
`/rnpc` in chat, and the same verbs as `rnpc.<verb>` 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 ## For other plugins
Every call is prefixed `RunicNpc_` and reached through `Call`: 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/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. | | `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). | | `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 ## After a Rust update: the swap's field list

File diff suppressed because it is too large Load Diff

View File

@@ -13,9 +13,10 @@ using UnityEngine.AI;
namespace Oxide.Plugins namespace Oxide.Plugins
{ {
/// <summary> /// <summary>
/// RunicNPC's stage 2 test harness (docs/runicnpc/PLAN.md §9, stage 2). NEVER SHIPPED: it lives /// 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 only through its API and hooks, /// in tools/ and is loaded on a rig by hand. It drives RunicNPC through its API, its hooks and its
/// as any other plugin would, and prints one line per assertion: /// console commands (the chat command through a stand-in player), and prints one line per
/// assertion:
/// ///
/// <code>[rnt] &lt;group&gt;.&lt;check&gt;: PASS|FAIL &lt;detail&gt;</code> /// <code>[rnt] &lt;group&gt;.&lt;check&gt;: PASS|FAIL &lt;detail&gt;</code>
/// ///
@@ -23,7 +24,7 @@ namespace Oxide.Plugins
/// ///
/// <para> /// <para>
/// <c>rnt.run &lt;group&gt;</c> runs one group: <c>api</c>, <c>hooks</c>, <c>move</c>, /// <c>rnt.run &lt;group&gt;</c> runs one group: <c>api</c>, <c>hooks</c>, <c>move</c>,
/// <c>sentry</c>, <c>sleep</c>, <c>place</c>, or <c>all</c> for each in turn. /// <c>sentry</c>, <c>sleep</c>, <c>cmd</c> (stage 3's commands), <c>place</c>, or <c>all</c> for each in turn.
/// <c>rnt.run place</c> leaves two placements behind for the restart check, and /// <c>rnt.run place</c> leaves two placements behind for the restart check, and
/// <c>rnt.after</c> checks, after a reload or restart, that every placement is back and nothing /// <c>rnt.after</c> checks, after a reload or restart, that every placement is back and nothing
/// else is. <c>rnt.spawn [n]</c> spawns plugin-owned NPCs, for the check that they die with this plugin, and /// else is. <c>rnt.spawn [n]</c> 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 <c>IsNpc</c> is false. They take no damage. /// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
/// </para> /// </para>
/// </summary> /// </summary>
[Info("RunicNpcTest", "Runic Gateway", "0.2.0")] [Info("RunicNpcTest", "Runic Gateway", "0.3.0")]
[Description("RunicNPC stage 2 tests. A developer tool: never ship it.")] [Description("RunicNPC stage 2 and 3 tests. A developer tool: never ship it.")]
internal class RunicNpcTest : RustPlugin internal class RunicNpcTest : RustPlugin
{ {
[PluginReference] private Plugin RunicNPC; [PluginReference] private Plugin RunicNPC;
@@ -326,9 +327,10 @@ namespace Oxide.Plugins
if (arg.Connection != null && !arg.IsAdmin) return; if (arg.Connection != null && !arg.IsAdmin) return;
ClearWorld(); ClearWorld();
Api<int>("RunicNpc_DespawnOwner", Owner); Api<int>("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<bool>("RunicNpc_RemovePlacement", id); Api<bool>("RunicNpc_RemovePlacement", id);
Api<bool>("RunicNpc_RemoveRoute", "t_loop"); Api<bool>("RunicNpc_RemoveRoute", "t_loop");
Api<bool>("RunicNpc_RemoveRoute", "t_rec");
arg.ReplyWith("rnt: cleared"); arg.ReplyWith("rnt: cleared");
} }
@@ -349,7 +351,7 @@ namespace Oxide.Plugins
string route = SetLoop(field + new Vector3(60f, 0f, 0f)); string route = SetLoop(field + new Vector3(60f, 0f, 0f));
Check("setup.route", route == null, route ?? "t_loop set"); 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) foreach (string g in groups)
{ {
IEnumerator inner = g == "api" ? Api(field) IEnumerator inner = g == "api" ? Api(field)
@@ -357,6 +359,8 @@ namespace Oxide.Plugins
: g == "move" ? Move(field) : g == "move" ? Move(field)
: g == "sentry" ? Sentry(field) : g == "sentry" ? Sentry(field)
: g == "sleep" ? Sleep(field) : g == "sleep" ? Sleep(field)
: g == "cmd" ? Cmd(field)
: g == "floor" ? Floor(field)
: g == "place" ? Place(field) : g == "place" ? Place(field)
: null; : null;
if (inner == null) if (inner == null)
@@ -367,7 +371,7 @@ namespace Oxide.Plugins
while (inner.MoveNext()) yield return inner.Current; while (inner.MoveNext()) yield return inner.Current;
Api<int>("RunicNpc_DespawnOwner", Owner); Api<int>("RunicNpc_DespawnOwner", Owner);
RemoveStandIns(); ClearWorld();
yield return new WaitForSeconds(1f); 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"); 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)}";
/// <summary>Runs a RunicNPC console command as the server console and returns its answer.</summary>
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<string, object> 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<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).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<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).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<string>("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<Dictionary<string, object>> 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<JObject>("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<Dictionary<string, object>> 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<Dictionary<string, object>> 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<string, object> 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<ulong> old = new HashSet<ulong>(List("placement:t_roam-1").Select(d => (ulong)d["netId"]));
r = Rnpc("rnpc.respawn t_roam-1");
yield return new WaitForSeconds(3f);
List<Dictionary<string, object>> 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<Dictionary<string, object>>()), 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<JObject>("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<JObject>("RunicNpc_Profiles")?["profiles"]?["t_new"];
Check("cmd.profile.set", made != null && (float)made["health"] == 321f && (float)made["ranges"]["sense"] == 44f &&
made["names"].ToObject<string[]>().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<JObject>("RunicNpc_Profiles")?["profiles"]?["t_new"] == null, "");
SetTestProfiles();
IEnumerator floorTest = Floor(field);
while (floorTest.MoveNext()) yield return floorTest.Current;
}
/// <summary>
/// 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.
/// </summary>
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<Dictionary<string, object>> 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<string, object> d in List("placement:" + id))
Kill((ulong)d["netId"]);
yield return new WaitForSeconds(9f);
on = List("placement:" + id);
Dictionary<string, object> 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) => 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 }); JObject.FromObject(new { profile, position = new { x = at.x, y = at.y, z = at.z }, yaw = 0f, count, respawn, respawnMode = mode });