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:
@@ -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
|
||||
|
||||
12
README.md
12
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<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
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
#
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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