Merge pull request 'feat: API 3, placements from a website (stage 4, D249)' (#6) from feat/stage-4-api3 into edge

Reviewed-on: #6
This commit is contained in:
2026-09-30 19:52:55 +00:00
5 changed files with 298 additions and 48 deletions

View File

@@ -38,9 +38,12 @@
# sidecar (PLAN.md §4): the bridge calls it in-process. What a consumer pairs
# on is the API version other plugins call, from plugin.toml.
#
# 4. NO BUNDLE DISPATCH, YET. The installer does not know RunicNPC until stage 4
# makes it a third artefact of the Rust bundle (D224). That stage adds the
# step Rust-Plugins ends with, which asks RunicGateway/installer to recompose.
# 4. THE BUNDLE IS RECOMPOSED. Since stage 4 RunicNPC is the Rust bundle's third
# artefact (D224): the installer's compose job carries the latest RunicNPC
# release that answers the API the bridge declares (Rust-Plugins' overlay.toml
# `runicnpc_api`). The last step here asks RunicGateway/installer to
# recompose, as Rust-Plugins' release does; the installer's nightly cron covers
# a dispatch that did not arrive.
#
# The version is stamped into the shipped copy's `[Info(…)]` attribute, so
# `oxide.plugins` / `c.plugins` on a server names the release it runs. The
@@ -55,7 +58,9 @@
# Prerequisites (Settings → Actions → Secrets on RunicGateway/runicnpc-rust, or
# the organisation's):
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the
# tag and create the release.
# tag and create the release. The final step also dispatches
# RunicGateway/installer's bundle workflow, so the token
# needs write on that repo too; without it that step warns.
# REGISTRY_USER — the Gitea username that token belongs to.
name: Release plugin
@@ -71,6 +76,7 @@ concurrency:
env:
GITEA_HOST: gitea.whitlocktech.com
INSTALLER_REPO: RunicGateway/installer
REPO: RunicGateway/runicnpc-rust
ARTIFACT: runicnpc
PLUGIN: plugin/RunicNPC.cs
@@ -474,3 +480,27 @@ jobs:
fi
echo " uploaded ${f}"
done
# ── Recompose the installer's bundle manifest (D224) ─────────────────
# DISPATCH, DON'T WAIT, as Rust-Plugins' release does. A dropped dispatch
# costs latency, not correctness: the installer's nightly cron recomputes
# the bundle from whatever the latest releases actually are.
- name: Ask the installer repo to recompose its bundle
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"ref":"main"}' \
"https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)"
case "$HTTP" in
20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;;
403|404)
echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;;
*)
echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;;
esac

View File

@@ -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<int>("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

View File

@@ -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 ─────────────────────────────────────────────────────────
#

View File

@@ -58,7 +58,7 @@ namespace Oxide.Plugins
/// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md.
/// </para>
/// </summary>
private const int ApiVersion = 2;
private const int ApiVersion = 3;
/// <summary>
/// Every hook this plugin implements. <c>rnpc.status</c> 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;
}
/// <summary>
/// API 3 (D249): creates a placement and names it as <c>rnpc place</c> does (D241, D246). A
/// position without <c>y</c> 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 <c>{id, position, built, cost}</c>, or <c>{error}</c>.
/// </summary>
private Dictionary<string, object> RunicNpc_AddPlacement(JObject placement)
{
Placement data;
try
{
data = placement?.ToObject<Placement>();
}
catch (Exception e)
{
return new Dictionary<string, object> { ["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<string, object> { ["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<string, object> { ["error"] = error };
}
return new Dictionary<string, object>
{
["id"] = id,
["position"] = new Dictionary<string, object> { ["x"] = data.Position.X, ["y"] = data.Position.Y, ["z"] = data.Position.Z },
["built"] = built,
["cost"] = CostWarning(PlannedTotal())
};
}
/// <summary>API 3 (D249): renames a placement, as <c>rnpc rename</c> does. Null on success.</summary>
private string RunicNpc_RenamePlacement(string from, string to) => from == null || to == null ? "Name both placements." : RenamePlacement(from, to);
/// <summary>API 3 (D249): respawns a placement's NPCs now, as <c>rnpc respawn</c>. How many, or -1 if there is no such placement.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// API 3 (D249): raised whenever a placement is set, removed or renamed, from the API or in
/// game alike. <c>change</c> is <c>set</c>, <c>removed</c> or <c>renamed</c> (with <c>previous</c>).
/// </summary>
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);
/// <summary>Adds or replaces a route (D234). Every point must be on Rust's navmesh.</summary>
@@ -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");
/// <summary>What a map point is put down on (D245): terrain and rock, never a building or a tree.</summary>
private static readonly int MapGroundMask = LayerMask.GetMask("Terrain", "World");
/// <summary>Who ran a command, and where the answer goes.</summary>
private sealed class Caller
{
@@ -2365,6 +2456,50 @@ namespace Oxide.Plugins
}
}
/// <summary>
/// Checks, names and sets a NEW placement: <c>rnpc place</c>, <c>rnpc here</c> and
/// <c>RunicNpc_AddPlacement</c> 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.
/// </summary>
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} <name>.");
@@ -2574,6 +2685,7 @@ namespace Oxide.Plugins
_placementsDirty = true;
Puts($"Placement '{from}' renamed to '{to}'.");
PlacementChanged(to, "renamed", from);
return null;
}

View File

@@ -24,7 +24,7 @@ namespace Oxide.Plugins
///
/// <para>
/// <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>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