feat(bridge): the faction table and an event's escort, ally and tether (runicnpc stage 5) #23

Merged
whitlocktech merged 1 commits from feat/runicnpc-stage5 into edge 2026-10-05 03:38:57 -05:00
2 changed files with 180 additions and 5 deletions

View File

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

View File

@@ -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).
/// <summary>The RunicNPC API these commands call: 3 added placements from a website (D249).</summary>
private const int RunicNpcApiNeeded = 3;
/// <summary>
/// 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).
/// </summary>
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<string, string>;
_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<string, object> reply = Frame("npc.ok", "reply");
reply["refused"] = refused ?? new Dictionary<string, string>();
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<WorldEntry>();
@@ -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
/// <summary>A registry entry's prefab for a RunicNPC profile: <c>runicnpc:&lt;profile&gt;</c>.</summary>
private const string RunicNpcPrefix = "runicnpc:";
// ---- an event's NPCs: escort, ally and tether (runicnpc stage 5, D269, D270, D272) ----
/// <summary>What a placing step tells its NPCs to do besides their profile. Every part is optional.</summary>
private sealed class NpcOrders
{
/// <summary>D269: the player they escort.</summary>
public BasePlayer Escort;
/// <summary>D270: <c>clan</c> (the game's clan id) or <c>player</c> (a Steam id: that player and their team).</summary>
public string AllyKind;
public string AllyId;
/// <summary>D272: the zone of this run that holds the step's point.</summary>
public string Tether;
public bool Any => Escort != null || AllyKind != null || Tether != null;
}
/// <summary>
/// Main thread. Reads <c>escort</c> (a Steam id), <c>ally</c> (<c>{kind: clan|player, id}</c>) and
/// <c>tether</c> (true: the run's own zone around the point) from a <c>world.place</c>. False with
/// the reason when one cannot be honoured, so the step is refused before anything spawns.
/// </summary>
private bool ReadNpcOrders(Dictionary<string, object> 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;
}
/// <summary>Main thread. The zone a run made that holds a point, or null.</summary>
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;
}
/// <summary>Main thread. Gives one spawned NPC its orders; null, or RunicNPC's reason for refusing one.</summary>
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;
}
/// <summary>
/// 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.