feat(bridge): RunicNPC, the site's NPC profiles and placements (runicnpc stage 4) #22

Merged
whitlocktech merged 2 commits from feat/runicnpc-stage4 into edge 2026-09-30 14:52:44 -05:00
4 changed files with 731 additions and 7 deletions

View File

@@ -338,6 +338,10 @@ jobs:
# requires_plugins = ["Kits", "ZoneManager"] is already a JSON array. # requires_plugins = ["Kits", "ZoneManager"] is already a JSON array.
# One line, quoted strings only; anything else fails the check below. # One line, quoted strings only; anything else fails the check below.
REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' overlay.toml | sed -E 's/^[^=]*=[[:space:]]*//')" REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' overlay.toml | sed -E 's/^[^=]*=[[:space:]]*//')"
# The RunicNPC API the bridge's npc.* commands need (runicnpc stage 4). 0 when
# absent, which a bundle reads as "carries no RunicNPC".
RUNICNPC_API="$(grep -m1 -E '^runicnpc_api[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')"
RUNICNPC_API="${RUNICNPC_API:-0}"
[ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; } [ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; }
[ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from overlay.toml"; exit 1; } [ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from overlay.toml"; exit 1; }
[ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from overlay.toml"; exit 1; } [ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from overlay.toml"; exit 1; }
@@ -354,6 +358,7 @@ jobs:
--arg min_oxide "${MIN_OXIDE}" \ --arg min_oxide "${MIN_OXIDE}" \
--arg min_carbon "${MIN_CARBON}" \ --arg min_carbon "${MIN_CARBON}" \
--argjson requires "${REQUIRES}" \ --argjson requires "${REQUIRES}" \
--argjson runicnpc "${RUNICNPC_API}" \
--argjson files "${FILES}" \ --argjson files "${FILES}" \
'{ '{
component: $component, component: $component,
@@ -364,6 +369,7 @@ jobs:
min_oxide_version: $min_oxide, min_oxide_version: $min_oxide,
min_carbon_version: $min_carbon, min_carbon_version: $min_carbon,
requires_plugins: $requires, requires_plugins: $requires,
runicnpc_api: $runicnpc,
files: $files files: $files
}' > "${STAGE}/manifest.json" }' > "${STAGE}/manifest.json"
echo "----- manifest.json -----" echo "----- manifest.json -----"

View File

@@ -77,3 +77,15 @@ min_carbon_version = "2.0.259"
# asked of a server without it is refused with a reason, and chat is the default. # asked of a server without it is refused with a reason, and chat is the default.
# BetterChat is optional in the same way — titles and group styles wait for it. # BetterChat is optional in the same way — titles and group styles wait for it.
requires_plugins = ["Kits", "ZoneManager"] requires_plugins = ["Kits", "ZoneManager"]
# ── The RunicNPC API the bridge needs ───────────────────────────────────────
#
# RunicNPC (docs/runicnpc/PLAN.md stage 4): 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
# bundle carries the latest RunicNPC release that answers at least it (D224).
#
# 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

View File

@@ -248,7 +248,8 @@ namespace Oxide.Plugins
"OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair", "OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair",
"OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched", "OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched",
"OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded", "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded",
"OnEnterZone", "OnExitZone", "OnRgDomesReady" "OnEnterZone", "OnExitZone", "OnRgDomesReady",
"OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged"
}; };
private void Init() private void Init()
@@ -1068,6 +1069,25 @@ namespace Oxide.Plugins
HandleMapLive(reqId); HandleMapLive(reqId);
break; 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: default:
Puts($"unknown command '{verb}'"); Puts($"unknown command '{verb}'");
break; break;
@@ -1311,7 +1331,7 @@ namespace Oxide.Plugins
/// the environment case and not an error. /// the environment case and not an error.
/// </para> /// </para>
/// </summary> /// </summary>
private static void DescribeAttacker( private void DescribeAttacker(
Dictionary<string, object> frame, BasePlayer victim, HitInfo info) Dictionary<string, object> frame, BasePlayer victim, HitInfo info)
{ {
BaseEntity initiator = info != null ? info.Initiator : null; BaseEntity initiator = info != null ? info.Initiator : null;
@@ -1344,6 +1364,17 @@ namespace Oxide.Plugins
{ {
frame["attackerType"] = "npc"; frame["attackerType"] = "npc";
frame["attackerName"] = initiator.ShortPrefabName; 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); float distance = DistanceOf(info, victim);
@@ -1828,6 +1859,11 @@ namespace Oxide.Plugins
Tally tally = TallyFor(attacker); Tally tally = TallyFor(attacker);
tally.NpcKills++; tally.NpcKills++;
tally.Killed(info, entity, false); tally.Killed(info, entity, false);
string profile = RunicNpcProfileOf(victimPlayer);
if (profile != null)
tally.KilledProfile(profile);
CreditKill(attacker, false); CreditKill(attacker, false);
} }
@@ -2775,6 +2811,9 @@ namespace Oxide.Plugins
public int ApcKills; public int ApcKills;
public int HeliKills; public int HeliKills;
public readonly Dictionary<string, int> Crafted = new Dictionary<string, int>(); public readonly Dictionary<string, int> Crafted = new Dictionary<string, int>();
/// <summary>Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in <see cref="NpcKills"/>.</summary>
public readonly Dictionary<string, int> NpcProfileKills = new Dictionary<string, int>();
public int Built; public int Built;
public int Repaired; public int Repaired;
public int Healed; public int Healed;
@@ -2792,6 +2831,11 @@ namespace Oxide.Plugins
Add(Crafted, category, amount); Add(Crafted, category, amount);
} }
public void KilledProfile(string profile)
{
Add(NpcProfileKills, profile, 1);
}
/// <summary> /// <summary>
/// What a credited kill adds for the titles: its weapon (any victim, D174), a /// 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. /// 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 && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0
&& BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0 && BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0
&& Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 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; frame["explosives"] = tally.Explosives;
if (tally.Missions > 0) if (tally.Missions > 0)
frame["missions"] = tally.Missions; frame["missions"] = tally.Missions;
if (tally.NpcProfileKills.Count > 0)
frame["npcProfileKills"] = tally.NpcProfileKills;
EmitFrame(frame); EmitFrame(frame);
} }
@@ -4931,6 +4977,12 @@ namespace Oxide.Plugins
return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes"; return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes";
} }
/// <summary>RunicNPC's presence and API are in hello, so its load and unload say it again (stage 4).</summary>
private bool IsRunicNpc(Plugin plugin)
{
return plugin != null && plugin != this && plugin.Name == "RunicNPC";
}
/// <summary>Main thread. Hello again, once the world is up and the link is.</summary> /// <summary>Main thread. Hello again, once the world is up and the link is.</summary>
private void ResendHello() 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 // 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). // 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); NextTick(ResendHello);
// BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has // 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") if (plugin != null && plugin.Name == "BetterChat")
_titlesRegistered = false; _titlesRegistered = false;
if (IsZonePlugin(plugin)) if (IsZonePlugin(plugin) || IsRunicNpc(plugin))
NextTick(ResendHello); NextTick(ResendHello);
ConfigWriteJob job = _configJob; ConfigWriteJob job = _configJob;
@@ -8990,6 +9042,21 @@ namespace Oxide.Plugins
return; 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")); WorldPrefab prefab = FindWorldPrefab(StringField(cmd, "prefab"));
if (prefab == null) if (prefab == null)
@@ -9199,7 +9266,7 @@ namespace Oxide.Plugins
} }
else else
{ {
wasThere = KillEntity(e.Id); wasThere = e.Kind == WorldKindNpc ? KillWorldNpc(e) : KillEntity(e.Id);
} }
_world.Remove(e.Id); _world.Remove(e.Id);
@@ -10292,6 +10359,636 @@ namespace Oxide.Plugins
EnsureTallyTimer(); 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).
/// <summary>The RunicNPC API these commands call: 3 added placements from a website (D249).</summary>
private const int RunicNpcApiNeeded = 3;
private Plugin RunicNpcPlugin()
{
return plugins.Find("RunicNPC");
}
/// <summary>RunicNPC's API version, or 0 when it is not loaded.</summary>
private int RunicNpcApi()
{
Plugin npc = RunicNpcPlugin();
if (npc == null)
return 0;
object api = npc.Call("RunicNpc_ApiVersion");
return api is int ? (int)api : 0;
}
/// <summary>For the integrations body: loaded, its version, and the API it answers.</summary>
private Dictionary<string, object> RunicNpcBody()
{
Dictionary<string, object> body = IntegrationBody(RunicNpcPlugin());
if (RunicNpcPlugin() != null)
body["api"] = RunicNpcApi();
return body;
}
/// <summary>
/// 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.
/// </summary>
private string RunicNpcProfileOf(BaseEntity entity)
{
if (entity == null)
return null;
Plugin npc = RunicNpcPlugin();
return npc != null ? npc.Call("RunicNpc_ProfileOf", entity) as string : null;
}
/// <summary>
/// Main thread. The plugin to call, or null after answering <c>npc.error</c> — missing, or
/// older than the API these commands need.
/// </summary>
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;
}
/// <summary>
/// Main thread. <c>npc.profiles</c>: 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).
/// </summary>
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<string, object> 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);
}
/// <summary>
/// Main thread. <c>npc.profiles.set</c>: 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.
/// </summary>
private void HandleNpcProfilesSet(Dictionary<string, object> 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<string, string>;
_lastNpcSummary = $"profiles pushed: {profiles.Count}, refused {(refused != null ? refused.Count : 0)}";
Puts($"runicnpc: {_lastNpcSummary}");
Dictionary<string, object> reply = Frame("npc.ok", "reply");
reply["refused"] = refused ?? new Dictionary<string, string>();
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread. <c>npc.placements</c>: 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).
/// </summary>
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<string>();
if (routes != null)
{
foreach (KeyValuePair<string, Newtonsoft.Json.Linq.JToken> route in routes)
names.Add(route.Key);
}
names.Sort(StringComparer.Ordinal);
Dictionary<string, object> reply = Frame("npc.placements", "reply");
reply["placements"] = npc.Call("RunicNpc_Placements") ?? new List<Dictionary<string, object>>();
reply["routes"] = names;
reply["cost"] = npc.Call("RunicNpc_CostWarning", 0) as string;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread. <c>npc.placement</c>: one change to one placement, by <c>op</c> — <c>add</c>
/// (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), <c>set</c> (new values for an existing one),
/// <c>remove</c>, <c>rename</c> and <c>respawn</c>. Every refusal is RunicNPC's own sentence.
/// </summary>
private void HandleNpcPlacement(Dictionary<string, object> 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<string, object> 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<string, object>;
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<Dictionary<string, object>>;
if (all == null)
return false;
foreach (Dictionary<string, object> 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<string, object> 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);
}
/// <summary>What the last RunicNPC command did, for <c>rg.status</c>.</summary>
private string _lastNpcSummary;
// ---- RunicNPC's hooks, as frames ----
/// <summary>
/// One of RunicNPC's NPCs died (stage 4). <c>npc.died</c> 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 (<c>npcProfileKills</c>), not this frame's.
/// </summary>
private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)
{
MarkHook("OnRunicNpcDied");
if (npc == null)
return;
Dictionary<string, object> 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<object>();
if (contributors != null)
{
var sorted = new List<KeyValuePair<ulong, float>>(contributors);
sorted.Sort((a, b) => b.Value.CompareTo(a.Value));
foreach (KeyValuePair<ulong, float> kv in sorted)
{
BasePlayer who = BasePlayer.FindByID(kv.Key) ?? BasePlayer.FindSleeping(kv.Key);
var row = new Dictionary<string, object>
{
{ "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);
}
/// <summary>An NPC fell to one of its profile's <c>healthThresholds</c> (a boss below 50%).</summary>
private void OnRunicNpcHealth(BasePlayer npc, string profile, float threshold)
{
MarkHook("OnRunicNpcHealth");
if (npc == null)
return;
Dictionary<string, object> 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);
}
/// <summary>
/// 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.
/// </summary>
private void OnRunicNpcPlacementChanged(string id, string change, string previous)
{
MarkHook("OnRunicNpcPlacementChanged");
Dictionary<string, object> frame = Frame("npc.placement.changed", "event");
frame["id"] = id;
frame["change"] = change;
if (!string.IsNullOrEmpty(previous))
frame["previous"] = previous;
EmitFrame(frame);
}
/// <summary>The fields every NPC frame shares. <c>owner</c> null reads it from the NPC's placement, if any.</summary>
private void PutNpc(Dictionary<string, object> 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) ----
/// <summary>
/// Main thread. <c>world.place</c> with a <c>profile</c>: the NPCs are RunicNPC's, owned by
/// the run (<c>run:&lt;runId&gt;</c>), 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.
/// </summary>
private void HandleWorldPlaceProfile(Dictionary<string, object> 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<WorldEntry>();
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);
}
/// <summary>A registry entry's prefab for a RunicNPC profile: <c>runicnpc:&lt;profile&gt;</c>.</summary>
private const string RunicNpcPrefix = "runicnpc:";
/// <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.
/// </summary>
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);
}
/// <summary>RunicNPC as the bridge sees it: loaded, its API, and the last command.</summary>
[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 ────────────────────────────────────────────────────────────────── // ── Kits ──────────────────────────────────────────────────────────────────
private Plugin KitsPlugin() private Plugin KitsPlugin()
@@ -11018,7 +11715,8 @@ namespace Oxide.Plugins
{ {
{ "betterChat", IntegrationBody(BetterChatPlugin()) }, { "betterChat", IntegrationBody(BetterChatPlugin()) },
{ "popupNotifications", IntegrationBody(PopupPlugin()) }, { "popupNotifications", IntegrationBody(PopupPlugin()) },
{ "zoneDomes", ZoneDomesBody() } { "zoneDomes", ZoneDomesBody() },
{ "runicNpc", RunicNpcBody() }
}; };
} }

View File

@@ -198,6 +198,14 @@ function check(source, toml) {
// 5. The two declaration sites this repository owns must agree. // 5. The two declaration sites this repository owns must agree.
const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source) const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)
// runicnpc stage 4: the RunicNPC API the bridge needs, declared twice like the protocol.
const npcToml = /^\s*runicnpc_api\s*=\s*(\d+)\s*$/m.exec(toml)
const npcCode = /const\s+int\s+RunicNpcApiNeeded\s*=\s*(\d+)\s*;/.exec(source)
if (npcCode && !npcToml) problems.push('the plugin needs a RunicNPC API (RunicNpcApiNeeded) and overlay.toml declares no runicnpc_api')
if (npcCode && npcToml && npcCode[1] !== npcToml[1]) {
problems.push(`the plugin needs RunicNPC API ${npcCode[1]} and overlay.toml declares runicnpc_api = ${npcToml[1]}. The bundle picks a RunicNPC by the second, so they must agree.`)
}
const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml) const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml)
if (!inCode) problems.push('could not read ProtocolVersion from the plugin source') if (!inCode) problems.push('could not read ProtocolVersion from the plugin source')