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:
@@ -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:<runId></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:<profile></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() }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user