diff --git a/overlay.toml b/overlay.toml
index 5f22132..07d111a 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -80,7 +80,7 @@ requires_plugins = ["Kits", "ZoneManager"]
# ── The RunicNPC API the bridge needs ───────────────────────────────────────
#
-# RunicNPC (docs/runicnpc/PLAN.md stage 4): the bridge's npc.* commands call
+# RunicNPC (docs/runicnpc/PLAN.md stages 4 and 5): the bridge's npc.* commands call
# RunicNPC's API and need at least this version of it. It MUST equal
# `RunicNpcApiNeeded` in RunicGateway.cs; `node scripts/checkPlugin.js` holds the
# two equal. The release copies it into manifest.json, and the installer's
@@ -88,4 +88,4 @@ requires_plugins = ["Kits", "ZoneManager"]
#
# RunicNPC is optional to the bridge until its stage 9: without it every npc.*
# command answers `runicnpc-missing`, and events place Rust's own scientists.
-runicnpc_api = 3
+runicnpc_api = 4
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index 63b70cf..26ef994 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -10367,8 +10367,11 @@ namespace Oxide.Plugins
// stays true. It is optional until RunicNPC's stage 9; without it every `npc.*` command
// answers `runicnpc-missing`, and events keep Rust's own scientists (D243).
- /// The RunicNPC API these commands call: 3 added placements from a website (D249).
- private const int RunicNpcApiNeeded = 3;
+ ///
+ /// The RunicNPC API these commands call: 3 added placements from a website (D249), 4 the
+ /// faction table and an event's escort, ally and tether (runicnpc stage 5).
+ ///
+ private const int RunicNpcApiNeeded = 4;
private Plugin RunicNpcPlugin()
{
@@ -10462,6 +10465,9 @@ namespace Oxide.Plugins
reply["profiles"] = all["profiles"] ?? new Newtonsoft.Json.Linq.JObject();
reply["refused"] = all["refused"] ?? new Newtonsoft.Json.Linq.JObject();
+ // Runicnpc stage 5 (D254): the faction table, read with the profiles so the site can adopt it.
+ reply["factions"] = all["factions"] ?? new Newtonsoft.Json.Linq.JArray();
+
if (reqId != null)
reply["reqId"] = reqId;
@@ -10489,13 +10495,23 @@ namespace Oxide.Plugins
return;
}
+ // Runicnpc stage 5 (D254): the faction table travels with the profiles. It is set first, so the
+ // profiles are checked against the table they will be used with; a refused table is reported
+ // and the old one kept, and the profiles are pushed either way.
+ var factions = cmd.TryGetValue("factions", out value) ? value as Newtonsoft.Json.Linq.JArray : null;
+ string factionsRefused = factions != null ? npc.Call("RunicNpc_SetFactions", factions) as string : null;
+
var refused = npc.Call("RunicNpc_SetProfiles", profiles) as Dictionary;
- _lastNpcSummary = $"profiles pushed: {profiles.Count}, refused {(refused != null ? refused.Count : 0)}";
+ _lastNpcSummary = $"profiles pushed: {profiles.Count}, refused {(refused != null ? refused.Count : 0)}" +
+ (factions != null ? $", factions {factions.Count}{(factionsRefused != null ? " REFUSED" : "")}" : "");
Puts($"runicnpc: {_lastNpcSummary}");
Dictionary reply = Frame("npc.ok", "reply");
reply["refused"] = refused ?? new Dictionary();
+ if (factionsRefused != null)
+ reply["factionsRefused"] = factionsRefused;
+
if (reqId != null)
reply["reqId"] = reqId;
@@ -10892,6 +10908,14 @@ namespace Oxide.Plugins
return;
}
+ NpcOrders orders;
+
+ if (!ReadNpcOrders(cmd, runId, center, out orders, out reason, out message))
+ {
+ EmitWorldError(reqId, reason, message);
+ return;
+ }
+
string owner = "run:" + runId;
var made = new List();
@@ -10925,6 +10949,19 @@ namespace Oxide.Plugins
return;
}
+ string orderRefusal = ApplyNpcOrders(npc, spawned, orders);
+
+ if (orderRefusal != null)
+ {
+ npc.Call("RunicNpc_Despawn", spawned.net.ID.Value);
+
+ foreach (WorldEntry e in made)
+ npc.Call("RunicNpc_Despawn", ulong.Parse(e.Id, System.Globalization.CultureInfo.InvariantCulture));
+
+ EmitWorldError(reqId, "refused", $"RunicNPC would not give '{profile}' its orders: {orderRefusal}");
+ return;
+ }
+
UnityEngine.Vector3 put = spawned.transform.position;
made.Add(new WorldEntry
{
@@ -10955,6 +10992,144 @@ namespace Oxide.Plugins
/// A registry entry's prefab for a RunicNPC profile: runicnpc:<profile>.
private const string RunicNpcPrefix = "runicnpc:";
+ // ---- an event's NPCs: escort, ally and tether (runicnpc stage 5, D269, D270, D272) ----
+
+ /// What a placing step tells its NPCs to do besides their profile. Every part is optional.
+ private sealed class NpcOrders
+ {
+ /// D269: the player they escort.
+ public BasePlayer Escort;
+
+ /// D270: clan (the game's clan id) or player (a Steam id: that player and their team).
+ public string AllyKind;
+ public string AllyId;
+
+ /// D272: the zone of this run that holds the step's point.
+ public string Tether;
+
+ public bool Any => Escort != null || AllyKind != null || Tether != null;
+ }
+
+ ///
+ /// Main thread. Reads escort (a Steam id), ally ({kind: clan|player, id}) and
+ /// tether (true: the run's own zone around the point) from a world.place. False with
+ /// the reason when one cannot be honoured, so the step is refused before anything spawns.
+ ///
+ private bool ReadNpcOrders(Dictionary cmd, string runId, UnityEngine.Vector3 center,
+ out NpcOrders orders, out string reason, out string message)
+ {
+ orders = new NpcOrders();
+ reason = null;
+ message = null;
+
+ string escort = StringField(cmd, "escort");
+
+ if (!string.IsNullOrEmpty(escort))
+ {
+ ulong steamId;
+
+ if (!ulong.TryParse(escort, out steamId))
+ {
+ reason = "malformed";
+ message = $"an escort is a player's Steam id, not '{escort}'";
+ return false;
+ }
+
+ BasePlayer player = BasePlayer.FindByID(steamId);
+
+ if (player == null || !player.IsConnected || player.IsDead())
+ {
+ reason = "escort-offline";
+ message = $"the player {steamId} is not on the server, so there is nobody to escort";
+ return false;
+ }
+
+ orders.Escort = player;
+ }
+
+ object allyValue;
+ var ally = cmd.TryGetValue("ally", out allyValue) ? allyValue as Newtonsoft.Json.Linq.JObject : null;
+
+ if (ally != null)
+ {
+ orders.AllyKind = (string)ally["kind"];
+ orders.AllyId = (string)ally["id"];
+
+ if ((orders.AllyKind != "clan" && orders.AllyKind != "player") || string.IsNullOrEmpty(orders.AllyId))
+ {
+ reason = "malformed";
+ message = "an ally is {kind: clan|player, id}";
+ return false;
+ }
+ }
+
+ object tetherValue;
+
+ if (cmd.TryGetValue("tether", out tetherValue) && tetherValue is bool && (bool)tetherValue)
+ {
+ orders.Tether = RunZoneAt(runId, center);
+
+ if (orders.Tether == null)
+ {
+ reason = "no-zone";
+ message = "no zone this event made holds that point, so there is nothing to keep the NPCs inside";
+ return false;
+ }
+ }
+
+ if (orders.Any && RunicNpcApi() < 4)
+ {
+ reason = "runicnpc-old";
+ message = $"escort, ally and tether need RunicNPC's API 4, and it answers {RunicNpcApi()}";
+ return false;
+ }
+
+ return true;
+ }
+
+ /// Main thread. The zone a run made that holds a point, or null.
+ private string RunZoneAt(string runId, UnityEngine.Vector3 at)
+ {
+ Plugin zm = plugins.Find("ZoneManager");
+
+ if (zm == null)
+ return null;
+
+ foreach (WorldEntry e in _world.Values)
+ {
+ if (e.Kind != WorldKindZone || e.RunId != runId || ZoneExpired(e))
+ continue;
+
+ object inside = zm.Call("IsPositionInZone", e.Id, at);
+
+ if (inside is bool && (bool)inside)
+ return e.Id;
+ }
+
+ return null;
+ }
+
+ /// Main thread. Gives one spawned NPC its orders; null, or RunicNPC's reason for refusing one.
+ private static string ApplyNpcOrders(Plugin npc, BasePlayer spawned, NpcOrders orders)
+ {
+ if (!orders.Any)
+ return null;
+
+ ulong netId = spawned.net.ID.Value;
+ string refusal = null;
+
+ if (orders.Escort != null)
+ refusal = npc.Call("RunicNpc_Escort", netId, (BaseEntity)orders.Escort) as string;
+
+ if (refusal == null && orders.AllyKind != null)
+ refusal = npc.Call("RunicNpc_Ally", netId, orders.AllyKind, orders.AllyId) as string;
+
+ if (refusal == null && orders.Tether != null)
+ refusal = npc.Call("RunicNpc_Tether", netId, orders.Tether) as string;
+
+ return refusal;
+ }
+
///
/// Main thread. Removes a run's RunicNPC NPC through RunicNPC, so it hears of it (and raises
/// its own despawn hook); anything else, or RunicNPC gone, is killed as any entity is.