feat: API 5, bosses, passive NPCs and the example profiles (stage 6) #10

Merged
whitlocktech merged 4 commits from feat/stage-6 into edge 2026-10-06 00:45:58 +00:00
3 changed files with 1299 additions and 42 deletions

View File

@@ -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 ─────────────────────────────────────────────────────────
#

File diff suppressed because it is too large Load Diff

View File

@@ -293,6 +293,7 @@ namespace Oxide.Plugins
if (withEach)
all["t_each"] = Prof(name: "TEach");
Stage5Profiles(all);
Stage6Profiles(all);
return Api<Dictionary<string, string>>("RunicNpc_SetProfiles", all) ?? new Dictionary<string, string> { ["?"] = "no answer" };
}
@@ -392,6 +393,74 @@ namespace Oxide.Plugins
arg.ReplyWith("rnt: kit probe started");
}
/// <summary>
/// A probe after the 2026-10-05 Rust update: a stock scientist and one of ours (t_sentry), each with a
/// stand-in 10 m off, every 2 s for 20 s; and how far the map's own scientists move in that time.
/// </summary>
[ConsoleCommand("rnt.stockprobe")]
private void CmdStockProbe(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
Vector3 f;
if (!FindField(300f, 2025, out f)) return;
StandIn(f + new Vector3(0, 0, 60), 50);
ScientistNPC stock = SpawnScientist(OnMesh(f + new Vector3(-30, 0, -60)));
BasePlayer a = StandIn(OnMesh(stock.transform.position + new Vector3(10, 0, 0)), 71);
BasePlayer ours = Spawn(OnMesh(f + new Vector3(30, 0, -60)), "t_sentry");
BasePlayer b = ours != null ? StandIn(OnMesh(ours.transform.position + new Vector3(10, 0, 0)), 72) : null;
List<ScientistNPC> world = BaseNetworkable.serverEntities.OfType<ScientistNPC>().Where(s => s.GetType().Name != "RunicNpcPlayer" && s != stock).Take(20).ToList();
Dictionary<ScientistNPC, Vector3> from = world.ToDictionary(s => s, s => s.transform.position);
_hitsOnStandIns = 0;
int n = 0;
timer.Repeat(2f, 10, () =>
{
n++;
string Line(ScientistNPC s, BasePlayer t)
{
if (Gone(s)) return "gone";
var senses = s.Brain?.Senses;
return $"state={s.Brain?.CurrentState?.StateType} players={senses?.Memory?.Players.Count} targets={senses?.Memory?.Targets.Count} knows={Knows(s, t)} best={((IAIAttack)s).GetBestTarget()?.ShortPrefabName ?? "none"}";
}
Puts($"[stock {n}] stock: {Line(stock, a)} | ours: {Line(ours as ScientistNPC, b)} | hits on stand-ins {_hitsOnStandIns}");
if (n == 10)
{
var moved = world.Where(s => !Gone(s)).Select(s => Vector3.Distance(s.transform.position, from[s])).ToList();
Puts($"[stock] the map's own scientists: {moved.Count}, moved >1 m in 20 s: {moved.Count(m => m > 1f)}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0} m; ai.think={ConVar.AI.think} ai.move={ConVar.AI.move} ai.ignoreplayers={ConVar.AI.ignoreplayers}");
Api<int>("RunicNpc_DespawnOwner", Owner);
ClearWorld();
}
});
arg.ReplyWith("rnt: stock probe started");
}
/// <summary>A probe: our monument roamer at the test's monument, and that monument's own scientists, every 4 s for 40 s.</summary>
[ConsoleCommand("rnt.monprobe")]
private void CmdMonProbe(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin) return;
Vector3 at;
string name;
if (!FindMonument(out at, out name)) { arg.ReplyWith("no monument"); return; }
StandIn(at + new Vector3(0, 0, 100), 2);
BasePlayer mon = Spawn(at, "t_mon");
AIInformationZone zone = AIInformationZone.GetForPoint(at, false);
List<ScientistNPC> locals = BaseNetworkable.serverEntities.OfType<ScientistNPC>()
.Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, at) < 150f).ToList();
Dictionary<ScientistNPC, Vector3> from = locals.ToDictionary(s => s, s => s.transform.position);
Vector3 monFrom = mon != null ? mon.transform.position : Vector3.zero;
Puts($"[mon] {name} at {at}: zone {(zone != null ? zone.name : "none")} movePoints={(zone != null ? zone.paths?.Count ?? -1 : -1)}, ours {(mon != null ? "spawned" : "REFUSED")}, its own scientists within 150 m: {locals.Count}");
int n = 0;
timer.Repeat(4f, 10, () =>
{
n++;
ScientistNPC s = mon as ScientistNPC;
var moved = locals.Where(x => !Gone(x)).Select(x => Vector3.Distance(x.transform.position, from[x])).ToList();
Puts($"[mon {n}] ours: state={s?.Brain?.CurrentState?.StateType} moved={(Gone(mon) ? -1 : Vector3.Distance(mon.transform.position, monFrom)):0.0} dest={(s?.Brain?.Navigator != null ? s.Brain.Navigator.Destination.ToString() : "-")} moving={s?.Brain?.Navigator?.Moving} | locals moved >1 m: {moved.Count(m => m > 1f)}/{moved.Count}, furthest {(moved.Count > 0 ? moved.Max() : 0):0.0}");
if (n == 10) { Api<int>("RunicNpc_DespawnOwner", Owner); ClearWorld(); }
});
arg.ReplyWith("rnt: monument probe started");
}
/// <summary>Every live RunicNPC NPC of an owner (or all), as RunicNpc_List describes it: for walks.</summary>
[ConsoleCommand("rnt.list")]
private void CmdList(ConsoleSystem.Arg arg)
@@ -490,7 +559,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 +572,7 @@ namespace Oxide.Plugins
: g == "floor" ? Floor(field)
: g == "place" ? Place(field)
: g == "s5" ? Stage5()
: g == "s6" ? Stage6()
: null;
if (inner == null)
{
@@ -524,7 +594,7 @@ namespace Oxide.Plugins
private IEnumerator Api(Vector3 field)
{
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 4, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
Check("api.version", Api<int>("RunicNpc_ApiVersion") == 5, "RunicNpc_ApiVersion=" + Api<int>("RunicNpc_ApiVersion"));
JObject profiles = Api<JObject>("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"]);
@@ -613,10 +683,16 @@ namespace Oxide.Plugins
Vector3 monument;
string monumentName;
BasePlayer mon = null;
List<ScientistNPC> locals = new List<ScientistNPC>();
var localsFrom = new Dictionary<ScientistNPC, Vector3>();
if (FindMonument(out monument, out monumentName))
{
StandIn(monument + new Vector3(0, 0, 100), 2);
mon = Spawn(monument, "t_mon");
// The monument's own scientists: a monument roamer is meant to roam as they do.
locals = BaseNetworkable.serverEntities.OfType<ScientistNPC>()
.Where(s => s.GetType().Name != "RunicNpcPlayer" && Vector3.Distance(s.transform.position, monument) < 150f).ToList();
foreach (ScientistNPC s in locals) localsFrom[s] = s.transform.position;
}
Vector3 home = wander.transform.position;
@@ -656,7 +732,13 @@ namespace Oxide.Plugins
if (order[i] != (order[i - 1] + 1) % loop.points.Count) inOrder = false;
Check("move.route.inOrder", order.Count >= 3 && inOrder, string.Join(">", order.Select(x => x.ToString()).ToArray()));
if (mon != null)
Check("move.monument.roams", monMax > 5f, $"{monumentName}: furthest {monMax:0.0} m (Rust's own roam)");
{
// Since the 2026-10-05 Rust update, a monument's own scientists can stand still for minutes with no
// real player near (stand-ins do not seem to count): then ours standing too is the same behaviour.
int localsMoved = locals.Count(s => !Gone(s) && Vector3.Distance(s.transform.position, localsFrom[s]) > 5f);
Check("move.monument.roams", monMax > 5f || (locals.Count > 0 && localsMoved == 0),
$"{monumentName}: ours furthest {monMax:0.0} m (Rust's own roam); the monument's own scientists moved >5 m: {localsMoved}/{locals.Count}");
}
else
Check("move.monument.roams", false, "no monument with an AI zone");
@@ -687,11 +769,20 @@ namespace Oxide.Plugins
private IEnumerator Sentry(Vector3 field)
{
BasePlayer sentry = Spawn(field, "t_sentry");
// On a clear line (no rock between): after the 2026-10-05 Rust update's navmesh rebuild the field's
// first spot had one, and the sentry never saw its stand-in. A probe 10 m apart in the open hit 13 times.
Vector3 sentryAt, standInAt;
if (!ClearLine(field, 12f, out sentryAt, out standInAt))
{
sentryAt = field;
standInAt = field + new Vector3(12, 0, 0);
}
BasePlayer sentry = Spawn(sentryAt, "t_sentry");
yield return new WaitForSeconds(1f);
Vector3 spot = sentry.transform.position;
_hitsOnStandIns = 0;
StandIn(spot + new Vector3(12, 0, 0), 4);
StandIn(standInAt, 4);
float moved = 0f;
for (int t = 0; t < 40; t++)
{
@@ -1906,6 +1997,255 @@ namespace Oxide.Plugins
Api<int>("RunicNpc_DespawnOwner", Owner);
}
// ---- stage 6: bosses, passive NPCs and the examples (D273–D289) ----
private readonly List<string> _bossEvents = new List<string>();
private readonly List<string> _used = new List<string>();
private readonly Dictionary<ulong, float> _bossContributors = new Dictionary<ulong, float>();
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<ulong, float> contributors)
{
_bossEvents.Add("died:" + profile);
_bossContributors.Clear();
foreach (KeyValuePair<ulong, float> 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;
}
/// <summary>A stand-in looks at an NPC's chest, as a player about to press E does (PlayerEyes.HeadRay).</summary>
private static void Look(BasePlayer p, BasePlayer at)
{
Vector3 dir = (at.CenterPoint() - p.eyes.position).normalized;
p.eyes.rotation = Quaternion.LookRotation(dir);
}
/// <summary>E, just pressed, through RunicNPC's own OnPlayerInput.</summary>
private void PressE(BasePlayer p)
{
var input = new InputState();
input.current.buttons = (int)BUTTON.USE;
input.previous.buttons = 0;
RunicNPC?.Call("OnPlayerInput", p, input);
}
/// <summary>A stand-in shoots an NPC in 40-damage hits until its health is below a fraction (its kit's armour soaks some).</summary>
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<string, string, bool> 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<int>("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<string, object> 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<string, object> 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<int>("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<string, object> db = Describe(boss);
Check("s6.boss.listed", db?["boss"] is Dictionary<string, object> && Convert.ToInt32(((Dictionary<string, object>)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<string, object> && Convert.ToInt32(((Dictionary<string, object>)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<int>("RunicNpc_Despawn", Id(second));
// The kill: the death hook with every player who hurt it; the adds fight on (D287).
ulong bossId = Id(boss);
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)");
// An event's teardown despawns what its step placed, by net id: the dead boss's id takes its adds with it.
int removed = Api<int>("RunicNpc_Despawn", bossId);
int addsAfter = List(Owner).Count(d => (string)d["profile"] == "t_roam");
Check("s6.boss.despawnTakesAdds", removed == 2 && addsAfter == 0, $"RunicNpc_Despawn(the dead boss's id) removed {removed}; adds left {addsAfter}");
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<JObject>("Kits/kits_data");
Check("s6.examples.permission", exampleKits.All(k => (string)kitFile?["_kits"]?[k]?["RequiredPermission"] == "kits.runicnpc"),
"each needs kits.runicnpc, which nobody is granted");
JObject state = Interface.Oxide.DataFileSystem.ReadObject<JObject>("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";