4 Commits

Author SHA1 Message Date
3be2c54260 fix: an event's teardown removes a boss's adds (D287)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m33s
The walk found an event run's boss adds outliving the run: teardown
removes what a step placed by net id, and the adds RunicNPC spawned for
a boss's phase are in no step's ledger. RunicNPC now keeps each boss's
live adds by the boss's net id, after the boss dies too, and
RunicNpc_Despawn(<boss net id>) removes them with it, which is the call
the bridge's teardown makes. An add leaves the set when it dies.

Walked on the Oxide rig: event 36, run 65 placed the boss, its phase
summoned two adds owned by run:65, the boss was killed, the event
advanced on rust.boss.killed, and the run's cleanup removed both adds.
New harness check s6.boss.despawnTakesAdds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 13:06:19 -05:00
cf7f9cd38d test(harness): stage 6 group, and two checks fixed after the Rust update
The s6 group (41 checks): profile refusals, the passive role (never
fights, unhurtable by default, press E through OnPlayerInput once a
second, a window, wandering), the boss (three phases: damage, adds,
kit swap, speed, aim and ranges; its own copy of the profile; the death
hook's contributors; adds that fight on) and the example kits.

After Rust 2634.289.1 (buildid 25681086) rebuilt the navmesh,
sentry.shoots's first spot had a rock between the sentry and its
stand-in: it now uses a clear line, as the stage 5 tests do (28 hits).
move.monument.roams: the military tunnel's own 30 scientists no longer
move with no real player near, so ours standing too is the same
behaviour, and the check says both.

Two probes for that: rnt.stockprobe and rnt.monprobe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 12:48:09 -05:00
1e927f6a66 fix: the example kits' permission is kits.runicnpc (stage 6)
Kits registers a kit's RequiredPermission, and Oxide warns on every Kits
load that a permission a plugin registers must start with that plugin's
name: "Missing plugin name prefix 'kits' for permission
'runicnpc.examplekits'". The org lead chose Kits' prefix. Nobody is
granted it, so no player can claim an example kit by name; RunicNPC's
GiveKit call skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 12:14:51 -05:00
3972bccf34 feat: API 5, bosses, passive NPCs and the example profiles (stage 6)
The boss box on any role but passive (D273): a health bar for players
within its distance and everyone who hurt it, redrawn in place at most
four times a second (D274); phases at health fractions that change its
own copy of the profile (damage, aim, speed, ranges), swap its kit,
summon adds one per frame and say a line (D275); spawn and death lines
to everyone, phase lines to players near it (D277, D286); adds fight on
after it dies (D287). Hooks OnRunicNpcBossSpawned, _BossPhase and
_BossDied.

The passive role (D278, D279, D288): never fights (GetBestTarget and
AttackTick refuse, since Rust's design picks targets from every sensed
player), cannot be hurt unless its profile says so, moves as its profile
says including the new `stand`, and answers E through OnPlayerInput
with a chat line or a window. Hook OnRunicNpcUsed.

The four example profiles and their kits, written into Kits' data file
on the first load only (D280, D284, D289), each kit needing a
permission nobody holds. Kits' return re-checks the profiles.

The swap's field list regenerated for Rust 2634.289.1: two new fields,
eleven now [NonSerialized].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 10:47:00 -05: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";