feat: API 3, placements from a website (stage 4, D249)
RunicNpc_AddPlacement creates a placement and names it as /rnpc place does (D241, D246). A position without y is a map point: it is put on the ground there (terrain and rock, never a building or a tree, D245), then checked against the navmesh like an in-game placement. It answers the id, the grounded position, whether the spot is player-built, and the cost warning, or the in-game refusal sentence. RunicNpc_RenamePlacement and RunicNpc_RespawnPlacement do what rnpc rename and rnpc respawn do. OnRunicNpcPlacementChanged(id, change, previous) is raised on every set, remove and rename, from the API or in game, so the bridge can tell the site at once. rnpc place and here now share CreatePlacement with the API, so both give the same refusals. The test harness gains an api3 group; all 157 checks pass on the Oxide and Carbon rigs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -24,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>cmd</c> (stage 3's commands), <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>api3</c> (stage 4's calls), <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
|
||||
@@ -42,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.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<string> _died = new List<string>();
|
||||
private readonly List<string> _health = new List<string>();
|
||||
private readonly List<string> _despawned = new List<string>();
|
||||
private readonly List<string> _changed = new List<string>();
|
||||
private readonly Dictionary<ulong, float> _lastContributors = new Dictionary<ulong, float>();
|
||||
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<int>("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<bool>("RunicNpc_RemovePlacement", id);
|
||||
Api<bool>("RunicNpc_RemoveRoute", "t_loop");
|
||||
Api<bool>("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<int>("RunicNpc_ApiVersion") == 2, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
|
||||
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 3, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
|
||||
JObject profiles = Api<JObject>("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.
|
||||
/// </summary>
|
||||
// ---- api3: stage 4's calls (D249) — a placement made from a map point, named as in game ----
|
||||
|
||||
private Dictionary<string, object> Add(object placement) =>
|
||||
Api<Dictionary<string, object>>("RunicNpc_AddPlacement", JObject.FromObject(placement)) ?? new Dictionary<string, object> { ["error"] = "no answer" };
|
||||
|
||||
private static string Said(Dictionary<string, object> 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<string, object> 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<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).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<List<Dictionary<string, object>>>("RunicNpc_Placements") ?? new List<Dictionary<string, object>>()).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<string>("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<string>("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<int>("RunicNpc_RespawnPlacement", "t_roam-1");
|
||||
Check("api3.respawn", respawned == 2, "respawned " + respawned);
|
||||
Check("api3.respawn.missing", Api<int>("RunicNpc_RespawnPlacement", "t_nothing") == -1, "");
|
||||
yield return new WaitForSeconds(3f);
|
||||
List<Dictionary<string, object>> 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<bool>("RunicNpc_RemovePlacement", "t_site");
|
||||
Check("api3.hook.removed", _changed.Contains("removed:t_site"), string.Join(",", _changed.ToArray()));
|
||||
Api<bool>("RunicNpc_RemovePlacement", "t_roam-1");
|
||||
}
|
||||
|
||||
/// <summary>A point of open sea at least 5 m deep, for the under-water refusal.</summary>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user