diff --git a/plugin.toml b/plugin.toml
index 66c490f..40bda9e 100644
--- a/plugin.toml
+++ b/plugin.toml
@@ -26,9 +26,12 @@
# 2 was stage 2's whole API, PLAN.md §4.
# 3 was stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the
# OnRunicNpcPlacementChanged hook.
-# Current: 4 — stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5
-# fields, a placement's tether, and the OnRunicNpcEscortEnded hook, documented in docs/runicnpc/API.md.
-api = 4
+# 4 was stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5
+# fields, a placement's tether, and the OnRunicNpcEscortEnded hook.
+# Current: 5 — stage 6: the profile's `boss` and `use` blocks and the passive role, the hooks
+# OnRunicNpcBossSpawned, _BossPhase, _BossDied and OnRunicNpcUsed, and `role` and `boss` in
+# RunicNpc_List, documented in docs/runicnpc/API.md.
+api = 5
# ── Framework floors ─────────────────────────────────────────────────────────
#
diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs
index 5670545..c120c3b 100644
--- a/plugin/RunicNPC.cs
+++ b/plugin/RunicNPC.cs
@@ -11,6 +11,7 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using Oxide.Core.Plugins;
+using Oxide.Game.Rust.Cui;
using UnityEngine;
using UnityEngine.AI;
@@ -58,7 +59,7 @@ namespace Oxide.Plugins
/// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md.
///
///
- private const int ApiVersion = 4;
+ private const int ApiVersion = 5;
///
/// Every hook this plugin implements. rnpc.status reports which have fired, because
@@ -74,7 +75,10 @@ namespace Oxide.Plugins
"OnEntitySpawned",
"OnEntityTakeDamage",
"CanBeTargeted",
- "CanEntityTakeDamage"
+ "CanEntityTakeDamage",
+ "OnPluginLoaded",
+ "OnPlayerInput",
+ "OnPlayerDisconnected"
};
private readonly Dictionary _hookCounts = new Dictionary();
@@ -230,9 +234,84 @@ namespace Oxide.Plugins
/// The kit's extras it uses (D260); all off by default, and each one used is used up (D265).
[JsonProperty("kitUse")] public KitUse KitUse = new KitUse();
+ // ---- stage 6 (D273–D279) ----
+
+ /// The boss box (D273): null for an ordinary NPC. Any role but passive may carry it.
+ [JsonProperty("boss")] public BossBlock Boss;
+
+ /// What pressing E on it does (D278); a passive profile's only, and required there.
+ [JsonProperty("use")] public UseBlock Use;
+
[JsonIgnore] public bool IsSentry => Role == "sentry";
[JsonIgnore] public bool IsGuard => Role == "guard";
+
+ /// D278, D279: never fights, press E to talk; cannot be hurt unless its profile says so.
+ [JsonIgnore] public bool IsPassive => Role == "passive";
+ }
+
+ ///
+ /// D273–D277: a health bar, phases at health thresholds, and lines said in game. Phases
+ /// change the NPC's own copy of its profile, never the shared one.
+ ///
+ public sealed class BossBlock
+ {
+ /// D274: players within this many metres see the bar, and so does everyone who has hurt it.
+ [JsonProperty("barDistance")] public float BarDistance = 100f;
+
+ /// D277: chat, or popup where PopupNotifications is loaded (chat where it is not).
+ [JsonProperty("announce")] public string Announce = "chat";
+
+ /// D286: said to everyone on the server. Blank says nothing.
+ [JsonProperty("spawnLine")] public string SpawnLine = "";
+
+ /// D286: said to everyone on the server. {killer} is the killing blow's player.
+ [JsonProperty("deathLine")] public string DeathLine = "";
+
+ [JsonProperty("phases")] public List Phases = new List();
+ }
+
+ /// D275: what happens when a boss falls to a fraction of its health. Every part is optional.
+ public sealed class Phase
+ {
+ [JsonProperty("at")] public float At;
+
+ /// Replaces the profile's damageDealt from here on.
+ [JsonProperty("damageDealt", NullValueHandling = NullValueHandling.Ignore)] public float? DamageDealt;
+
+ /// Replaces the profile's aimCone from here on.
+ [JsonProperty("aimCone", NullValueHandling = NullValueHandling.Ignore)] public float? AimCone;
+
+ /// A multiple of Rust's own speed for the NPC: 1.3 is a third faster.
+ [JsonProperty("speed", NullValueHandling = NullValueHandling.Ignore)] public float? Speed;
+
+ /// Replaces the profile's ranges from here on.
+ [JsonProperty("ranges", NullValueHandling = NullValueHandling.Ignore)] public Ranges Ranges;
+
+ /// A Kits kit it swaps to: strip, give, equip (stage 6 spike, finding 3).
+ [JsonProperty("kit", NullValueHandling = NullValueHandling.Ignore)] public string Kit;
+
+ [JsonProperty("adds", NullValueHandling = NullValueHandling.Ignore)] public Adds Adds;
+
+ /// D286: said only to the players near the boss and those who have hurt it.
+ [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] public string Line;
+ }
+
+ /// D275, D287: NPCs of another profile placed around the boss. They fight on after it dies.
+ public sealed class Adds
+ {
+ [JsonProperty("profile")] public string Profile;
+ [JsonProperty("count")] public int Count = 1;
+ }
+
+ /// D278: a chat line picked at random from lines, or a window with a title, text and Close.
+ public sealed class UseBlock
+ {
+ /// chat or window.
+ [JsonProperty("mode")] public string Mode = "chat";
+ [JsonProperty("lines")] public List Lines = new List();
+ [JsonProperty("title")] public string Title = "";
+ [JsonProperty("text")] public string Text = "";
}
/// D260: the behaviours a kit's extra items allow, each opted into.
@@ -354,11 +433,35 @@ namespace Oxide.Plugins
private void ReadProfileFile()
{
- _profileFile = ReadData(DataProfiles);
+ JObject raw = ReadData(DataProfiles);
+ try
+ {
+ if (raw["profiles"] is JObject)
+ foreach (JProperty p in ((JObject)raw["profiles"]).Properties())
+ NormalizePassive(p.Value as JObject);
+ _profileFile = raw.ToObject() ?? new ProfileFile();
+ }
+ catch (Exception e)
+ {
+ PrintError($"data/{DataProfiles}.json could not be read and is treated as empty: {e.Message}");
+ _profileFile = new ProfileFile();
+ }
+
if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary();
if (_profileFile.Factions == null) _profileFile.Factions = new List();
}
+ ///
+ /// D279: a passive NPC cannot be hurt unless its profile says it can. hurtByPlayers is on
+ /// by default for every other role, so a passive profile that leaves it out is given "off" as
+ /// it is read, wherever a profile comes in: the file, a site's push, or rnpc.profile.
+ ///
+ private static void NormalizePassive(JObject profile)
+ {
+ if (profile != null && (string)profile["role"] == "passive" && profile["hurtByPlayers"] == null)
+ profile["hurtByPlayers"] = false;
+ }
+
private void SaveProfiles() => Interface.Oxide.DataFileSystem.WriteObject(DataProfiles, _profileFile);
private void SaveRoutes() => Interface.Oxide.DataFileSystem.WriteObject(DataRoutes, _routeFile);
@@ -411,10 +514,12 @@ namespace Oxide.Plugins
return $"kits: this server has no kit '{kit}'";
if (ResolvePrefab(p.Prefab) == null)
return $"prefab: '{p.Prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)";
- if (p.Role != "roamer" && p.Role != "sentry" && p.Role != "guard")
- return $"role: '{p.Role}' is not roamer, sentry or guard";
+ if (p.Role != "roamer" && p.Role != "sentry" && p.Role != "guard" && p.Role != "passive")
+ return $"role: '{p.Role}' is not roamer, sentry, guard or passive";
string movement = ValidateMovement(p.Movement);
if (movement != null) return movement;
+ if (p.Movement.Mode == "stand" && !p.IsPassive)
+ return "movement.mode: stand is a passive NPC's; a fighting NPC that stands is a sentry";
if (!(p.Health > 0f)) return "health: must be above 0";
if (p.DamageDealt < 0f) return "damageDealt: must not be negative";
if (p.DamageTaken == null || p.DamageTaken.Head < 0f || p.DamageTaken.Body < 0f || p.DamageTaken.Legs < 0f)
@@ -442,6 +547,83 @@ namespace Oxide.Plugins
if (p.Turrets != "default" && p.Turrets != "ignore" && p.Turrets != "always")
return $"turrets: '{p.Turrets}' is not default, ignore or always";
if (p.KitUse == null) return "kitUse: an object, even an empty one";
+
+ // Stage 6 (D273–D279).
+ if (p.IsPassive)
+ {
+ if (p.Boss != null) return "boss: a passive NPC never fights, so it cannot be a boss";
+ if (p.Use == null) return "use: a passive NPC says what pressing E on it does (D278)";
+ }
+
+ if (p.Use != null && !p.IsPassive)
+ return "use: only a passive NPC answers E (D278)";
+ if (p.Use != null)
+ {
+ string use = ValidateUse(p.Use);
+ if (use != null) return use;
+ }
+
+ if (p.Boss != null)
+ {
+ string boss = ValidateBoss(p.Boss);
+ if (boss != null) return boss;
+ }
+
+ return null;
+ }
+
+ private const int MaxLine = 256;
+ private const int MaxWindowText = 2000;
+ private const int MaxAdds = 20;
+
+ private static string ValidateUse(UseBlock u)
+ {
+ if (u.Mode != "chat" && u.Mode != "window")
+ return $"use.mode: '{u.Mode}' is not chat or window";
+ if (u.Mode == "chat" && (u.Lines == null || u.Lines.Count == 0 || u.Lines.Any(string.IsNullOrWhiteSpace)))
+ return "use.lines: a chat answer gives at least one line, and no blank ones";
+ if (u.Lines != null && u.Lines.Any(l => l != null && l.Length > MaxLine))
+ return $"use.lines: each line is at most {MaxLine} characters";
+ if (u.Mode == "window" && string.IsNullOrWhiteSpace(u.Title) && string.IsNullOrWhiteSpace(u.Text))
+ return "use: a window has a title or some text";
+ if ((u.Title ?? "").Length > 80) return "use.title: at most 80 characters";
+ if ((u.Text ?? "").Length > MaxWindowText) return $"use.text: at most {MaxWindowText} characters";
+ return null;
+ }
+
+ private string ValidateBoss(BossBlock b)
+ {
+ if (!(b.BarDistance > 0f && b.BarDistance <= 1000f)) return "boss.barDistance: above 0 and at most 1000 metres";
+ if (b.Announce != "chat" && b.Announce != "popup") return $"boss.announce: '{b.Announce}' is not chat or popup";
+ if ((b.SpawnLine ?? "").Length > MaxLine || (b.DeathLine ?? "").Length > MaxLine)
+ return $"boss: a line is at most {MaxLine} characters";
+ if (b.Phases == null) return "boss.phases: a list, even an empty one";
+ var seen = new HashSet();
+ for (int i = 0; i < b.Phases.Count; i++)
+ {
+ Phase ph = b.Phases[i];
+ string at = $"boss.phases[{i}]";
+ if (ph == null) return $"{at}: it is empty";
+ if (!(ph.At > 0f && ph.At < 1f)) return $"{at}.at: a fraction of its health between 0 and 1";
+ if (!seen.Add(ph.At)) return $"{at}.at: two phases at {ph.At}";
+ if (ph.DamageDealt.HasValue && ph.DamageDealt.Value < 0f) return $"{at}.damageDealt: must not be negative";
+ if (ph.AimCone.HasValue && ph.AimCone.Value < 0f) return $"{at}.aimCone: must not be negative";
+ if (ph.Speed.HasValue && !(ph.Speed.Value > 0f && ph.Speed.Value <= 5f)) return $"{at}.speed: above 0 and at most 5 (times Rust's own)";
+ if (ph.Ranges != null && (!(ph.Ranges.Sense > 0f) || !(ph.Ranges.Attack > 0f) || ph.Ranges.LoseTarget < ph.Ranges.Sense || ph.Ranges.Chase < 0f))
+ return $"{at}.ranges: sense and attack above 0, loseTarget at least sense, chase not negative";
+ if (ph.Kit != null && !KitExists(ph.Kit)) return $"{at}.kit: this server has no kit '{ph.Kit}'";
+ if (ph.Adds != null)
+ {
+ if (ph.Adds.Profile == null || !NameRule.IsMatch(ph.Adds.Profile)) return $"{at}.adds.profile: a profile name";
+ if (ph.Adds.Count < 1 || ph.Adds.Count > MaxAdds) return $"{at}.adds.count: 1 to {MaxAdds}";
+ Profile add;
+ if (_profileFile.Profiles.TryGetValue(ph.Adds.Profile, out add) && add != null && add.Boss != null)
+ return $"{at}.adds.profile: '{ph.Adds.Profile}' is a boss, and a boss does not summon bosses";
+ }
+
+ if ((ph.Line ?? "").Length > MaxLine) return $"{at}.line: at most {MaxLine} characters";
+ }
+
return null;
}
@@ -500,17 +682,21 @@ namespace Oxide.Plugins
if (m == null) return "movement: missing";
string kind, route;
if (!ParseMode(m.Mode, out kind, out route))
- return $"movement.mode: '{m.Mode}' is not wander, monument or route:";
+ return $"movement.mode: '{m.Mode}' is not wander, monument, route: or (passive only) stand";
if (kind == "wander" && !(m.Radius > 0f))
return "movement.radius: a wanderer's radius must be above 0";
return null;
}
+ /// A sentry, or a passive NPC whose movement is stand (D288): it holds its spot, on the navmesh or off it.
+ private static bool Still(Profile p, Movement placementMovement) =>
+ p.IsSentry || (p.IsPassive && (placementMovement ?? p.Movement)?.Mode == "stand");
+
private static bool ParseMode(string mode, out string kind, out string route)
{
kind = null;
route = null;
- if (mode == "wander" || mode == "monument")
+ if (mode == "wander" || mode == "monument" || mode == "stand")
{
kind = mode;
return true;
@@ -613,7 +799,7 @@ namespace Oxide.Plugins
#region The swap (D223, D232)
//
- // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 2573b149-ba5d-458a-9b83-7f5f32c76afc.
+ // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 5c6eb1c7-ffa2-4b1e-a218-0a66bcc56f9e.
// Do not edit by hand: regenerate after a Rust update (D232).
private static readonly string[] SwapNpcFields =
{
@@ -636,24 +822,14 @@ namespace Oxide.Plugins
"NPCPlayer.damageScale",
"NPCPlayer.shortRange",
"NPCPlayer.attackLengthMaxShortRangeScale",
+ "BasePlayer.debugCommandedNpcs",
"BasePlayer.GestureViewModel",
- "BasePlayer.currentTeam",
- "BasePlayer.PetEntity",
"BasePlayer.lastPlayerVisibility",
"BasePlayer.eyeHistory",
"BasePlayer.tickHistory",
"BasePlayer.fallDamageEffect",
"BasePlayer.drownEffect",
- "BasePlayer.playerFlags",
"BasePlayer.Belt",
- "BasePlayer.weaponMoveSpeedScale",
- "BasePlayer.clothingBlocksAiming",
- "BasePlayer.clothingMoveSpeedReduction",
- "BasePlayer.clothingWaterSpeedBonus",
- "BasePlayer.clothingAccuracyBonus",
- "BasePlayer.equippingBlocked",
- "BasePlayer.eggVision",
- "BasePlayer.designingAIEntity",
"BaseCombatEntity.spawnDeployableCorpseOnDeath",
"BaseCombatEntity.deployableCorpsePrefab",
"BaseCombatEntity.skeletonProperties",
@@ -668,6 +844,7 @@ namespace Oxide.Plugins
"BaseCombatEntity.sendsMeleeHitNotification",
"BaseCombatEntity.markAttackerHostile",
"BaseCombatEntity.faction",
+ "BaseEntity.faces",
"BaseEntity.bounds",
"BaseEntity.impactEffect",
"BaseEntity.enableSaving",
@@ -803,7 +980,7 @@ namespace Oxide.Plugins
/// swapped for ours. Everything the brain reads once, as it starts, is set here (stage 1,
/// Q3: Senses.Init copies the sense values a single time).
///
- private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, out string error)
+ private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, bool still, out string error)
{
error = null;
BaseEntity made = GameManager.server.CreateEntity(ResolvePrefab(p.Prefab), at, Quaternion.Euler(0f, yaw, 0f), false);
@@ -835,7 +1012,8 @@ namespace Oxide.Plugins
// Our own sleep replaces an AI zone's (D235): a zone must never put ours to sleep.
brain.AllowedToSleep = false;
- brain.Sentry = p.IsSentry;
+ // A sentry, and a passive NPC that stands (D288), hold their spot and turn.
+ brain.Sentry = still;
return npc;
}
@@ -893,6 +1071,21 @@ namespace Oxide.Plugins
public new float EngagementRange() => Profile != null ? Profile.Ranges.Attack : base.EngagementRange();
+ ///
+ /// D279: a passive NPC never fights. Rust's design picks its target from every player it has
+ /// sensed (HumanNPC.GetBestTarget reads Senses.Players, which IsTarget
+ /// does not filter) and shoots through AttackTick; both come through IAIAttack, which
+ /// this class re-implements, so a passive one has no target and no attack.
+ ///
+ public new BaseEntity GetBestTarget() => Profile != null && Profile.IsPassive ? null : base.GetBestTarget();
+
+ public new void AttackTick(float delta, BaseEntity target, bool targetIsLOS)
+ {
+ if (Profile != null && Profile.IsPassive)
+ return;
+ base.AttackTick(delta, target, targetIsLOS);
+ }
+
// ---- stage 5: whom it fights, beside Rust's own design (D231, D254–D272) ----
///
@@ -933,7 +1126,23 @@ namespace Oxide.Plugins
/// escorting (D257, D269). IAISenses is re-implemented so Rust's senses ask here.
///
public new bool IsTarget(BaseEntity entity) =>
- base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity));
+ (Profile == null || !Profile.IsPassive) && base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity));
+
+ // ---- stage 6: the boss (D273–D277) ----
+
+ /// The phases it has entered, by their at; each is entered once.
+ public readonly HashSet PhasesFired = new HashSet();
+
+ /// The phase it is in: 0 before the first.
+ public int Phase;
+
+ /// Rust's own navigator speed at spawn, which a phase's speed multiplies.
+ public float BaseSpeed = -1f;
+
+ /// Its bar needs a redraw: set on every hit, cleared by the next redraw (at most four a second).
+ public bool BarDirty;
+
+ public bool IsBoss => Profile != null && Profile.Boss != null;
public new bool IsThreat(BaseEntity entity) => IsTarget(entity);
@@ -1022,7 +1231,20 @@ namespace Oxide.Plugins
Credit(attackerId, _healthBefore - health);
if (Profile != null && !IsDead())
{
+ // D279: Rust's own Hurt teaches the NPC its attacker; a passive one forgets at once.
+ if (Profile.IsPassive && Brain?.Senses?.Memory != null)
+ {
+ Brain.Senses.Memory.Targets.Clear();
+ Brain.Senses.Memory.Threats.Clear();
+ }
+
_self?.CheckThresholds(this);
+ if (IsBoss)
+ {
+ BarDirty = true;
+ _self?.CheckPhases(this, attackerId);
+ }
+
// An unkillable NPC (D271) takes nothing and still answers the attack.
if (info != null && info.Initiator != null)
{
@@ -1902,7 +2124,7 @@ namespace Oxide.Plugins
private void NoteDefender(RunicNpcPlayer npc)
{
- if (npc.HasAlly || npc.Escort != null)
+ if ((npc.HasAlly || npc.Escort != null) && !npc.Profile.IsPassive)
_defenders.Add(npc);
else
_defenders.Remove(npc);
@@ -1933,6 +2155,8 @@ namespace Oxide.Plugins
///
private void Fight(RunicNpcPlayer npc, RunicNpcBrain brain, float delta)
{
+ if (npc.Profile.IsPassive)
+ return;
float now = Time.time;
HashSet hunts = HuntsOf(npc);
BaseCombatEntity t = npc.NpcTarget;
@@ -2110,6 +2334,9 @@ namespace Oxide.Plugins
/// Called from the NPC's own Hurt, after Rust's.
private void NpcHurt(RunicNpcPlayer npc, HitInfo info)
{
+ // D279: a passive NPC never fights back, and raises no alarm.
+ if (npc.Profile.IsPassive)
+ return;
BaseEntity source = info.Initiator;
BaseCombatEntity attacker = TurretOf(source) ?? TurretOf(info.Weapon != null ? info.Weapon.GetParentEntity() : null) ?? source as BaseCombatEntity;
if (attacker == null || attacker == npc || attacker.IsDestroyed || attacker.IsDead())
@@ -2158,7 +2385,8 @@ namespace Oxide.Plugins
/// Tells one of ours about an attacker: Rust's memory for a player, our own target for anything else.
private void Learn(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real)
{
- if (Spares(npc, attacker))
+ // D279: a passive NPC never fights, so an ally's alarm does not reach it.
+ if (npc.Profile.IsPassive || Spares(npc, attacker))
return;
npc.RnBrain?.Rouse();
if (real)
@@ -2899,14 +3127,21 @@ namespace Oxide.Plugins
Movement movement = movementOverride ?? p.Movement;
string kind, route;
ParseMode(movement.Mode, out kind, out route);
- if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route))
+ if (kind == "stand" && !p.IsPassive)
+ {
+ error = "movement stand is a passive NPC's; a fighting NPC that stands is a sentry";
+ return null;
+ }
+
+ bool still = p.IsSentry || kind == "stand";
+ if (!still && kind == "route" && !_routeFile.Routes.ContainsKey(route))
{
error = $"route '{route}' does not exist";
return null;
}
Vector3 position = at;
- if (!p.IsSentry)
+ if (!still)
{
// Rust's sampler reaches further down than across: without the height check, a spot
// on a roof finds the ground below it (stage 2).
@@ -2926,7 +3161,11 @@ namespace Oxide.Plugins
return null;
}
- RunicNpcPlayer npc = CreateNpc(p, position, yaw, out error);
+ // A boss's phases change its stats, so it gets its own copy of the profile (D275).
+ if (p.Boss != null)
+ p = Effective(p, null);
+
+ RunicNpcPlayer npc = CreateNpc(p, position, yaw, still, out error);
if (npc == null)
return null;
@@ -2960,6 +3199,8 @@ namespace Oxide.Plugins
_npcs[npc.NetId] = npc;
Index(npc, true);
Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner);
+ if (p.Boss != null)
+ BossSpawned(npc);
return npc;
}
@@ -2969,6 +3210,8 @@ namespace Oxide.Plugins
if (!_npcs.Remove(npc.NetId))
return;
Index(npc, false);
+ if (npc.IsBoss)
+ ClearBar(npc);
if (npc.PlacementId != null)
{
@@ -3008,6 +3251,8 @@ namespace Oxide.Plugins
var contributors = new Dictionary(npc.Contributors);
Unregister(npc);
Interface.CallHook("OnRunicNpcDied", npc, npc.ProfileName, npc.Owner, info, contributors);
+ if (npc.IsBoss)
+ BossDied(npc, info, contributors);
}
private void NoteRegrounded(RunicNpcPlayer npc, Vector3 from, Vector3 to)
@@ -3089,7 +3334,7 @@ namespace Oxide.Plugins
{
if (_placementsDirty)
SavePlacements();
- if (!_ready || _unloading)
+ if (!_ready || _unloading || _holdForKits)
return;
float now = Time.realtimeSinceStartup;
@@ -3219,13 +3464,13 @@ namespace Oxide.Plugins
// A scattered spot that leaves a small floor takes the placement's own spot instead.
Vector2 offset = UnityEngine.Random.insideUnitCircle * Scatter;
at = spot + new Vector3(offset.x, 0f, offset.y);
- if (!p.IsSentry && OffMeshReason(at) != null)
+ if (!Still(p, data.Movement) && OffMeshReason(at) != null)
at = spot;
}
// D239: the spot was on the navmesh when it was placed. If the world has changed under it (a
// player-built floor destroyed), the NPC stands on the nearest navmesh until the spot returns.
- string off = p.IsSentry ? null : OffMeshReason(at);
+ string off = Still(p, data.Movement) ? null : OffMeshReason(at);
NavMeshHit nearest;
if (off != null && SampleNavmesh(at, Fallback, out nearest))
{
@@ -3283,6 +3528,588 @@ namespace Oxide.Plugins
#endregion
+ #region Bosses (stage 6: D273–D277, D286, D287)
+
+ [PluginReference] private Plugin PopupNotifications;
+
+ private const string BarName = "rnpc.bar";
+ private const string BarFill = "rnpc.bar.fill";
+ private const string BarText = "rnpc.bar.text";
+
+ /// How far from the boss its adds are placed (D275), before each point is put on the navmesh.
+ private const float AddRing = 6f;
+
+ /// The bosses alive now.
+ private readonly HashSet _bosses = new HashSet();
+
+ /// Whose bar each player sees, by Steam id: one boss at a time, the nearest that qualifies (D274).
+ private readonly Dictionary _barShown = new Dictionary();
+
+ private int _barTick;
+
+ private void BossSpawned(RunicNpcPlayer npc)
+ {
+ _bosses.Add(npc);
+ BossBlock b = npc.Profile.Boss;
+ if (!string.IsNullOrWhiteSpace(b.SpawnLine))
+ Say(b, BossText(b.SpawnLine, npc, null), null);
+ Interface.CallHook("OnRunicNpcBossSpawned", npc, npc.ProfileName, npc.Owner);
+ }
+
+ private void BossDied(RunicNpcPlayer npc, HitInfo info, Dictionary contributors)
+ {
+ BossBlock b = npc.Profile.Boss;
+ BasePlayer killer = info != null ? info.InitiatorPlayer : null;
+ if (killer != null && killer.IsNpc)
+ killer = null;
+ if (!string.IsNullOrWhiteSpace(b.DeathLine))
+ Say(b, BossText(b.DeathLine, npc, killer), null);
+ Interface.CallHook("OnRunicNpcBossDied", npc, npc.ProfileName, npc.Owner, info, contributors);
+ }
+
+ /// A line's placeholders: {name} is the boss's name, {killer} the killing blow's player.
+ private static string BossText(string line, RunicNpcPlayer npc, BasePlayer killer) =>
+ line.Replace("{name}", npc.RnName).Replace("{killer}", killer != null ? killer.displayName : "");
+
+ ///
+ /// D277, D286: a line said in game, to everyone ( null) or to the players
+ /// given. A popup where the profile asks for one and PopupNotifications is loaded; chat otherwise.
+ ///
+ private void Say(BossBlock b, string text, IEnumerable to)
+ {
+ bool popup = b.Announce == "popup" && PopupNotifications != null && PopupNotifications.IsLoaded;
+ if (to == null)
+ {
+ if (popup)
+ PopupNotifications.Call("CreatePopupNotification", text, null, 0f);
+ else
+ PrintToChat(text);
+ return;
+ }
+
+ foreach (BasePlayer p in to)
+ {
+ if (p == null || !p.IsConnected)
+ continue;
+ if (popup)
+ PopupNotifications.Call("CreatePopupNotification", text, p, 0f);
+ else
+ p.ChatMessage(text);
+ }
+ }
+
+ /// D286: the players a phase line reaches: within the bar's distance, or who have hurt it.
+ private static List Near(RunicNpcPlayer npc)
+ {
+ float r = npc.Profile.Boss.BarDistance;
+ Vector3 at = npc.transform.position;
+ return BasePlayer.activePlayerList
+ .Where(p => p != null && !p.IsNpc && ((p.transform.position - at).sqrMagnitude <= r * r || npc.Contributors.ContainsKey((ulong)p.userID)))
+ .ToList();
+ }
+
+ /// Called from the boss's Hurt: enters every phase its health has fallen to, highest first.
+ private void CheckPhases(RunicNpcPlayer npc, ulong attackerId)
+ {
+ float fraction = npc.health / npc.MaxHealth();
+ foreach (Phase ph in npc.Profile.Boss.Phases.OrderByDescending(x => x.At))
+ if (fraction <= ph.At && npc.PhasesFired.Add(ph.At))
+ EnterPhase(npc, ph);
+ }
+
+ /// D275: a phase changes the boss's own copy of its profile (Spawn made it), never the shared one.
+ private void EnterPhase(RunicNpcPlayer npc, Phase ph)
+ {
+ npc.Phase = npc.PhasesFired.Count;
+ Profile p = npc.Profile;
+ RunicNpcBrain brain = npc.RnBrain;
+
+ if (ph.DamageDealt.HasValue)
+ {
+ p.DamageDealt = ph.DamageDealt.Value;
+ npc.damageScale = p.DamageDealt;
+ }
+
+ if (ph.AimCone.HasValue)
+ {
+ p.AimCone = ph.AimCone.Value;
+ npc.aimConeScale = p.AimCone;
+ }
+
+ if (ph.Ranges != null)
+ {
+ p.Ranges = new Ranges { Sense = ph.Ranges.Sense, LoseTarget = ph.Ranges.LoseTarget, Chase = ph.Ranges.Chase, Attack = ph.Ranges.Attack };
+ if (brain != null)
+ {
+ brain.SenseRange = p.Ranges.Sense;
+ brain.TargetLostRange = p.Ranges.LoseTarget;
+ }
+ }
+
+ // Rust sets the agent's speed from the navigator's on every move, so this holds.
+ if (ph.Speed.HasValue && brain?.Navigator != null)
+ {
+ if (npc.BaseSpeed < 0f)
+ npc.BaseSpeed = brain.Navigator.Speed;
+ brain.Navigator.Speed = npc.BaseSpeed * ph.Speed.Value;
+ }
+
+ // The spike (finding 3): strip, give, equip. Without EquipWeapon it holds nothing.
+ if (ph.Kit != null)
+ {
+ npc.inventory.Strip();
+ object given = Kits?.Call("GiveKit", npc, ph.Kit);
+ if (given is string)
+ PrintWarning($"Kits did not give '{ph.Kit}' to {npc.RnName} ({npc.ProfileName}) in phase {npc.Phase}: {given}");
+ else
+ npc.Kit = ph.Kit;
+ npc.EquipWeapon();
+ }
+
+ if (ph.Adds != null)
+ SummonAdds(npc, ph.Adds);
+
+ if (!string.IsNullOrWhiteSpace(ph.Line))
+ Say(p.Boss, BossText(ph.Line, npc, null), Near(npc));
+
+ Interface.CallHook("OnRunicNpcBossPhase", npc, npc.ProfileName, npc.Owner, npc.Phase, ph.At);
+ }
+
+ ///
+ /// D275, D287: adds of another profile on a ring around the boss, one per server frame (the
+ /// spike measured 4–8 ms each). They belong to the boss's owner, so an event's teardown removes
+ /// them, and they fight on after the boss dies. Each learns the boss's attackers nearby.
+ ///
+ private void SummonAdds(RunicNpcPlayer boss, Adds adds)
+ {
+ Profile p;
+ if (!_profiles.TryGetValue(adds.Profile, out p))
+ {
+ PrintWarning($"{boss.RnName} ({boss.ProfileName}) cannot summon '{adds.Profile}': " +
+ (_refused.ContainsKey(adds.Profile) ? "it is refused: " + _refused[adds.Profile] : "there is no such profile"));
+ return;
+ }
+
+ Vector3 centre = boss.transform.position;
+ string owner = boss.Owner;
+ var attackers = boss.Contributors.Keys.ToList();
+ float turn = UnityEngine.Random.value * Mathf.PI * 2f;
+ int i = 0;
+ Action next = null;
+ next = () =>
+ {
+ if (_unloading || i >= adds.Count)
+ return;
+ float a = turn + i * Mathf.PI * 2f / adds.Count;
+ Vector3 ring = centre + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * AddRing;
+ string error;
+ NavMeshHit hit;
+ RunicNpcPlayer add = null;
+ if (SampleNavmesh(ring, 3f, out hit))
+ add = Spawn(adds.Profile, p, hit.position, UnityEngine.Random.Range(0f, 360f), owner, null, null, -1, out error);
+ else
+ error = "no navmesh within 3 m of its spot";
+ if (add == null)
+ PrintWarning($"{boss.RnName} ({boss.ProfileName}): add {i + 1} of {adds.Count} ('{adds.Profile}') not placed: {error}");
+ else
+ foreach (ulong id in attackers)
+ {
+ BasePlayer who = BasePlayer.FindByID(id);
+ if (who != null && who.IsAlive() && Vector3.Distance(who.transform.position, add.transform.position) <= 100f)
+ Learn(add, who, true);
+ }
+
+ i++;
+ if (i < adds.Count)
+ NextFrame(next);
+ };
+ next();
+ }
+
+ // ---- the bar (D274): the stage 6 spike's shape (finding 2) ----
+
+ ///
+ /// Four times a second: redraw each hurt boss's bar in place for its viewers. Once a second:
+ /// work out who sees which bar, adding and removing whole bars.
+ ///
+ private void BarTick()
+ {
+ if (_bosses.Count == 0 && _barShown.Count == 0)
+ return;
+ if (++_barTick % 4 == 0)
+ SweepViewers();
+ foreach (RunicNpcPlayer boss in _bosses)
+ {
+ if (!boss.BarDirty || boss.IsDestroyed)
+ continue;
+ boss.BarDirty = false;
+ string json = null;
+ foreach (KeyValuePair kv in _barShown)
+ {
+ if (kv.Value != boss)
+ continue;
+ BasePlayer p = BasePlayer.FindByID(kv.Key);
+ if (p == null || !p.IsConnected)
+ continue;
+ if (json == null)
+ json = BarJson(boss, true);
+ CuiHelper.AddUi(p, json);
+ }
+ }
+ }
+
+ private void SweepViewers()
+ {
+ var want = new Dictionary();
+ foreach (BasePlayer p in BasePlayer.activePlayerList)
+ {
+ if (p == null || p.IsNpc || !p.IsConnected)
+ continue;
+ ulong id = (ulong)p.userID;
+ RunicNpcPlayer best = null;
+ float bestD = float.MaxValue;
+ foreach (RunicNpcPlayer b in _bosses)
+ {
+ if (b == null || b.IsDestroyed || b.IsDead())
+ continue;
+ float d = (b.transform.position - p.transform.position).sqrMagnitude;
+ float r = b.Profile.Boss.BarDistance;
+ if ((d <= r * r || b.Contributors.ContainsKey(id)) && d < bestD)
+ {
+ best = b;
+ bestD = d;
+ }
+ }
+
+ if (best != null)
+ want[id] = best;
+ }
+
+ foreach (KeyValuePair kv in _barShown.ToList())
+ {
+ RunicNpcPlayer now;
+ if (want.TryGetValue(kv.Key, out now) && now == kv.Value)
+ continue;
+ BasePlayer p = BasePlayer.FindByID(kv.Key);
+ if (p != null && p.IsConnected)
+ CuiHelper.DestroyUi(p, BarName);
+ _barShown.Remove(kv.Key);
+ }
+
+ foreach (KeyValuePair kv in want)
+ {
+ if (_barShown.ContainsKey(kv.Key))
+ continue;
+ BasePlayer p = BasePlayer.FindByID(kv.Key);
+ if (p == null)
+ continue;
+ CuiHelper.AddUi(p, BarJson(kv.Value, false));
+ _barShown[kv.Key] = kv.Value;
+ }
+ }
+
+ /// A boss gone (death, despawn, unload): its bar comes off every screen showing it.
+ private void ClearBar(RunicNpcPlayer npc)
+ {
+ _bosses.Remove(npc);
+ foreach (KeyValuePair kv in _barShown.Where(x => x.Value == npc).ToList())
+ {
+ BasePlayer p = BasePlayer.FindByID(kv.Key);
+ if (p != null && p.IsConnected)
+ CuiHelper.DestroyUi(p, BarName);
+ _barShown.Remove(kv.Key);
+ }
+ }
+
+ ///
+ /// The bar: a dark strip at the top of the screen, a red fill and the boss's name and health.
+ /// A redraw sends only the fill and the text, changed in place with CUI's update.
+ ///
+ private static string BarJson(RunicNpcPlayer boss, bool update)
+ {
+ float fraction = Mathf.Clamp01(boss.health / boss.MaxHealth());
+ string text = $"{boss.RnName} {Mathf.CeilToInt(boss.health)} / {Mathf.CeilToInt(boss.MaxHealth())}";
+ var c = new CuiElementContainer();
+ if (!update)
+ c.Add(new CuiPanel { Image = { Color = "0.1 0.1 0.1 0.75" }, RectTransform = { AnchorMin = "0.35 0.905", AnchorMax = "0.65 0.935" } }, "Hud", BarName);
+ c.Add(new CuiPanel { Image = { Color = "0.72 0.12 0.1 0.9" }, RectTransform = { AnchorMin = "0 0", AnchorMax = $"{fraction.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)} 1" } }, BarName, BarFill);
+ c.Add(new CuiLabel { Text = { Text = text, FontSize = 13, Align = TextAnchor.MiddleCenter, Color = "1 1 1 0.95" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, BarName, BarText);
+ if (update)
+ foreach (CuiElement e in c)
+ e.Update = true;
+ return CuiHelper.ToJson(c);
+ }
+
+ #endregion
+
+ #region Passive NPCs and press E (stage 6: D278, D279, D288)
+
+ private const string WindowName = "rnpc.window";
+
+ /// How far a player's E reaches: Rust's own use distance is about this.
+ private const float UseReach = 3f;
+
+ private static int _useMask;
+
+ private readonly Dictionary _nextUse = new Dictionary();
+
+ ///
+ /// Neither framework raises a hook for E on an NPC, and Rust ignores it (stage 6 spike, finding
+ /// 1): so every player tick is checked for E pressed, at about 7 ns, and only then is a ray cast.
+ ///
+ private void OnPlayerInput(BasePlayer player, InputState input)
+ {
+ MarkHook("OnPlayerInput");
+ if (input == null || !input.WasJustPressed(BUTTON.USE) || player == null || player.IsNpc)
+ return;
+ if (_useMask == 0)
+ _useMask = LayerMask.GetMask("Player (Server)", "Construction", "Deployed", "World", "Terrain", "Default", "Tree");
+
+ RaycastHit hit;
+ if (!Physics.Raycast(player.eyes.HeadRay(), out hit, UseReach, _useMask, QueryTriggerInteraction.Ignore))
+ return;
+ RunicNpcPlayer npc = hit.GetEntity() as RunicNpcPlayer;
+ if (npc?.Profile == null || !npc.Profile.IsPassive || npc.Profile.Use == null || npc.IsDead())
+ return;
+
+ ulong id = (ulong)player.userID;
+ float now = Time.realtimeSinceStartup, next;
+ if (_nextUse.TryGetValue(id, out next) && now < next)
+ return;
+ _nextUse[id] = now + 1f;
+ Use(npc, player);
+ }
+
+ /// D278: a chat line picked at random, or a window. {player} in either is the player's name.
+ private void Use(RunicNpcPlayer npc, BasePlayer player)
+ {
+ UseBlock u = npc.Profile.Use;
+ if (u.Mode == "chat")
+ {
+ string line = u.Lines[UnityEngine.Random.Range(0, u.Lines.Count)].Replace("{player}", player.displayName);
+ player.ChatMessage($"{npc.RnName}: {line}");
+ }
+ else
+ OpenWindow(player, npc, u);
+
+ // One that stands turns to whoever spoke to it.
+ if (npc.RnBrain != null && npc.RnBrain.Sentry)
+ npc.SetAimDirection((player.eyes.position - npc.eyes.position).normalized);
+
+ Interface.CallHook("OnRunicNpcUsed", npc, npc.ProfileName, player);
+ }
+
+ private static void OpenWindow(BasePlayer player, RunicNpcPlayer npc, UseBlock u)
+ {
+ CuiHelper.DestroyUi(player, WindowName);
+ string title = string.IsNullOrWhiteSpace(u.Title) ? npc.RnName : u.Title;
+ var c = new CuiElementContainer();
+ c.Add(new CuiPanel
+ {
+ Image = { Color = "0.08 0.08 0.08 0.92" },
+ RectTransform = { AnchorMin = "0.32 0.3", AnchorMax = "0.68 0.72" },
+ CursorEnabled = true
+ }, "Overlay", WindowName);
+ c.Add(new CuiLabel
+ {
+ Text = { Text = title.Replace("{player}", player.displayName), FontSize = 18, Align = TextAnchor.MiddleLeft, Color = "0.91 0.76 0.44 1" },
+ RectTransform = { AnchorMin = "0.05 0.85", AnchorMax = "0.95 0.96" }
+ }, WindowName);
+ c.Add(new CuiLabel
+ {
+ Text = { Text = (u.Text ?? "").Replace("{player}", player.displayName), FontSize = 14, Align = TextAnchor.UpperLeft, Color = "0.92 0.92 0.92 1" },
+ RectTransform = { AnchorMin = "0.05 0.17", AnchorMax = "0.95 0.82" }
+ }, WindowName);
+ c.Add(new CuiButton
+ {
+ Button = { Close = WindowName, Color = "0.3 0.3 0.3 0.9" },
+ Text = { Text = "Close", FontSize = 14, Align = TextAnchor.MiddleCenter },
+ RectTransform = { AnchorMin = "0.4 0.03", AnchorMax = "0.6 0.12" }
+ }, WindowName);
+ CuiHelper.AddUi(player, c);
+ }
+
+ private void OnPlayerDisconnected(BasePlayer player, string reason)
+ {
+ MarkHook("OnPlayerDisconnected");
+ if (player == null)
+ return;
+ ulong id = (ulong)player.userID;
+ _barShown.Remove(id);
+ _nextUse.Remove(id);
+ }
+
+ #endregion
+
+ #region The example profiles (stage 6: D280, D284, D289)
+
+ private const string DataState = "RunicNPC/state";
+
+ /// The permission every example kit needs, which nobody is granted: no player can claim one by name (D284).
+ private const string ExamplePermission = "runicnpc.examplekits";
+
+ private sealed class StateFile
+ {
+ /// D284: set on the first load that wrote them, and never cleared.
+ [JsonProperty("examplesWritten")] public bool ExamplesWritten;
+ }
+
+ /// Placements wait while Kits reloads with the example kits (D283 reloads RunicNPC with it).
+ private bool _holdForKits;
+
+ private static JObject ExItem(string shortname, int amount, int position, float condition = 0f) =>
+ JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = condition, MaxCondition = condition, Ammo = 0, Position = position, Frequency = -1 });
+
+ private static JObject ExKit(string name, JObject[] main, JObject[] wear, JObject[] belt) => new JObject
+ {
+ ["Name"] = name, ["Description"] = "A RunicNPC example kit, for its example NPCs", ["RequiredPermission"] = ExamplePermission,
+ ["MaximumUses"] = 0, ["RequiredAuth"] = 0, ["Cooldown"] = 0, ["Cost"] = 0, ["IsHidden"] = true,
+ ["CopyPasteFile"] = "", ["KitImage"] = "",
+ ["MainItems"] = new JArray(main), ["WearItems"] = new JArray(wear), ["BeltItems"] = new JArray(belt)
+ };
+
+ private static JObject[] Wear(params string[] items) => items.Select((s, i) => ExItem(s, 1, i)).ToArray();
+
+ /// D289: the four kits. A short name this Rust does not know is left out with a warning.
+ private static Dictionary ExampleKits() => new Dictionary
+ {
+ ["rnpc_raider"] = ExKit("rnpc_raider", new[] { ExItem("ammo.rifle", 120, 0) },
+ Wear("hoodie", "pants", "shoes.boots", "mask.bandana"), new[] { ExItem("rifle.semiauto", 1, 0, 100f) }),
+ ["rnpc_campguard"] = ExKit("rnpc_campguard", new[] { ExItem("ammo.pistol", 200, 0) },
+ Wear("metal.facemask", "metal.plate.torso", "hoodie", "pants", "shoes.boots"), new[] { ExItem("smg.thompson", 1, 0, 100f) }),
+ ["rnpc_sniper"] = ExKit("rnpc_sniper", new[] { ExItem("ammo.rifle", 60, 0) },
+ Wear("hat.boonie", "hoodie", "pants", "shoes.boots"), new[] { ExItem("rifle.bolt", 1, 0, 100f) }),
+ ["rnpc_juggernaut"] = ExKit("rnpc_juggernaut", new[] { ExItem("ammo.rifle", 300, 0) },
+ Wear("heavy.plate.helmet", "heavy.plate.jacket", "heavy.plate.pants", "shoes.boots"), new[] { ExItem("lmg.m249", 1, 0, 100f) })
+ };
+
+ private static JObject ExRanges(float sense, float lose, float chase, float attack) =>
+ JObject.FromObject(new { sense, loseTarget = lose, chase, attack });
+
+ /// D289: the four profiles.
+ private static Dictionary ExampleProfiles() => new Dictionary
+ {
+ ["raider"] = new JObject
+ {
+ ["names"] = new JArray("Raider"), ["kits"] = new JArray("rnpc_raider"), ["role"] = "roamer",
+ ["movement"] = JObject.FromObject(new { mode = "wander", radius = 40f }), ["health"] = 150f,
+ ["ranges"] = ExRanges(30f, 40f, 40f, 30f)
+ },
+ ["campguard"] = new JObject
+ {
+ ["names"] = new JArray("Camp Guard"), ["kits"] = new JArray("rnpc_campguard"), ["role"] = "guard",
+ ["movement"] = JObject.FromObject(new { mode = "wander", radius = 10f }), ["health"] = 200f,
+ ["ranges"] = ExRanges(35f, 45f, 30f, 30f)
+ },
+ ["sniper"] = new JObject
+ {
+ ["names"] = new JArray("Sniper"), ["kits"] = new JArray("rnpc_sniper"), ["role"] = "sentry", ["health"] = 120f,
+ ["aimCone"] = 1f, ["ranges"] = ExRanges(120f, 130f, 0f, 120f)
+ },
+ ["juggernaut"] = new JObject
+ {
+ ["names"] = new JArray("Juggernaut"), ["kits"] = new JArray("rnpc_juggernaut"), ["role"] = "roamer",
+ ["movement"] = JObject.FromObject(new { mode = "wander", radius = 30f }), ["health"] = 2500f,
+ ["ranges"] = ExRanges(40f, 50f, 40f, 35f),
+ ["boss"] = JObject.FromObject(new
+ {
+ barDistance = 100f,
+ announce = "chat",
+ spawnLine = "The Juggernaut has risen!",
+ deathLine = "The Juggernaut has fallen.",
+ phases = new object[]
+ {
+ new { at = 0.5f, adds = new { profile = "raider", count = 3 }, line = "The Juggernaut calls for help!" },
+ new { at = 0.25f, damageDealt = 1.5f, speed = 1.3f, line = "The Juggernaut is enraged!" }
+ }
+ })
+ }
+ };
+
+ ///
+ /// D284: on the first load only, the four example kits go into Kits' own data file (Kits has no
+ /// API that creates one, D280) and the four profiles into ours, each only where its name is free.
+ /// The flag is set before anything is written, so this runs once whatever happens. Kits is then
+ /// reloaded, which reloads RunicNPC too (D283); placements wait for it.
+ ///
+ private void WriteExamplesOnce()
+ {
+ StateFile state = ReadData(DataState);
+ if (state.ExamplesWritten)
+ return;
+ state.ExamplesWritten = true;
+ Interface.Oxide.DataFileSystem.WriteObject(DataState, state);
+
+ var addedKits = new List();
+ try
+ {
+ JObject file = Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data") ?? new JObject();
+ JObject kits = file["_kits"] as JObject;
+ if (kits == null)
+ file["_kits"] = kits = new JObject();
+ foreach (KeyValuePair kv in ExampleKits())
+ {
+ if (kits.Properties().Any(x => string.Equals(x.Name, kv.Key, StringComparison.OrdinalIgnoreCase)))
+ continue;
+ foreach (string list in new[] { "MainItems", "WearItems", "BeltItems" })
+ {
+ var known = new JArray();
+ foreach (JToken item in (JArray)kv.Value[list])
+ {
+ if (ItemManager.FindItemDefinition((string)item["Shortname"]) != null)
+ known.Add(item);
+ else
+ PrintWarning($"Example kit {kv.Key}: this Rust has no item '{item["Shortname"]}', so it is left out.");
+ }
+
+ kv.Value[list] = known;
+ }
+
+ kits[kv.Key] = kv.Value;
+ addedKits.Add(kv.Key);
+ }
+
+ if (addedKits.Count > 0)
+ Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file);
+ }
+ catch (Exception e)
+ {
+ PrintError($"The example kits could not be written into Kits' data file: {e.Message}");
+ addedKits.Clear();
+ }
+
+ var addedProfiles = new List();
+ if (!_profileFile.Managed)
+ {
+ foreach (KeyValuePair kv in ExampleProfiles())
+ {
+ if (_profileFile.Profiles.ContainsKey(kv.Key))
+ continue;
+ _profileFile.Profiles[kv.Key] = kv.Value.ToObject();
+ addedProfiles.Add(kv.Key);
+ }
+
+ if (addedProfiles.Count > 0)
+ SaveProfiles();
+ }
+
+ Puts($"First load: example kits written into Kits' data file: {(addedKits.Count > 0 ? string.Join(", ", addedKits.ToArray()) : "none (their names are taken)")}; " +
+ $"example profiles: {(addedProfiles.Count > 0 ? string.Join(", ", addedProfiles.ToArray()) : _profileFile.Managed ? "none (a site manages this server's profiles)" : "none (their names are taken)")}. " +
+ "They are never written again, even if you delete them.");
+
+ if (addedKits.Count == 0)
+ return;
+
+ // Kits reads its file only when it loads (stage 6 spike, finding 5). The reload takes RunicNPC down and
+ // back with it (D283); if this framework does not, OnPluginLoaded("Kits") picks the profiles up.
+ _holdForKits = true;
+ timer.Once(60f, () => _holdForKits = false);
+ string command = Framework() == "carbon" ? "c.reload" : "oxide.reload";
+ timer.Once(1f, () => ConsoleSystem.Run(ConsoleSystem.Option.Server, command, "Kits"));
+ }
+
+ #endregion
+
#region Lifecycle and hooks
private void Init()
@@ -3298,12 +4125,14 @@ namespace Oxide.Plugins
MarkHook("OnServerInitialized");
ResolveFields();
LoadData();
+ WriteExamplesOnce();
RevalidateProfiles();
RebuildPlacements();
TrackWorld();
PatchSentries();
_ready = true;
timer.Every(1f, TickPlacements);
+ timer.Every(0.25f, BarTick);
Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " +
$"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}");
TickPlacements();
@@ -3338,9 +4167,32 @@ namespace Oxide.Plugins
Puts($"{plugin.Name} unloaded: its {n} NPC(s) were removed.");
}
+ ///
+ /// Kits came back (a reload, or the example kits' own, D284): the profiles' kits are checked again
+ /// and held placements go on. Where the framework reloads RunicNPC with Kits (D283), this is moot.
+ ///
+ private void OnPluginLoaded(Plugin plugin)
+ {
+ MarkHook("OnPluginLoaded");
+ if (plugin == null || plugin.Name != "Kits" || !_ready)
+ return;
+ _holdForKits = false;
+ RevalidateProfiles();
+ TickPlacements();
+ }
+
private void Unload()
{
_unloading = true;
+ foreach (BasePlayer p in BasePlayer.activePlayerList)
+ {
+ if (p == null || !p.IsConnected)
+ continue;
+ CuiHelper.DestroyUi(p, BarName);
+ CuiHelper.DestroyUi(p, WindowName);
+ }
+
+ _barShown.Clear();
UnpatchSentries();
Despawn(_npcs.Values);
_npcs.Clear();
@@ -3438,6 +4290,8 @@ namespace Oxide.Plugins
["ally"] = n.AllyClan != 0L ? "clan:" + n.AllyClan : n.AllyTeam != 0UL ? "team:" + n.AllyTeam : n.AllyPlayer != 0UL ? "player:" + n.AllyPlayer : null,
["tether"] = n.Tether,
["shotsMarked"] = n.ShotsMarked,
+ ["role"] = n.Profile?.Role,
+ ["boss"] = n.IsBoss ? new Dictionary { ["phase"] = n.Phase, ["damagers"] = n.Contributors.Count } : null,
["kitUsed"] = new Dictionary { ["heals"] = n.Heals, ["throws"] = n.Throws, ["rockets"] = n.Rockets, ["meleeHits"] = n.MeleeHits }
};
}
@@ -3458,6 +4312,9 @@ namespace Oxide.Plugins
Dictionary profiles;
try
{
+ if (all != null)
+ foreach (JProperty p in all.Properties())
+ NormalizePassive(p.Value as JObject);
profiles = all != null ? all.ToObject>() : new Dictionary();
}
catch (Exception e)
@@ -3513,7 +4370,7 @@ namespace Oxide.Plugins
return error;
Profile profile;
- if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !profile.IsSentry)
+ if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !Still(profile, data.Movement))
{
string off = OffMeshReason(data.Position.V);
if (off != null)
@@ -4170,7 +5027,7 @@ namespace Oxide.Plugins
if (!NavmeshReady())
return "The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly.";
- if (!p.IsSentry)
+ if (!Still(p, data.Movement))
{
string off = OffMeshReason(data.Position.V);
if (off != null)
@@ -4970,6 +5827,20 @@ namespace Oxide.Plugins
return null;
}
+ // Stage 6: the boss box and press E switch on with their defaults and off with none; their own
+ // fields are then set one at a time (boss.spawnLine …, use.mode …), and a list of objects as
+ // JSON: boss.phases [{"at":0.5,"line":"…"}].
+ if ((field == "boss" || field == "use") && (value == "on" || value == "none" || value == "-"))
+ {
+ o[field] = value == "on" ? (o[field] is JObject ? o[field] : JObject.FromObject(field == "boss" ? (object)new BossBlock() : new UseBlock { Lines = new List { "Hello." } })) : null;
+ _profileFile.Profiles[name] = o.ToObject();
+ return null;
+ }
+
+ // D279: a profile made passive starts unhurtable; "hurtByPlayers on" changes that.
+ if (field == "role" && value == "passive" && (string)o["role"] != "passive")
+ o["hurtByPlayers"] = false;
+
JToken target = o.SelectToken(field);
if (target == null || target is JObject)
return target == null
@@ -4978,7 +5849,18 @@ namespace Oxide.Plugins
JToken replacement;
float f;
- if (target is JArray)
+ if (target is JArray && value.TrimStart().StartsWith("["))
+ {
+ try
+ {
+ replacement = JArray.Parse(value);
+ }
+ catch (Exception e)
+ {
+ return $"'{field}' could not be read as JSON: {e.Message}";
+ }
+ }
+ else if (target is JArray)
{
string[] parts = value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray();
if (field == "healthThresholds")
diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs
index 753f264..a7fdb5a 100644
--- a/tools/RunicNpcTest.cs
+++ b/tools/RunicNpcTest.cs
@@ -293,6 +293,7 @@ namespace Oxide.Plugins
if (withEach)
all["t_each"] = Prof(name: "TEach");
Stage5Profiles(all);
+ Stage6Profiles(all);
return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "no answer" };
}
@@ -490,7 +491,7 @@ namespace Oxide.Plugins
string route = SetLoop(field + new Vector3(60f, 0f, 0f));
Check("setup.route", route == null, route ?? "t_loop set");
- string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "place" } : new[] { group };
+ string[] groups = group == "all" ? new[] { "api", "hooks", "move", "sentry", "sleep", "cmd", "api3", "s5", "s6", "place" } : new[] { group };
foreach (string g in groups)
{
IEnumerator inner = g == "api" ? Api(field)
@@ -503,6 +504,7 @@ namespace Oxide.Plugins
: g == "floor" ? Floor(field)
: g == "place" ? Place(field)
: g == "s5" ? Stage5()
+ : g == "s6" ? Stage6()
: null;
if (inner == null)
{
@@ -524,7 +526,7 @@ namespace Oxide.Plugins
private IEnumerator Api(Vector3 field)
{
- Check("api.version", Api("RunicNpc_ApiVersion") == 4, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion"));
+ Check("api.version", Api("RunicNpc_ApiVersion") == 5, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion"));
JObject profiles = Api("RunicNpc_Profiles");
Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]);
Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]);
@@ -1906,6 +1908,250 @@ namespace Oxide.Plugins
Api("RunicNpc_DespawnOwner", Owner);
}
+ // ---- stage 6: bosses, passive NPCs and the examples (D273–D289) ----
+
+ private readonly List _bossEvents = new List();
+ private readonly List _used = new List();
+ private readonly Dictionary _bossContributors = new Dictionary();
+
+ private void OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner) => _bossEvents.Add("spawned:" + profile);
+
+ private void OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at) => _bossEvents.Add($"phase{phase}@{at}");
+
+ private void OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors)
+ {
+ _bossEvents.Add("died:" + profile);
+ _bossContributors.Clear();
+ foreach (KeyValuePair kv in contributors) _bossContributors[kv.Key] = kv.Value;
+ }
+
+ private void OnRunicNpcUsed(BasePlayer npc, string profile, BasePlayer player) => _used.Add(profile + "/" + player.displayName);
+
+ private static void Stage6Profiles(JObject all)
+ {
+ JObject boss = Prof(name: "TBoss");
+ boss["health"] = 1000f;
+ boss["damageTaken"] = JObject.FromObject(new { head = 1f, body = 1f, legs = 1f });
+ boss["boss"] = JObject.Parse(@"{
+ ""barDistance"": 100, ""announce"": ""chat"",
+ ""spawnLine"": ""{name} rises (rnt)"", ""deathLine"": ""{name} fell to {killer} (rnt)"",
+ ""phases"": [
+ { ""at"": 0.75, ""damageDealt"": 2, ""line"": ""phase one (rnt)"" },
+ { ""at"": 0.5, ""adds"": { ""profile"": ""t_roam"", ""count"": 2 }, ""line"": ""adds (rnt)"" },
+ { ""at"": 0.25, ""kit"": ""rnhrevolver"", ""speed"": 1.5, ""aimCone"": 4,
+ ""ranges"": { ""sense"": 50, ""loseTarget"": 60, ""chase"": 50, ""attack"": 40 } }
+ ]}");
+ all["t_boss"] = boss;
+
+ JObject passive = Prof(role: "passive", name: "TPassive");
+ passive["movement"] = JObject.FromObject(new { mode = "stand", radius = 1f });
+ passive["use"] = JObject.FromObject(new { mode = "chat", lines = new[] { "Hello {player} (rnt)" } });
+ all["t_passive"] = passive;
+
+ JObject window = Prof(role: "passive", name: "TWindow");
+ window["use"] = JObject.FromObject(new { mode = "window", title = "Notice", text = "Hello {player} (rnt)" });
+ all["t_passwin"] = window;
+
+ JObject hurt = Prof(role: "passive", name: "THurt");
+ hurt["movement"] = JObject.FromObject(new { mode = "stand", radius = 1f });
+ hurt["use"] = JObject.FromObject(new { mode = "chat", lines = new[] { "Ow (rnt)" } });
+ hurt["hurtByPlayers"] = true;
+ all["t_passhurt"] = hurt;
+ }
+
+ /// A stand-in looks at an NPC's chest, as a player about to press E does (PlayerEyes.HeadRay).
+ private static void Look(BasePlayer p, BasePlayer at)
+ {
+ Vector3 dir = (at.CenterPoint() - p.eyes.position).normalized;
+ p.eyes.rotation = Quaternion.LookRotation(dir);
+ }
+
+ /// E, just pressed, through RunicNPC's own OnPlayerInput.
+ private void PressE(BasePlayer p)
+ {
+ var input = new InputState();
+ input.current.buttons = (int)BUTTON.USE;
+ input.previous.buttons = 0;
+ RunicNPC?.Call("OnPlayerInput", p, input);
+ }
+
+ /// A stand-in shoots an NPC in 40-damage hits until its health is below a fraction (its kit's armour soaks some).
+ private static void HurtTo(BasePlayer npc, BasePlayer by, float fraction)
+ {
+ for (int i = 0; i < 200 && !Gone(npc) && npc.health / npc.MaxHealth() >= fraction; i++)
+ npc.Hurt(new HitInfo(by, npc, Rust.DamageType.Bullet, 40f));
+ }
+
+ private IEnumerator Stage6()
+ {
+ Vector3 f;
+ if (!FindField(300f, 2026, out f))
+ {
+ Check("s6.setup.field", false, "no third open field on the navmesh");
+ yield break;
+ }
+
+ StandIn(f + new Vector3(0, 0, 60), 50);
+
+ // ---- refusals, through a spawn's overrides (ValidateProfile on the effective profile) ----
+ Func refused = (profile, json) => Spawn(f, profile, JObject.Parse(json)) == null;
+ Check("s6.refuse.passiveBoss", refused("t_passive", "{\"boss\":{}}"), "a passive NPC cannot be a boss");
+ Check("s6.refuse.useOnRoamer", refused("t_roam", "{\"use\":{\"mode\":\"chat\",\"lines\":[\"hi\"]}}"), "only a passive NPC answers E");
+ Check("s6.refuse.standRoamer", refused("t_roam", "{\"movement\":{\"mode\":\"stand\",\"radius\":1}}"), "stand is a passive NPC's");
+ Check("s6.refuse.useMode", refused("t_passive", "{\"use\":{\"mode\":\"shout\"}}"), "use.mode chat or window");
+ Check("s6.refuse.chatNoLines", refused("t_passive", "{\"use\":{\"lines\":[]}}"), "a chat answer has lines");
+ Check("s6.refuse.phaseAt", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":1.5}]}}"), "at between 0 and 1");
+ Check("s6.refuse.phaseTwice", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5},{\"at\":0.5}]}}"), "two phases at one fraction");
+ Check("s6.refuse.addsCount", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"adds\":{\"profile\":\"t_roam\",\"count\":0}}]}}"), "adds 1 to 20");
+ Check("s6.refuse.addsBoss", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"adds\":{\"profile\":\"t_boss\",\"count\":1}}]}}"), "a boss does not summon bosses");
+ Check("s6.refuse.phaseKit", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"kit\":\"rnt_nokit\"}]}}"), "a phase's kit must exist");
+ Check("s6.refuse.speed", refused("t_boss", "{\"boss\":{\"phases\":[{\"at\":0.5,\"speed\":9}]}}"), "speed at most 5");
+ Check("s6.refuse.announce", refused("t_boss", "{\"boss\":{\"announce\":\"shout\"}}"), "chat or popup");
+ Check("s6.refuse.barDistance", refused("t_boss", "{\"boss\":{\"barDistance\":0}}"), "above 0");
+ Api("RunicNpc_DespawnOwner", Owner);
+
+ // ---- passive (D278, D279, D288) ----
+ Vector3 a = OnMesh(f + new Vector3(-40, 0, 0));
+ _used.Clear();
+ int hitsBefore = _hitsOnStandIns;
+ BasePlayer pas = Spawn(a, "t_passive");
+ BasePlayer talker = StandIn(OnMesh(a + new Vector3(2f, 0, 0)), 61);
+ yield return new WaitForSeconds(1.5f);
+ Dictionary dp = Describe(pas);
+ Check("s6.passive.spawned", pas != null && dp != null && (string)dp["role"] == "passive", dp != null ? "role " + dp["role"] : "not spawned");
+ if (pas == null) yield break;
+
+ Sense(pas, talker);
+ yield return new WaitForSeconds(6f);
+ Check("s6.passive.neverFights", _hitsOnStandIns == hitsBefore && !Knows(pas, talker),
+ $"told about a player 2 m off: hits {_hitsOnStandIns - hitsBefore}, still knows it as a target: {Knows(pas, talker)}");
+ Check("s6.passive.stands", Flat(pas.transform.position, a) < 1.5f, $"{Flat(pas.transform.position, a):0.0} m from its spot after 7.5 s");
+
+ float hp = pas.health;
+ pas.Hurt(new HitInfo(talker, pas, Rust.DamageType.Bullet, 50f));
+ Check("s6.passive.unhurtByDefault", pas.health >= hp - 0.01f, $"health {hp:0} → {pas.health:0} after a player's 50 (D279: off by default)");
+
+ Look(talker, pas);
+ PressE(talker);
+ Check("s6.passive.pressE", _used.Count == 1 && _used[0] == "t_passive/RntStandIn61", string.Join(", ", _used.ToArray()));
+ PressE(talker);
+ Check("s6.passive.pressOncePerSecond", _used.Count == 1, $"{_used.Count} answer(s) to two presses at once");
+ talker.eyes.rotation = Quaternion.LookRotation(Vector3.back);
+ yield return new WaitForSeconds(1.2f);
+ PressE(talker);
+ Check("s6.passive.lookingAway", _used.Count == 1, $"E while looking away: {_used.Count} answer(s)");
+
+ BasePlayer win = Spawn(OnMesh(a + new Vector3(0, 0, 10f)), "t_passwin");
+ BasePlayer reader = StandIn(OnMesh(a + new Vector3(2f, 0, 10f)), 62);
+ yield return new WaitForSeconds(1f);
+ if (win != null && reader != null)
+ {
+ Look(reader, win);
+ PressE(reader);
+ }
+
+ Check("s6.passive.window", _used.Contains("t_passwin/RntStandIn62"), string.Join(", ", _used.ToArray()));
+ Dictionary dw = Describe(win);
+ Check("s6.passive.wanders", dw != null && (string)dw["movement"] == "wander", dw != null ? "movement " + dw["movement"] : "not spawned");
+
+ BasePlayer ph = Spawn(OnMesh(a + new Vector3(0, 0, -10f)), "t_passhurt");
+ BasePlayer hitter = StandIn(OnMesh(a + new Vector3(2f, 0, -10f)), 63);
+ yield return new WaitForSeconds(1f);
+ if (ph != null && hitter != null)
+ {
+ hitsBefore = _hitsOnStandIns;
+ float before = ph.health;
+ ph.Hurt(new HitInfo(hitter, ph, Rust.DamageType.Bullet, 30f));
+ float after = ph.health;
+ yield return new WaitForSeconds(5f);
+ Check("s6.passive.hurtable", after < before, $"hurtByPlayers on: health {before:0} → {after:0}");
+ Check("s6.passive.neverFightsBack", _hitsOnStandIns == hitsBefore && !Knows(ph, hitter) && TargetOf(Describe(ph)) == "none",
+ $"hit by a player: shots back {_hitsOnStandIns - hitsBefore}, knows it {Knows(ph, hitter)}");
+ }
+
+ Api("RunicNpc_DespawnOwner", Owner);
+ ClearWorld();
+ StandIn(f + new Vector3(0, 0, 60), 50);
+
+ // ---- the boss (D273–D277, D286, D287) ----
+ _bossEvents.Clear();
+ _bossContributors.Clear();
+ Vector3 b = OnMesh(f + new Vector3(40, 0, 0));
+ BasePlayer shooter = StandIn(OnMesh(b + new Vector3(0, 0, 30f)), 64);
+ BasePlayer boss = Spawn(b, "t_boss");
+ yield return new WaitForSeconds(1.5f);
+ Check("s6.boss.spawned", boss != null && _bossEvents.Contains("spawned:t_boss"), string.Join(", ", _bossEvents.ToArray()));
+ if (boss == null) yield break;
+ Dictionary db = Describe(boss);
+ Check("s6.boss.listed", db?["boss"] is Dictionary && Convert.ToInt32(((Dictionary)db["boss"])["phase"]) == 0, db != null ? JsonConvert.SerializeObject(db["boss"]) : "-");
+
+ ScientistNPC sci = boss as ScientistNPC;
+ float baseSpeed = sci?.Brain?.Navigator != null ? sci.Brain.Navigator.Speed : -1f;
+ float scaleBefore = ((NPCPlayer)boss).damageScale;
+
+ // Down to 70%: phase one (damage ×2, a line).
+ HurtTo(boss, shooter, 0.7f);
+ yield return null;
+ Check("s6.boss.phase1", _bossEvents.Contains("phase1@0.75") && Mathf.Approximately(((NPCPlayer)boss).damageScale, 2f),
+ $"health {boss.health:0}/1000, events {string.Join(", ", _bossEvents.ToArray())}, damageScale {scaleBefore} → {((NPCPlayer)boss).damageScale}");
+
+ // Down to 45%: phase two (two adds of t_roam, one per frame, owned as the boss is).
+ int before2 = List(Owner).Count;
+ HurtTo(boss, shooter, 0.45f);
+ for (int i = 0; i < 10; i++) yield return null;
+ var adds = List(Owner).Where(d => (string)d["profile"] == "t_roam").ToList();
+ Check("s6.boss.phase2.adds", _bossEvents.Contains("phase2@0.5") && adds.Count == 2 && adds.All(d => Flat(V(d["position"]), boss.transform.position) < 10f),
+ $"{adds.Count} add(s) owned by {Owner}, {string.Join(", ", adds.Select(d => Flat(V(d["position"]), boss.transform.position).ToString("0.0") + " m").ToArray())} from the boss; listed {before2} → {List(Owner).Count}");
+
+ // Down to 20%: phase three (kit swap, speed ×1.5, aim and ranges). It is first given another
+ // weapon to hold, so the swap back to the kit's revolver can be seen.
+ boss.inventory.Strip();
+ Give(boss, "pistol.semiauto", 1);
+ ((HumanNPC)boss).EquipWeapon();
+ string heldBefore = boss.GetActiveItem()?.info.shortname ?? "none";
+ HurtTo(boss, shooter, 0.2f);
+ yield return null;
+ float speedNow = sci?.Brain?.Navigator != null ? sci.Brain.Navigator.Speed : -1f;
+ string heldAfter = boss.GetActiveItem()?.info.shortname ?? "none";
+ Check("s6.boss.phase3", _bossEvents.Contains("phase3@0.25") && heldAfter == "pistol.revolver" && Mathf.Approximately(speedNow, baseSpeed * 1.5f) &&
+ Mathf.Approximately(((HumanNPC)boss).aimConeScale, 4f),
+ $"held {heldBefore} → {heldAfter}, speed {baseSpeed:0.0} → {speedNow:0.0}, aimConeScale {((HumanNPC)boss).aimConeScale}, senseRange {sci?.Brain?.SenseRange}");
+ Check("s6.boss.phase3.ranges", sci?.Brain != null && Mathf.Approximately(sci.Brain.SenseRange, 50f) && Mathf.Approximately(sci.Brain.TargetLostRange, 60f),
+ $"senseRange {sci?.Brain?.SenseRange}, targetLostRange {sci?.Brain?.TargetLostRange}");
+ db = Describe(boss);
+ Check("s6.boss.listedPhase", db?["boss"] is Dictionary && Convert.ToInt32(((Dictionary)db["boss"])["phase"]) == 3, db != null ? JsonConvert.SerializeObject(db["boss"]) : "-");
+
+ // A phase changes the boss's own copy of its profile, never the shared one.
+ BasePlayer second = Spawn(OnMesh(b + new Vector3(8, 0, 0)), "t_boss");
+ yield return new WaitForSeconds(0.5f);
+ Check("s6.boss.ownProfile", second != null && Mathf.Approximately(((NPCPlayer)second).damageScale, 1f) && Mathf.Approximately(((HumanNPC)second).aimConeScale, 2f),
+ second != null ? $"a second t_boss: damageScale {((NPCPlayer)second).damageScale}, aimConeScale {((HumanNPC)second).aimConeScale}" : "not spawned");
+ if (second != null) Api("RunicNpc_Despawn", Id(second));
+
+ // The kill: the death hook with every player who hurt it; the adds fight on (D287).
+ boss.Hurt(new HitInfo(shooter, boss, Rust.DamageType.Bullet, 5000f));
+ yield return new WaitForSeconds(1f);
+ Check("s6.boss.died", _bossEvents.Contains("died:t_boss") && _bossContributors.ContainsKey(StandInId + 64) && _bossContributors[StandInId + 64] > 990f,
+ $"events {string.Join(", ", _bossEvents.ToArray())}, contributors {JsonConvert.SerializeObject(_bossContributors)}");
+ int addsLeft = List(Owner).Count(d => (string)d["profile"] == "t_roam");
+ Check("s6.boss.addsFightOn", addsLeft == 2, $"{addsLeft} add(s) alive after the boss died (D287)");
+ Check("s6.boss.unlisted", List(Owner).All(d => (string)d["profile"] != "t_boss"), "");
+
+ // ---- the examples (D280, D284, D289) ----
+ Plugin kits = plugins.Find("Kits");
+ string[] exampleKits = { "rnpc_raider", "rnpc_campguard", "rnpc_sniper", "rnpc_juggernaut" };
+ string[] missing = exampleKits.Where(k => !(kits?.Call("IsKit", k) is bool && (bool)kits.Call("IsKit", k))).ToArray();
+ Check("s6.examples.kits", missing.Length == 0, missing.Length == 0 ? "all four in Kits" : "missing: " + string.Join(", ", missing));
+ JObject kitFile = Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data");
+ Check("s6.examples.permission", exampleKits.All(k => (string)kitFile?["_kits"]?[k]?["RequiredPermission"] == "runicnpc.examplekits"),
+ "each needs runicnpc.examplekits, which nobody is granted");
+ JObject state = Interface.Oxide.DataFileSystem.ReadObject("RunicNPC/state");
+ Check("s6.examples.once", state?["examplesWritten"]?.Type == JTokenType.Boolean && (bool)state["examplesWritten"], "state.json: " + state?.ToString(Formatting.None));
+ JObject items = kitFile?["_kits"]?["rnpc_juggernaut"] as JObject;
+ int itemCount = items == null ? 0 : ((JArray)items["MainItems"]).Count + ((JArray)items["WearItems"]).Count + ((JArray)items["BeltItems"]).Count;
+ Check("s6.examples.items", itemCount == 6, $"rnpc_juggernaut carries {itemCount} of its 6 items (a short name this Rust lacks would be left out)");
+ }
+
// ---- stage 6's spike (D282): press E, the health bar, Kits' data file, a kit swap mid-fight, adds ----
private const string SwapKit = "rnt6smg";