From cf26b05abc6e3ef90a42112d7b543d7b4b133492 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 1 Oct 2026 07:58:16 -0500 Subject: [PATCH] feat(bridge): the faction table and an event's escort, ally and tether (runicnpc stage 5) - npc.profiles answers with RunicNPC's faction table, and npc.profiles.set pushes one beside the profiles (RunicNpc_SetFactions), set first so the profiles are checked against it; a refused table comes back as factionsRefused and the old one is kept (D254); - world.place with a profile takes escort (a Steam id; the player must be on, else escort-offline), ally ({kind: clan|player, id}) and tether (the run's own zone that holds the point, else no-zone), and gives each NPC its orders through RunicNpc_Escort, _Ally and _Tether. All or nothing: an order RunicNPC refuses removes every NPC the step placed (D269, D270, D272); - the RunicNPC API the bridge needs is 4 (overlay.toml runicnpc_api = 4). Compiled on the Oxide rig. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 4 +- overlay/oxide/plugins/RunicGateway.cs | 181 +++++++++++++++++++++++++- 2 files changed, 180 insertions(+), 5 deletions(-) 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. -- 2.49.1