diff --git a/README.md b/README.md index 4124548..a2acee8 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ works on its own, and on a [Runic Gateway](https://gitea.whitlocktech.com/RunicG website authors its NPC profiles and events use its NPCs. The plan of record, stage by stage, is [`docs/runicnpc/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/PLAN.md). -> **Status: stage 3.** The NPC, its profiles, placements and routes, the API other plugins call, and -> the `/rnpc` commands admins use in game. Runic Gateway's website takes over the profiles in stage 4. +> **Status: stage 4.** The NPC, its profiles, placements and routes, the API other plugins call, and +> the `/rnpc` commands admins use in game. On a Runic Gateway server, the website takes over the +> profiles and lists, edits and creates placements through the bridge (API 3). ## Requirements @@ -73,9 +74,10 @@ Every call is prefixed `RunicNpc_` and reached through `Call`: int api = RunicNPC?.Call("RunicNpc_ApiVersion") ?? 0; ``` -The API is version 2, documented in +The API is version 3, documented in [`docs/runicnpc/API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/runicnpc/API.md): -spawning and removing NPCs by owner, profiles, placements, routes, the cost warning, and the hooks it raises. +spawning and removing NPCs by owner, profiles, placements (created and named as in game, from a map point), +routes, the cost warning, and the hooks it raises. The API version moves when a call or a raised hook changes shape, not on every release. ## Repository layout @@ -85,7 +87,7 @@ The API version moves when a call or a raised hook changes shape, not on every r | `plugin/RunicNPC.cs` | The plugin. The only file a server gets. | | `plugin.toml` | Its declarations: API version, framework floors, required plugins. The release copies them into the manifest. | | `scripts/checkPlugin.js` | The static checks run on every pull request and again before a release (see its header). | -| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2 and 3 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | +| `tools/` | Developer scaffolding for the test rigs, never shipped: the panel scripts (see `tools/rigs.example.json`); `RunicNpcHarness.cs`, the stage 1 measurement plugin; `RunicNpcTest.cs`, the stage 2, 3 and 4 test harness (`rnt.run all`, then `rnt.after` after a reload or restart); and `fieldlist/` with `managed.js`, which regenerate the swap's field list (below). | ## After a Rust update: the swap's field list diff --git a/plugin.toml b/plugin.toml index 062206a..82d231e 100644 --- a/plugin.toml +++ b/plugin.toml @@ -23,8 +23,10 @@ # lets a bundle check the pair BEFORE an operator installs it. # # 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else. -# Current: 2 — stage 2's whole API, PLAN.md §4, documented in docs/runicnpc/API.md. -api = 2 +# 2 was stage 2's whole API, PLAN.md §4. +# Current: 3 — stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the +# OnRunicNpcPlacementChanged hook, documented in docs/runicnpc/API.md. +api = 3 # ── Framework floors ───────────────────────────────────────────────────────── # diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index c893ace..d40df64 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -58,7 +58,7 @@ namespace Oxide.Plugins /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// - private const int ApiVersion = 2; + private const int ApiVersion = 3; /// /// Every hook this plugin implements. rnpc.status reports which have fired, because @@ -1964,6 +1964,7 @@ namespace Oxide.Plugins _placementsDirty = true; Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}"); TickPlacements(); + PlacementChanged(id, "set"); return null; } @@ -1976,9 +1977,96 @@ namespace Oxide.Plugins _placementFile.Placements.Remove(id); _placementsDirty = true; Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null)); + PlacementChanged(id, "removed"); return true; } + /// + /// API 3 (D249): creates a placement and names it as rnpc place does (D241, D246). A + /// position without y is a point on a map: it is put on the ground there (terrain and + /// rock, never a building, D245) and then checked against the navmesh like any other. + /// Answers {id, position, built, cost}, or {error}. + /// + private Dictionary RunicNpc_AddPlacement(JObject placement) + { + Placement data; + try + { + data = placement?.ToObject(); + } + catch (Exception e) + { + return new Dictionary { ["error"] = "the placement could not be read: " + e.Message }; + } + + if (data != null && data.Position != null && placement["position"]?["y"] == null) + { + Vector3 ground; + string off = GroundAt(data.Position.X, data.Position.Z, out ground); + if (off != null) + return new Dictionary { ["error"] = off }; + data.Position = Pos.Of(ground); + } + + string id; + bool built; + string error = CreatePlacement(data, out id, out built); + if (error != null) + { + PrintWarning($"RunicNpc_AddPlacement('{data?.Profile}') refused: {error}"); + return new Dictionary { ["error"] = error }; + } + + return new Dictionary + { + ["id"] = id, + ["position"] = new Dictionary { ["x"] = data.Position.X, ["y"] = data.Position.Y, ["z"] = data.Position.Z }, + ["built"] = built, + ["cost"] = CostWarning(PlannedTotal()) + }; + } + + /// API 3 (D249): renames a placement, as rnpc rename does. Null on success. + private string RunicNpc_RenamePlacement(string from, string to) => from == null || to == null ? "Name both placements." : RenamePlacement(from, to); + + /// API 3 (D249): respawns a placement's NPCs now, as rnpc respawn. How many, or -1 if there is no such placement. + private int RunicNpc_RespawnPlacement(string id) + { + PlacementState ps; + if (id == null || !_placements.TryGetValue(id, out ps)) + return -1; + int n = RespawnNow(ps); + TickPlacements(); + return n; + } + + /// + /// The ground under a point of the map: terrain or rock, never anything built or a tree (D245: + /// a roof is an in-game placement). Null with the spot, or the reason there is none. + /// + private static string GroundAt(float x, float z, out Vector3 ground) + { + ground = Vector3.zero; + float half = World.Size / 2f; + if (float.IsNaN(x) || float.IsNaN(z) || Mathf.Abs(x) > half || Mathf.Abs(z) > half) + return $"({x:0}, {z:0}) is off the map"; + RaycastHit hit; + if (Physics.Raycast(new Vector3(x, 1000f, z), Vector3.down, out hit, 2000f, MapGroundMask, QueryTriggerInteraction.Ignore)) + ground = hit.point; + else + ground = new Vector3(x, TerrainMeta.HeightMap.GetHeight(new Vector3(x, 0f, z)), z); + if (TerrainMeta.WaterMap != null && TerrainMeta.WaterMap.GetHeight(ground) > ground.y + 0.5f) + return $"({x:0}, {z:0}) is under water"; + return null; + } + + /// + /// API 3 (D249): raised whenever a placement is set, removed or renamed, from the API or in + /// game alike. change is set, removed or renamed (with previous). + /// + private static void PlacementChanged(string id, string change, string previous = null) => + Interface.CallHook("OnRunicNpcPlacementChanged", id, change, previous); + private JObject RunicNpc_Routes() => JObject.FromObject(_routeFile.Routes); /// Adds or replaces a route (D234). Every point must be on Rust's navmesh. @@ -2044,6 +2132,9 @@ namespace Oxide.Plugins private static readonly int GroundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default", "Tree"); private static readonly int BuiltMask = LayerMask.GetMask("Construction", "Deployed"); + /// What a map point is put down on (D245): terrain and rock, never a building or a tree. + private static readonly int MapGroundMask = LayerMask.GetMask("Terrain", "World"); + /// Who ran a command, and where the answer goes. private sealed class Caller { @@ -2365,6 +2456,50 @@ namespace Oxide.Plugins } } + /// + /// Checks, names and sets a NEW placement: rnpc place, rnpc here and + /// RunicNpc_AddPlacement alike, so a placement made from a website is named and refused + /// exactly as one made in game (D241, D246, D249). Null on success, else the sentence to show. + /// + private string CreatePlacement(Placement data, out string id, out bool built) + { + id = null; + built = false; + Profile p; + if (data == null || data.Profile == null || !_profiles.TryGetValue(data.Profile, out p)) + { + string name = data?.Profile; + return name != null && _refused.ContainsKey(name) ? $"Profile '{name}' is refused: {_refused[name]}" : $"There is no profile '{name}'. rnpc profiles lists them."; + } + + string error = ValidatePlacement(data); + if (error != null) + return "Not placed: " + error; + + string kind, route; + ParseMode((data.Movement ?? p.Movement).Mode, out kind, out route); + if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) + return $"There is no route '{route}'. Record it first: rnpc path record {route}"; + + if (!NavmeshReady()) + return "The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly."; + + if (!p.IsSentry) + { + string off = OffMeshReason(data.Position.V); + if (off != null) + return $"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219)."; + built = IsPlayerBuilt(data.Position.V); + } + + string next = NextPlacementId(data.Profile); + error = SetPlacement(next, data); + if (error != null) + return "Not placed: " + error; + id = next; + return null; + } + private void CmdPlace(Caller c, string[] args, bool here) { if (args.Length < 1) @@ -2437,33 +2572,6 @@ namespace Oxide.Plugins } } - string kind, route; - ParseMode((movement ?? p.Movement).Mode, out kind, out route); - if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) - { - c.Say($"There is no route '{route}'. Record it first: rnpc path record {route}"); - return; - } - - if (!NavmeshReady()) - { - c.Say("The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly."); - return; - } - - bool built = false; - if (!p.IsSentry) - { - string off = OffMeshReason(spot); - if (off != null) - { - c.Say($"A roamer cannot stand there: {off}. Only a sentry may stand off Rust's navmesh (D219)."); - return; - } - - built = IsPlayerBuilt(spot); - } - var data = new Placement { Profile = name, @@ -2475,14 +2583,17 @@ namespace Oxide.Plugins Movement = movement }; - string id = NextPlacementId(name); - error = SetPlacement(id, data); + string id; + bool built; + error = CreatePlacement(data, out id, out built); if (error != null) { - c.Say("Not placed: " + error); + c.Say(error); return; } + string kind, route; + ParseMode((movement ?? p.Movement).Mode, out kind, out route); Movement m = movement ?? p.Movement; c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " + $"respawn {data.Respawn:0} s, {data.RespawnMode}. Rename it with rnpc rename {id} ."); @@ -2574,6 +2685,7 @@ namespace Oxide.Plugins _placementsDirty = true; Puts($"Placement '{from}' renamed to '{to}'."); + PlacementChanged(to, "renamed", from); return null; } diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index bc635b1..3055ff5 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -24,7 +24,7 @@ namespace Oxide.Plugins /// /// /// rnt.run <group> runs one group: api, hooks, move, - /// sentry, sleep, cmd (stage 3's commands), place, or all for each in turn. + /// sentry, sleep, cmd (stage 3's commands), api3 (stage 4's calls), place, or all for each in turn. /// rnt.run place leaves two placements behind for the restart check, and /// rnt.after checks, after a reload or restart, that every placement is back and nothing /// else is. rnt.spawn [n] spawns plugin-owned NPCs, for the check that they die with this plugin, and @@ -42,8 +42,8 @@ namespace Oxide.Plugins /// a made-up user id (11400000001 and up), so IsNpc is false. They take no damage. /// /// - [Info("RunicNpcTest", "Runic Gateway", "0.3.0")] - [Description("RunicNPC stage 2 and 3 tests. A developer tool: never ship it.")] + [Info("RunicNpcTest", "Runic Gateway", "0.4.0")] + [Description("RunicNPC stage 2, 3 and 4 tests. A developer tool: never ship it.")] internal class RunicNpcTest : RustPlugin { [PluginReference] private Plugin RunicNPC; @@ -64,6 +64,7 @@ namespace Oxide.Plugins private readonly List _died = new List(); private readonly List _health = new List(); private readonly List _despawned = new List(); + private readonly List _changed = new List(); private readonly Dictionary _lastContributors = new Dictionary(); private int _hitsOnStandIns; @@ -230,6 +231,9 @@ namespace Oxide.Plugins private void OnRunicNpcDespawned(BasePlayer npc, string owner) => _despawned.Add(owner); + private void OnRunicNpcPlacementChanged(string id, string change, string previous) => + _changed.Add(previous != null ? $"{change}:{previous}>{id}" : $"{change}:{id}"); + private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info) { BasePlayer p = victim as BasePlayer; @@ -327,7 +331,7 @@ namespace Oxide.Plugins if (arg.Connection != null && !arg.IsAdmin) return; ClearWorld(); Api("RunicNpc_DespawnOwner", Owner); - foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2", "t_roam-1", "t_roam-2", "t_gate", "t_sentry-1" }) + foreach (string id in new[] { "t_each", "t_group", "t_waitroute", "t_keep1", "t_keep2", "t_roam-1", "t_roam-2", "t_gate", "t_sentry-1", "t_site" }) Api("RunicNpc_RemovePlacement", id); Api("RunicNpc_RemoveRoute", "t_loop"); Api("RunicNpc_RemoveRoute", "t_rec"); @@ -351,7 +355,7 @@ namespace Oxide.Plugins string route = SetLoop(field + new Vector3(60f, 0f, 0f)); Check("setup.route", route == null, route ?? "t_loop set"); - string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "place" } : new[] { group }; + string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "place" } : new[] { group }; foreach (string g in groups) { IEnumerator inner = g == "api" ? Api(field) @@ -360,6 +364,7 @@ namespace Oxide.Plugins : g == "sentry" ? Sentry(field) : g == "sleep" ? Sleep(field) : g == "cmd" ? Cmd(field) + : g == "api3" ? Api3(field) : g == "floor" ? Floor(field) : g == "place" ? Place(field) : null; @@ -383,7 +388,7 @@ namespace Oxide.Plugins private IEnumerator Api(Vector3 field) { - Check("api.version", Api("RunicNpc_ApiVersion") == 2, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + Check("api.version", Api("RunicNpc_ApiVersion") == 3, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); JObject profiles = Api("RunicNpc_Profiles"); Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]); @@ -931,6 +936,105 @@ namespace Oxide.Plugins /// a live NPC (what happens to it is measured, not asserted), and after its death the placement /// falls back to the nearest navmesh until the floor returns. /// + // ---- api3: stage 4's calls (D249) — a placement made from a map point, named as in game ---- + + private Dictionary Add(object placement) => + Api>("RunicNpc_AddPlacement", JObject.FromObject(placement)) ?? new Dictionary { ["error"] = "no answer" }; + + private static string Said(Dictionary d) => + d.ContainsKey("error") ? "error: " + d["error"] : "id " + d["id"] + (d.ContainsKey("position") ? " at " + V(d["position"]) : ""); + + private IEnumerator Api3(Vector3 field) + { + _changed.Clear(); + Vector3 a = OnMesh(field + new Vector3(-40, 0, 40)); + StandIn(OnMesh(field + new Vector3(-40, 0, 140)), 31); + + // A map point: x and z only. RunicNPC grounds it and names it like rnpc place (D241, D246). + Dictionary r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, count = 2, respawn = 30f, respawnMode = "group" }); + Check("api3.add.named", !r.ContainsKey("error") && (string)r["id"] == "t_roam-1", Said(r)); + Vector3 put = r.ContainsKey("position") ? V(r["position"]) : Vector3.zero; + float ground = TerrainMeta.HeightMap.GetHeight(a); + Check("api3.add.grounded", r.ContainsKey("position") && Mathf.Abs(put.y - ground) < 1.5f, $"y {put.y:0.00}, terrain {ground:0.00}"); + Check("api3.add.cost", r.ContainsKey("cost") && ((string)r["cost"]).Contains("NPC(s) on this server"), r.ContainsKey("cost") ? (string)r["cost"] : ""); + Check("api3.add.notBuilt", r.ContainsKey("built") && !(bool)r["built"], ""); + JObject data = PlacementData("t_roam-1"); + Check("api3.add.values", data != null && (int)data["count"] == 2 && (string)data["respawnMode"] == "group", "" + data?.ToString(Formatting.None)); + Check("api3.hook.set", _changed.Contains("set:t_roam-1"), string.Join(",", _changed.ToArray())); + + r = Add(new { profile = "t_roam", position = new { x = a.x + 6f, z = a.z } }); + Check("api3.add.nextName", !r.ContainsKey("error") && (string)r["id"] == "t_roam-2", Said(r)); + + // Refusals, each with the in-game reason; none leaves a placement behind. + int before = (Api>>("RunicNpc_Placements") ?? new List>()).Count; + float edge = World.Size / 2f + 50f; + r = Add(new { profile = "t_roam", position = new { x = edge, z = 0f } }); + Check("api3.refuse.offMap", r.ContainsKey("error") && ((string)r["error"]).Contains("off the map"), Said(r)); + r = Add(new { profile = "t_roam", position = new { x = a.x, y = a.y + 40f, z = a.z } }); + Check("api3.refuse.offMesh", r.ContainsKey("error") && ((string)r["error"]).Contains("m above the"), Said(r)); + r = Add(new { profile = "nope", position = new { x = a.x, z = a.z } }); + Check("api3.refuse.unknownProfile", r.ContainsKey("error") && ((string)r["error"]).Contains("no profile"), Said(r)); + r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, count = 0 }); + Check("api3.refuse.count", r.ContainsKey("error") && ((string)r["error"]).Contains("count"), Said(r)); + r = Add(new { profile = "t_roam", position = new { x = a.x, z = a.z }, movement = new { mode = "route:t_none", radius = 0f } }); + Check("api3.refuse.missingRoute", r.ContainsKey("error") && ((string)r["error"]).Contains("no route 't_none'"), Said(r)); + Vector3 sea; + if (FindWater(out sea)) + { + r = Add(new { profile = "t_roam", position = new { x = sea.x, z = sea.z } }); + Check("api3.refuse.water", r.ContainsKey("error") && ((string)r["error"]).Contains("under water"), Said(r) + $" at {sea}"); + } + else + Note("api3.refuse.water skipped: no deep water found"); + Check("api3.refuse.leftNothing", (Api>>("RunicNpc_Placements") ?? new List>()).Count == before, ""); + + yield return new WaitForSeconds(4f); + Check("api3.spawned", List("placement:t_roam-1").Count == 2, "listed " + List("placement:t_roam-1").Count); + + // Rename and respawn, as rnpc rename and rnpc respawn. + _changed.Clear(); + string renamed = Api("RunicNpc_RenamePlacement", "t_roam-2", "t_site"); + Check("api3.rename", renamed == null && PlacementData("t_site") != null && PlacementData("t_roam-2") == null, renamed ?? ""); + Check("api3.hook.renamed", _changed.Contains("renamed:t_roam-2>t_site"), string.Join(",", _changed.ToArray())); + string taken = Api("RunicNpc_RenamePlacement", "t_site", "t_roam-1"); + Check("api3.rename.refuseTaken", taken != null && taken.Contains("taken"), taken ?? "null"); + ulong first = List("placement:t_roam-1").Select(d => (ulong)d["netId"]).FirstOrDefault(); + int respawned = Api("RunicNpc_RespawnPlacement", "t_roam-1"); + Check("api3.respawn", respawned == 2, "respawned " + respawned); + Check("api3.respawn.missing", Api("RunicNpc_RespawnPlacement", "t_nothing") == -1, ""); + yield return new WaitForSeconds(3f); + List> after = List("placement:t_roam-1"); + Check("api3.respawn.fresh", after.Count == 2 && after.All(d => (ulong)d["netId"] != first), $"listed {after.Count}"); + + // The hook for every change made in game too. + _changed.Clear(); + Rnpc($"rnpc.place t_roam {At(OnMesh(field + new Vector3(-30, 0, 40)))}"); + Rnpc("rnpc.remove t_roam-2"); + Check("api3.hook.inGame", _changed.Contains("set:t_roam-2") && _changed.Contains("removed:t_roam-2"), string.Join(",", _changed.ToArray())); + Api("RunicNpc_RemovePlacement", "t_site"); + Check("api3.hook.removed", _changed.Contains("removed:t_site"), string.Join(",", _changed.ToArray())); + Api("RunicNpc_RemovePlacement", "t_roam-1"); + } + + /// A point of open sea at least 5 m deep, for the under-water refusal. + private static bool FindWater(out Vector3 at) + { + var rng = new System.Random(7); + float half = World.Size / 2f - 20f; + for (int i = 0; i < 4000; i++) + { + var p = new Vector3((float)(rng.NextDouble() * 2 - 1) * half, 0f, (float)(rng.NextDouble() * 2 - 1) * half); + if (TerrainMeta.WaterMap.GetHeight(p) - TerrainMeta.HeightMap.GetHeight(p) > 5f) + { + at = p; + return true; + } + } + + at = Vector3.zero; + return false; + } + private IEnumerator Floor(Vector3 field) { // Over dry land: a first run put it over water (terrain −0.25 m), and Rust's navmesh never