feat: stage 3, the /rnpc commands in game #5
27
README.md
27
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.<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
|
||||
|
||||
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
|
||||
|
||||
|
||||
1138
plugin/RunicNPC.cs
1138
plugin/RunicNPC.cs
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,10 @@ using UnityEngine.AI;
|
||||
namespace Oxide.Plugins
|
||||
{
|
||||
/// <summary>
|
||||
/// 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:
|
||||
///
|
||||
/// <code>[rnt] <group>.<check>: PASS|FAIL <detail></code>
|
||||
///
|
||||
@@ -23,7 +24,7 @@ namespace Oxide.Plugins
|
||||
///
|
||||
/// <para>
|
||||
/// <c>rnt.run <group></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.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
|
||||
@@ -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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[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<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_RemoveRoute", "t_loop");
|
||||
Api<bool>("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<int>("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)}";
|
||||
|
||||
/// <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) =>
|
||||
JObject.FromObject(new { profile, position = new { x = at.x, y = at.y, z = at.z }, yaw = 0f, count, respawn, respawnMode = mode });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user