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
|
# 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.
|
# 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
|
# 4. THE BUNDLE IS RECOMPOSED. Since stage 4 RunicNPC is the Rust bundle's third
|
||||||
# makes it a third artefact of the Rust bundle (D224). That stage adds the
|
# artefact (D224): the installer's compose job carries the latest RunicNPC
|
||||||
# step Rust-Plugins ends with, which asks RunicGateway/installer to recompose.
|
# 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
|
# 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
|
# `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
|
# Prerequisites (Settings → Actions → Secrets on RunicGateway/runicnpc-rust, or
|
||||||
# the organisation's):
|
# the organisation's):
|
||||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the
|
# 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.
|
# REGISTRY_USER — the Gitea username that token belongs to.
|
||||||
|
|
||||||
name: Release plugin
|
name: Release plugin
|
||||||
@@ -71,6 +76,7 @@ concurrency:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_HOST: gitea.whitlocktech.com
|
GITEA_HOST: gitea.whitlocktech.com
|
||||||
|
INSTALLER_REPO: RunicGateway/installer
|
||||||
REPO: RunicGateway/runicnpc-rust
|
REPO: RunicGateway/runicnpc-rust
|
||||||
ARTIFACT: runicnpc
|
ARTIFACT: runicnpc
|
||||||
PLUGIN: plugin/RunicNPC.cs
|
PLUGIN: plugin/RunicNPC.cs
|
||||||
@@ -474,3 +480,27 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo " uploaded ${f}"
|
echo " uploaded ${f}"
|
||||||
done
|
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
|
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).
|
[`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
|
> **Status: stage 4.** 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.
|
> 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
|
## Requirements
|
||||||
|
|
||||||
@@ -73,9 +74,10 @@ Every call is prefixed `RunicNpc_` and reached through `Call`:
|
|||||||
int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0;
|
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):
|
[`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.
|
The API version moves when a call or a raised hook changes shape, not on every release.
|
||||||
|
|
||||||
## Repository layout
|
## 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/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. |
|
| `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). |
|
| `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
|
## After a Rust update: the swap's field list
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
# lets a bundle check the pair BEFORE an operator installs it.
|
# lets a bundle check the pair BEFORE an operator installs it.
|
||||||
#
|
#
|
||||||
# 1 was stage 0's: `RunicNpc_ApiVersion()` and nothing else.
|
# 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.
|
# 2 was stage 2's whole API, PLAN.md §4.
|
||||||
api = 2
|
# Current: 3 — stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the
|
||||||
|
# OnRunicNpcPlacementChanged hook, documented in docs/runicnpc/API.md.
|
||||||
|
api = 3
|
||||||
|
|
||||||
# ── Framework floors ─────────────────────────────────────────────────────────
|
# ── 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.
|
/// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const int ApiVersion = 2;
|
private const int ApiVersion = 3;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Every hook this plugin implements. <c>rnpc.status</c> reports which have fired, because
|
/// Every hook this plugin implements. <c>rnpc.status</c> reports which have fired, because
|
||||||
@@ -1964,6 +1964,7 @@ namespace Oxide.Plugins
|
|||||||
_placementsDirty = true;
|
_placementsDirty = true;
|
||||||
Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}");
|
Puts($"Placement '{id}' set: {data.Count} × '{data.Profile}'. {CostWarning(PlannedTotal())}");
|
||||||
TickPlacements();
|
TickPlacements();
|
||||||
|
PlacementChanged(id, "set");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1976,9 +1977,96 @@ namespace Oxide.Plugins
|
|||||||
_placementFile.Placements.Remove(id);
|
_placementFile.Placements.Remove(id);
|
||||||
_placementsDirty = true;
|
_placementsDirty = true;
|
||||||
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
|
Despawn(ps.Slots.Select(s => s.Npc).Where(n => n != null));
|
||||||
|
PlacementChanged(id, "removed");
|
||||||
return true;
|
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);
|
private JObject RunicNpc_Routes() => JObject.FromObject(_routeFile.Routes);
|
||||||
|
|
||||||
/// <summary>Adds or replaces a route (D234). Every point must be on Rust's navmesh.</summary>
|
/// <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 GroundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default", "Tree");
|
||||||
private static readonly int BuiltMask = LayerMask.GetMask("Construction", "Deployed");
|
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>
|
/// <summary>Who ran a command, and where the answer goes.</summary>
|
||||||
private sealed class Caller
|
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)
|
private void CmdPlace(Caller c, string[] args, bool here)
|
||||||
{
|
{
|
||||||
if (args.Length < 1)
|
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
|
var data = new Placement
|
||||||
{
|
{
|
||||||
Profile = name,
|
Profile = name,
|
||||||
@@ -2475,14 +2583,17 @@ namespace Oxide.Plugins
|
|||||||
Movement = movement
|
Movement = movement
|
||||||
};
|
};
|
||||||
|
|
||||||
string id = NextPlacementId(name);
|
string id;
|
||||||
error = SetPlacement(id, data);
|
bool built;
|
||||||
|
error = CreatePlacement(data, out id, out built);
|
||||||
if (error != null)
|
if (error != null)
|
||||||
{
|
{
|
||||||
c.Say("Not placed: " + error);
|
c.Say(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string kind, route;
|
||||||
|
ParseMode((movement ?? p.Movement).Mode, out kind, out route);
|
||||||
Movement m = movement ?? p.Movement;
|
Movement m = movement ?? p.Movement;
|
||||||
c.Say($"Placed {id}: {data.Count} × '{name}' ({p.Role}{(p.IsSentry ? "" : ", " + m.Mode + (kind == "wander" ? $" {m.Radius:0} m" : ""))}), " +
|
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>.");
|
$"respawn {data.Respawn:0} s, {data.RespawnMode}. Rename it with rnpc rename {id} <name>.");
|
||||||
@@ -2574,6 +2685,7 @@ namespace Oxide.Plugins
|
|||||||
|
|
||||||
_placementsDirty = true;
|
_placementsDirty = true;
|
||||||
Puts($"Placement '{from}' renamed to '{to}'.");
|
Puts($"Placement '{from}' renamed to '{to}'.");
|
||||||
|
PlacementChanged(to, "renamed", from);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ namespace Oxide.Plugins
|
|||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <c>rnt.run <group></c> runs one group: <c>api</c>, <c>hooks</c>, <c>move</c>,
|
/// <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.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
|
/// <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
|
/// 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.
|
/// a made-up user id (11400000001 and up), so <c>IsNpc</c> is false. They take no damage.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Info("RunicNpcTest", "Runic Gateway", "0.3.0")]
|
[Info("RunicNpcTest", "Runic Gateway", "0.4.0")]
|
||||||
[Description("RunicNPC stage 2 and 3 tests. A developer tool: never ship it.")]
|
[Description("RunicNPC stage 2, 3 and 4 tests. A developer tool: never ship it.")]
|
||||||
internal class RunicNpcTest : RustPlugin
|
internal class RunicNpcTest : RustPlugin
|
||||||
{
|
{
|
||||||
[PluginReference] private Plugin RunicNPC;
|
[PluginReference] private Plugin RunicNPC;
|
||||||
@@ -64,6 +64,7 @@ namespace Oxide.Plugins
|
|||||||
private readonly List<string> _died = new List<string>();
|
private readonly List<string> _died = new List<string>();
|
||||||
private readonly List<string> _health = new List<string>();
|
private readonly List<string> _health = new List<string>();
|
||||||
private readonly List<string> _despawned = 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 readonly Dictionary<ulong, float> _lastContributors = new Dictionary<ulong, float>();
|
||||||
private int _hitsOnStandIns;
|
private int _hitsOnStandIns;
|
||||||
|
|
||||||
@@ -230,6 +231,9 @@ namespace Oxide.Plugins
|
|||||||
|
|
||||||
private void OnRunicNpcDespawned(BasePlayer npc, string owner) => _despawned.Add(owner);
|
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)
|
private void OnEntityTakeDamage(BaseCombatEntity victim, HitInfo info)
|
||||||
{
|
{
|
||||||
BasePlayer p = victim as BasePlayer;
|
BasePlayer p = victim as BasePlayer;
|
||||||
@@ -327,7 +331,7 @@ namespace Oxide.Plugins
|
|||||||
if (arg.Connection != null && !arg.IsAdmin) return;
|
if (arg.Connection != null && !arg.IsAdmin) return;
|
||||||
ClearWorld();
|
ClearWorld();
|
||||||
Api<int>("RunicNpc_DespawnOwner", Owner);
|
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_RemovePlacement", id);
|
||||||
Api<bool>("RunicNpc_RemoveRoute", "t_loop");
|
Api<bool>("RunicNpc_RemoveRoute", "t_loop");
|
||||||
Api<bool>("RunicNpc_RemoveRoute", "t_rec");
|
Api<bool>("RunicNpc_RemoveRoute", "t_rec");
|
||||||
@@ -351,7 +355,7 @@ namespace Oxide.Plugins
|
|||||||
string route = SetLoop(field + new Vector3(60f, 0f, 0f));
|
string route = SetLoop(field + new Vector3(60f, 0f, 0f));
|
||||||
Check("setup.route", route == null, route ?? "t_loop set");
|
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)
|
foreach (string g in groups)
|
||||||
{
|
{
|
||||||
IEnumerator inner = g == "api" ? Api(field)
|
IEnumerator inner = g == "api" ? Api(field)
|
||||||
@@ -360,6 +364,7 @@ namespace Oxide.Plugins
|
|||||||
: g == "sentry" ? Sentry(field)
|
: g == "sentry" ? Sentry(field)
|
||||||
: g == "sleep" ? Sleep(field)
|
: g == "sleep" ? Sleep(field)
|
||||||
: g == "cmd" ? Cmd(field)
|
: g == "cmd" ? Cmd(field)
|
||||||
|
: g == "api3" ? Api3(field)
|
||||||
: g == "floor" ? Floor(field)
|
: g == "floor" ? Floor(field)
|
||||||
: g == "place" ? Place(field)
|
: g == "place" ? Place(field)
|
||||||
: null;
|
: null;
|
||||||
@@ -383,7 +388,7 @@ namespace Oxide.Plugins
|
|||||||
|
|
||||||
private IEnumerator Api(Vector3 field)
|
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");
|
JObject profiles = Api<JObject>("RunicNpc_Profiles");
|
||||||
Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]);
|
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"]);
|
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
|
/// 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.
|
/// falls back to the nearest navmesh until the floor returns.
|
||||||
/// </summary>
|
/// </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)
|
private IEnumerator Floor(Vector3 field)
|
||||||
{
|
{
|
||||||
// Over dry land: a first run put it over water (terrain −0.25 m), and Rust's navmesh never
|
// 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