From 742e18ba0af676f8706f589b9c57821cfd29d83e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 04:21:50 -0500 Subject: [PATCH] feat(bridge): RunicNPC, the site's NPC profiles and placements (runicnpc stage 4) Protocol 13 gains npc.profiles, npc.profiles.set, npc.placements and npc.placement (add, set, remove, rename, respawn), each calling RunicNPC's API 3; the frames npc.died, npc.health and npc.placement.changed from its hooks; runicNpc in the integrations body; world.place with a profile; the killfeed's attackerNpc and attackerProfile; and the tally's npcProfileKills. rg.npc reports it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 712 +++++++++++++++++++++++++- 1 file changed, 705 insertions(+), 7 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 5001b9b..63b70cf 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -248,7 +248,8 @@ namespace Oxide.Plugins "OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair", "OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched", "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded", - "OnEnterZone", "OnExitZone", "OnRgDomesReady" + "OnEnterZone", "OnExitZone", "OnRgDomesReady", + "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged" }; private void Init() @@ -1068,6 +1069,25 @@ namespace Oxide.Plugins HandleMapLive(reqId); break; + case "npc.profiles": + // Protocol 13, RunicNPC (docs/runicnpc/PLAN.md stage 4). The server's profiles, + // read before the site's first push so it can adopt them (D244). + HandleNpcProfiles(reqId); + break; + + case "npc.profiles.set": + HandleNpcProfilesSet(cmd, reqId); + break; + + case "npc.placements": + HandleNpcPlacements(reqId); + break; + + case "npc.placement": + // One change to one placement, by `op` (D245, D246). + HandleNpcPlacement(cmd, reqId); + break; + default: Puts($"unknown command '{verb}'"); break; @@ -1311,7 +1331,7 @@ namespace Oxide.Plugins /// the environment case and not an error. /// /// - private static void DescribeAttacker( + private void DescribeAttacker( Dictionary frame, BasePlayer victim, HitInfo info) { BaseEntity initiator = info != null ? info.Initiator : null; @@ -1344,6 +1364,17 @@ namespace Oxide.Plugins { frame["attackerType"] = "npc"; frame["attackerName"] = initiator.ShortPrefabName; + + // One of RunicNPC's: its own name, as the victim's death screen shows it, and its + // profile. Beside `attackerName`, not over it, so a reader that knows only prefab + // names still reads a scientist (runicnpc PLAN.md §1.4, stage 4). + string profile = RunicNpcProfileOf(initiator); + + if (profile != null && attacker != null) + { + frame["attackerNpc"] = attacker.displayName; + frame["attackerProfile"] = profile; + } } float distance = DistanceOf(info, victim); @@ -1828,6 +1859,11 @@ namespace Oxide.Plugins Tally tally = TallyFor(attacker); tally.NpcKills++; tally.Killed(info, entity, false); + + string profile = RunicNpcProfileOf(victimPlayer); + + if (profile != null) + tally.KilledProfile(profile); CreditKill(attacker, false); } @@ -2775,6 +2811,9 @@ namespace Oxide.Plugins public int ApcKills; public int HeliKills; public readonly Dictionary Crafted = new Dictionary(); + + /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . + public readonly Dictionary NpcProfileKills = new Dictionary(); public int Built; public int Repaired; public int Healed; @@ -2792,6 +2831,11 @@ namespace Oxide.Plugins Add(Crafted, category, amount); } + public void KilledProfile(string profile) + { + Add(NpcProfileKills, profile, 1); + } + /// /// What a credited kill adds for the titles: its weapon (any victim, D174), a /// headshot, and its distance as this interval's best of its kind. @@ -2827,7 +2871,7 @@ namespace Oxide.Plugins && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 && BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 - && Rockets == 0 && Explosives == 0 && Missions == 0; + && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0; } } @@ -2926,6 +2970,8 @@ namespace Oxide.Plugins frame["explosives"] = tally.Explosives; if (tally.Missions > 0) frame["missions"] = tally.Missions; + if (tally.NpcProfileKills.Count > 0) + frame["npcProfileKills"] = tally.NpcProfileKills; EmitFrame(frame); } @@ -4931,6 +4977,12 @@ namespace Oxide.Plugins return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes"; } + /// RunicNPC's presence and API are in hello, so its load and unload say it again (stage 4). + private bool IsRunicNpc(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "RunicNPC"; + } + /// Main thread. Hello again, once the world is up and the link is. private void ResendHello() { @@ -4961,7 +5013,7 @@ namespace Oxide.Plugins // Hello carries ZoneManager's flag list and both helpers' states, so a change to any // of the four is said again rather than waiting for the next connect (§3.1, §3.2). - if (IsZonePlugin(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) NextTick(ResendHello); // BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has @@ -4986,7 +5038,7 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) NextTick(ResendHello); ConfigWriteJob job = _configJob; @@ -8990,6 +9042,21 @@ namespace Oxide.Plugins return; } + // Stage 4 (D243): a RunicNPC profile instead of one of Rust's own prefabs. + string profile = StringField(cmd, "profile"); + + if (!string.IsNullOrEmpty(profile)) + { + if (!string.IsNullOrEmpty(StringField(cmd, "prefab"))) + { + EmitWorldError(reqId, "malformed", "a placement names a prefab or a profile, not both"); + return; + } + + HandleWorldPlaceProfile(cmd, reqId, key, runId, profile); + return; + } + WorldPrefab prefab = FindWorldPrefab(StringField(cmd, "prefab")); if (prefab == null) @@ -9199,7 +9266,7 @@ namespace Oxide.Plugins } else { - wasThere = KillEntity(e.Id); + wasThere = e.Kind == WorldKindNpc ? KillWorldNpc(e) : KillEntity(e.Id); } _world.Remove(e.Id); @@ -10292,6 +10359,636 @@ namespace Oxide.Plugins EnsureTallyTimer(); } + // ── RunicNPC (docs/runicnpc/PLAN.md stage 4, PROTOCOL.md §19.12) ───────────────────── + // + // RunicNPC is Runic Gateway's own NPC plugin. The bridge is its only link to the site + // (runicnpc PLAN.md §4): it calls RunicNPC's API for the site's commands and turns its hooks + // into frames. RunicNPC never talks to the sidecar, so "the sidecar is a dumb forwarder" + // 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; + + private Plugin RunicNpcPlugin() + { + return plugins.Find("RunicNPC"); + } + + /// RunicNPC's API version, or 0 when it is not loaded. + private int RunicNpcApi() + { + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + return 0; + + object api = npc.Call("RunicNpc_ApiVersion"); + return api is int ? (int)api : 0; + } + + /// For the integrations body: loaded, its version, and the API it answers. + private Dictionary RunicNpcBody() + { + Dictionary body = IntegrationBody(RunicNpcPlugin()); + + if (RunicNpcPlugin() != null) + body["api"] = RunicNpcApi(); + + return body; + } + + /// + /// Main thread. The profile name of one of RunicNPC's NPCs, or null for anything else — + /// a Rust scientist, an animal, or any NPC while RunicNPC is not loaded. + /// + private string RunicNpcProfileOf(BaseEntity entity) + { + if (entity == null) + return null; + + Plugin npc = RunicNpcPlugin(); + return npc != null ? npc.Call("RunicNpc_ProfileOf", entity) as string : null; + } + + /// + /// Main thread. The plugin to call, or null after answering npc.error — missing, or + /// older than the API these commands need. + /// + private Plugin RunicNpcFor(string reqId) + { + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + { + EmitNpcError(reqId, "runicnpc-missing", "RunicNPC is not loaded on this server"); + return null; + } + + int api = RunicNpcApi(); + + if (api < RunicNpcApiNeeded) + { + EmitNpcError(reqId, "runicnpc-old", + $"RunicNPC {npc.Version} answers API {api}, and this bridge needs {RunicNpcApiNeeded}"); + return null; + } + + return npc; + } + + /// + /// Main thread. npc.profiles: the server's profile set as RunicNPC holds it — whether + /// a site manages it, every profile, and those refused and why. The site reads this before + /// its first push to a server, to adopt the server's own (D244). + /// + private void HandleNpcProfiles(string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + var all = npc.Call("RunicNpc_Profiles") as Newtonsoft.Json.Linq.JObject; + + if (all == null) + { + EmitNpcError(reqId, "refused", "RunicNPC did not answer with its profiles"); + return; + } + + Dictionary reply = Frame("npc.profiles", "reply"); + reply["managed"] = all["managed"] != null && (bool)all["managed"]; + reply["profiles"] = all["profiles"] ?? new Newtonsoft.Json.Linq.JObject(); + reply["refused"] = all["refused"] ?? new Newtonsoft.Json.Linq.JObject(); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. npc.profiles.set: replaces the whole profile set, as the site does on + /// every push, and so marks the server managed (D221). RunicNPC checks each profile — a kit + /// this server lacks, for one — and the answer lists those it refused. + /// + private void HandleNpcProfilesSet(Dictionary cmd, string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + object value; + var profiles = cmd.TryGetValue("profiles", out value) ? value as Newtonsoft.Json.Linq.JObject : null; + + if (profiles == null) + { + EmitNpcError(reqId, "malformed", "a push carries `profiles`, an object of profiles by name"); + return; + } + + var refused = npc.Call("RunicNpc_SetProfiles", profiles) as Dictionary; + _lastNpcSummary = $"profiles pushed: {profiles.Count}, refused {(refused != null ? refused.Count : 0)}"; + Puts($"runicnpc: {_lastNpcSummary}"); + + Dictionary reply = Frame("npc.ok", "reply"); + reply["refused"] = refused ?? new Dictionary(); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. npc.placements: every placement with its values, how many of its NPCs + /// are alive, what it waits for and its note (D237, D239); the routes a placement may walk; + /// and the cost warning for what the server plans now (D227). + /// + private void HandleNpcPlacements(string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + var routes = npc.Call("RunicNpc_Routes") as Newtonsoft.Json.Linq.JObject; + var names = new List(); + + if (routes != null) + { + foreach (KeyValuePair route in routes) + names.Add(route.Key); + } + + names.Sort(StringComparer.Ordinal); + + Dictionary reply = Frame("npc.placements", "reply"); + reply["placements"] = npc.Call("RunicNpc_Placements") ?? new List>(); + reply["routes"] = names; + reply["cost"] = npc.Call("RunicNpc_CostWarning", 0) as string; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. npc.placement: one change to one placement, by op — add + /// (a new one, named by RunicNPC as in game, from a map point it puts on the ground and + /// checks against the navmesh: D245, D246), set (new values for an existing one), + /// remove, rename and respawn. Every refusal is RunicNPC's own sentence. + /// + private void HandleNpcPlacement(Dictionary cmd, string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + string op = StringField(cmd, "op"); + string id = StringField(cmd, "id"); + object value; + var placement = cmd.TryGetValue("placement", out value) ? value as Newtonsoft.Json.Linq.JObject : null; + Dictionary reply = Frame("npc.ok", "reply"); + + switch (op) + { + case "add": + { + if (placement == null) + { + EmitNpcError(reqId, "malformed", "add carries `placement`"); + return; + } + + var answer = npc.Call("RunicNpc_AddPlacement", placement) as Dictionary; + object error = null; + + if (answer == null || answer.TryGetValue("error", out error)) + { + EmitNpcError(reqId, "refused", answer != null ? error as string : "RunicNPC did not answer"); + return; + } + + Merge(reply, answer); + id = answer["id"] as string; + break; + } + + case "set": + { + if (string.IsNullOrEmpty(id) || placement == null) + { + EmitNpcError(reqId, "malformed", "set carries `id` and `placement`"); + return; + } + + if (!PlacementExists(npc, id)) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + string error = npc.Call("RunicNpc_SetPlacement", id, placement) as string; + + if (error != null) + { + EmitNpcError(reqId, "refused", error); + return; + } + + reply["id"] = id; + reply["cost"] = npc.Call("RunicNpc_CostWarning", 0) as string; + break; + } + + case "remove": + { + object removed = npc.Call("RunicNpc_RemovePlacement", id); + + if (!(removed is bool) || !(bool)removed) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + reply["id"] = id; + break; + } + + case "rename": + { + string to = StringField(cmd, "to"); + + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) + { + EmitNpcError(reqId, "malformed", "rename carries `id` and `to`"); + return; + } + + if (!PlacementExists(npc, id)) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + string error = npc.Call("RunicNpc_RenamePlacement", id, to) as string; + + if (error != null) + { + EmitNpcError(reqId, "refused", error); + return; + } + + reply["id"] = to; + reply["previous"] = id; + break; + } + + case "respawn": + { + object count = npc.Call("RunicNpc_RespawnPlacement", id); + int n = count is int ? (int)count : -1; + + if (n < 0) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + reply["id"] = id; + reply["respawned"] = n; + break; + } + + default: + EmitNpcError(reqId, "malformed", "op is add, set, remove, rename or respawn"); + return; + } + + _lastNpcSummary = $"placement {op} {id}"; + Puts($"runicnpc: {_lastNpcSummary}"); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private static bool PlacementExists(Plugin npc, string id) + { + var all = npc.Call("RunicNpc_Placements") as List>; + + if (all == null) + return false; + + foreach (Dictionary row in all) + { + object rowId; + + if (row.TryGetValue("id", out rowId) && rowId as string == id) + return true; + } + + return false; + } + + private void EmitNpcError(string reqId, string reason, string message) + { + Dictionary frame = Frame("npc.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastNpcSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// What the last RunicNPC command did, for rg.status. + private string _lastNpcSummary; + + // ---- RunicNPC's hooks, as frames ---- + + /// + /// One of RunicNPC's NPCs died (stage 4). npc.died carries its profile and name, who + /// landed the killing blow, and every player who took health from it, most first — what a + /// phase waiting on "8 guards died" counts, and what a boss's reward goes to (stage 6). + /// The per-player kill count is the tally's (npcProfileKills), not this frame's. + /// + private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) + { + MarkHook("OnRunicNpcDied"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.died", "event"); + PutNpc(frame, npc, profile, owner); + + BasePlayer killer = info != null ? info.InitiatorPlayer : null; + + if (killer != null && !killer.IsNpc) + { + frame["killerId"] = ((ulong)killer.userID).ToString(); + frame["killerName"] = killer.displayName; + } + + string weapon = WeaponOf(info); + + if (weapon != null) + frame["weapon"] = weapon; + + var rows = new List(); + + if (contributors != null) + { + var sorted = new List>(contributors); + sorted.Sort((a, b) => b.Value.CompareTo(a.Value)); + + foreach (KeyValuePair kv in sorted) + { + BasePlayer who = BasePlayer.FindByID(kv.Key) ?? BasePlayer.FindSleeping(kv.Key); + var row = new Dictionary + { + { "steamId", kv.Key.ToString() }, + { "damage", (float)Math.Round(kv.Value, 1) } + }; + + if (who != null) + row["name"] = who.displayName; + + rows.Add(row); + } + } + + frame["contributors"] = rows; + EmitFrame(frame); + } + + /// An NPC fell to one of its profile's healthThresholds (a boss below 50%). + private void OnRunicNpcHealth(BasePlayer npc, string profile, float threshold) + { + MarkHook("OnRunicNpcHealth"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.health", "event"); + PutNpc(frame, npc, profile, null); + frame["threshold"] = threshold; + frame["health"] = (float)Math.Round(npc.health, 1); + frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1); + EmitFrame(frame); + } + + /// + /// A placement was set, removed or renamed, from the site or in game (D249). The frame says + /// which; the site reads the list again rather than trusting a diff. + /// + private void OnRunicNpcPlacementChanged(string id, string change, string previous) + { + MarkHook("OnRunicNpcPlacementChanged"); + + Dictionary frame = Frame("npc.placement.changed", "event"); + frame["id"] = id; + frame["change"] = change; + + if (!string.IsNullOrEmpty(previous)) + frame["previous"] = previous; + + EmitFrame(frame); + } + + /// The fields every NPC frame shares. owner null reads it from the NPC's placement, if any. + private void PutNpc(Dictionary frame, BasePlayer npc, string profile, string owner) + { + frame["netId"] = npc.net != null ? npc.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : null; + frame["profile"] = profile; + frame["name"] = npc.displayName; + + if (!string.IsNullOrEmpty(owner)) + { + frame["owner"] = owner; + + // An event run's NPC names its run, for the site's phase gates; a placement's names + // the placement. `plugin:` owners are another plugin's business. + if (owner.StartsWith("run:", StringComparison.Ordinal)) + frame["runId"] = owner.Substring(4); + else if (owner.StartsWith("placement:", StringComparison.Ordinal)) + frame["placement"] = owner.Substring(10); + } + + UnityEngine.Vector3 at = npc.transform.position; + frame["x"] = (float)Math.Round(at.x, 1); + frame["z"] = (float)Math.Round(at.z, 1); + } + + // ---- events: an NPC of a profile (D243) ---- + + /// + /// Main thread. world.place with a profile: the NPCs are RunicNPC's, owned by + /// the run (run:<runId>), so its restart and teardown rules are the bridge's as + /// for any placed NPC (§15.3). All or nothing, like a prefab: if RunicNPC refuses one, the + /// ones already spawned are removed. + /// + private void HandleWorldPlaceProfile(Dictionary cmd, string reqId, string key, string runId, string profile) + { + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + { + EmitWorldError(reqId, "runicnpc-missing", $"RunicNPC is not loaded, so there is no profile '{profile}' here"); + return; + } + + var profiles = npc.Call("RunicNpc_Profiles") as Newtonsoft.Json.Linq.JObject; + Newtonsoft.Json.Linq.JToken known = profiles != null ? profiles["profiles"]?[profile] : null; + + if (known == null) + { + EmitWorldError(reqId, "unknown-profile", $"this server has no RunicNPC profile '{profile}'"); + return; + } + + Newtonsoft.Json.Linq.JToken refusal = profiles["refused"]?[profile]; + + if (refusal != null) + { + EmitWorldError(reqId, "unknown-profile", $"RunicNPC refuses the profile '{profile}': {refusal}"); + return; + } + + int count = IntField(cmd, "count"); + int max = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs; + + if (count < 1 || count > max) + { + EmitWorldError(reqId, "out-of-range", $"this server places 1 to {max} NPCs at a time, not {count}"); + return; + } + + double spread = DoubleField(cmd, "spread") ?? (count > 1 ? 10.0 : 0.0); + + if (spread < 0 || spread > WorldMaxSpread) + { + EmitWorldError(reqId, "out-of-range", $"a scatter is 0 to {WorldMaxSpread} m, not {spread}"); + return; + } + + UnityEngine.Vector3 center; + string reason, message; + + if (!ResolveWorldPoint(cmd, out center, out reason, out message)) + { + EmitWorldError(reqId, reason, message); + return; + } + + string owner = "run:" + runId; + var made = new List(); + + for (int i = 0; i < count; i++) + { + BasePlayer spawned = null; + + // A scattered point may land off the navmesh (a rock, a slope); a few tries, then the + // centre itself, before the step is refused. + for (int attempt = 0; attempt < 4 && spawned == null; attempt++) + { + UnityEngine.Vector3 at = center; + + if (count > 1 && attempt < 3) + { + UnityEngine.Vector2 jitter = UnityEngine.Random.insideUnitCircle * (float)spread; + at += new UnityEngine.Vector3(jitter.x, 0f, jitter.y); + at.y = TerrainMeta.HeightMap.GetHeight(at); + } + + spawned = npc.Call("RunicNpc_Spawn", at, profile, owner, null) as BasePlayer; + } + + if (spawned == null) + { + foreach (WorldEntry e in made) + npc.Call("RunicNpc_Despawn", ulong.Parse(e.Id, System.Globalization.CultureInfo.InvariantCulture)); + + EmitWorldError(reqId, "refused", + $"RunicNPC would not spawn '{profile}' there (off the navmesh, a cap, or the navmesh still building; the server log says which)"); + return; + } + + UnityEngine.Vector3 put = spawned.transform.position; + made.Add(new WorldEntry + { + Id = spawned.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), + Kind = WorldKindNpc, + Prefab = RunicNpcPrefix + profile, + RunId = runId, + Key = key, + X = put.x, + Y = put.y, + Z = put.z, + BootId = _bootId, + WipeId = _wipeId, + TakenMs = NowMs() + }); + } + + foreach (WorldEntry e in made) + _world[e.Id] = e; + + SaveWorld(); + + _lastWorldSummary = $"placed {count} of RunicNPC profile {profile} for run {runId}"; + Puts($"world: {_lastWorldSummary}"); + EmitWorldPlaced(reqId, made, false); + } + + /// A registry entry's prefab for a RunicNPC profile: runicnpc:<profile>. + private const string RunicNpcPrefix = "runicnpc:"; + + /// + /// 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. + /// + private bool KillWorldNpc(WorldEntry e) + { + Plugin npc = RunicNpcPlugin(); + ulong netId; + + if (npc != null && e.Prefab != null && e.Prefab.StartsWith(RunicNpcPrefix, StringComparison.Ordinal) + && ulong.TryParse(e.Id, out netId)) + { + object removed = npc.Call("RunicNpc_Despawn", netId); + + if (removed is int && (int)removed > 0) + return true; + } + + return KillEntity(e.Id); + } + + /// RunicNPC as the bridge sees it: loaded, its API, and the last command. + [ConsoleCommand("rg.npc")] + private void CmdNpc(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + Plugin npc = RunicNpcPlugin(); + arg.ReplyWith(npc == null + ? "RunicNPC: not loaded. npc.* commands answer runicnpc-missing; events place Rust's own scientists." + : $"RunicNPC {npc.Version}, API {RunicNpcApi()} (this bridge needs {RunicNpcApiNeeded}). last: {_lastNpcSummary ?? "none yet"}"); + } + // ── Kits ────────────────────────────────────────────────────────────────── private Plugin KitsPlugin() @@ -11018,7 +11715,8 @@ namespace Oxide.Plugins { { "betterChat", IntegrationBody(BetterChatPlugin()) }, { "popupNotifications", IntegrationBody(PopupPlugin()) }, - { "zoneDomes", ZoneDomesBody() } + { "zoneDomes", ZoneDomesBody() }, + { "runicNpc", RunicNpcBody() } }; }