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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-30 04:21:50 -05:00
parent 428055f6ef
commit 742e18ba0a

View File

@@ -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.
/// </para>
/// </summary>
private static void DescribeAttacker(
private void DescribeAttacker(
Dictionary<string, object> 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<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 Repaired;
public int Healed;
@@ -2792,6 +2831,11 @@ namespace Oxide.Plugins
Add(Crafted, category, amount);
}
public void KilledProfile(string profile)
{
Add(NpcProfileKills, profile, 1);
}
/// <summary>
/// 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";
}
/// <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>
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).
/// <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 ──────────────────────────────────────────────────────────────────
private Plugin KitsPlugin()
@@ -11018,7 +11715,8 @@ namespace Oxide.Plugins
{
{ "betterChat", IntegrationBody(BetterChatPlugin()) },
{ "popupNotifications", IntegrationBody(PopupPlugin()) },
{ "zoneDomes", ZoneDomesBody() }
{ "zoneDomes", ZoneDomesBody() },
{ "runicNpc", RunicNpcBody() }
};
}