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
This commit is contained in:
2026-10-05 10:47:00 -05:00
parent 5274da1e69
commit 3972bccf34
3 changed files with 1169 additions and 38 deletions

View File

@@ -26,9 +26,12 @@
# 2 was stage 2's whole API, PLAN.md §4. # 2 was stage 2's whole API, PLAN.md §4.
# 3 was stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the # 3 was stage 4 (D249): RunicNpc_AddPlacement, _RenamePlacement, _RespawnPlacement and the
# OnRunicNpcPlacementChanged hook. # OnRunicNpcPlacementChanged hook.
# Current: 4 — stage 5: RunicNpc_Factions, _SetFactions, _Escort, _Ally, _Tether, the profile's stage 5 # 4 was 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. # fields, a placement's tether, and the OnRunicNpcEscortEnded hook.
api = 4 # 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 ───────────────────────────────────────────────────────── # ── Framework floors ─────────────────────────────────────────────────────────
# #

File diff suppressed because it is too large Load Diff

View File

@@ -293,6 +293,7 @@ namespace Oxide.Plugins
if (withEach) if (withEach)
all["t_each"] = Prof(name: "TEach"); all["t_each"] = Prof(name: "TEach");
Stage5Profiles(all); Stage5Profiles(all);
Stage6Profiles(all);
return Api<Dictionary<string, string>>("RunicNpc_SetProfiles", all) ?? new Dictionary<string, string> { ["?"] = "no answer" }; return Api<Dictionary<string, string>>("RunicNpc_SetProfiles", all) ?? new Dictionary<string, string> { ["?"] = "no answer" };
} }
@@ -490,7 +491,7 @@ namespace Oxide.Plugins
string route = SetLoop(field + new Vector3(60f, 0f, 0f)); string route = SetLoop(field + new Vector3(60f, 0f, 0f));
Check("setup.route", route == null, route ?? "t_loop set"); 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) foreach (string g in groups)
{ {
IEnumerator inner = g == "api" ? Api(field) IEnumerator inner = g == "api" ? Api(field)
@@ -503,6 +504,7 @@ namespace Oxide.Plugins
: g == "floor" ? Floor(field) : g == "floor" ? Floor(field)
: g == "place" ? Place(field) : g == "place" ? Place(field)
: g == "s5" ? Stage5() : g == "s5" ? Stage5()
: g == "s6" ? Stage6()
: null; : null;
if (inner == null) if (inner == null)
{ {
@@ -524,7 +526,7 @@ namespace Oxide.Plugins
private IEnumerator Api(Vector3 field) 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"); JObject profiles = Api<JObject>("RunicNpc_Profiles");
Check("api.profiles.managed", profiles != null && (bool)profiles["managed"], "managed=" + profiles?["managed"]); 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"]); Check("api.profiles.refused", profiles != null && profiles["refused"]?["t_bad"] != null, "" + profiles?["refused"]);
@@ -1906,6 +1908,250 @@ namespace Oxide.Plugins
Api<int>("RunicNpc_DespawnOwner", Owner); 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).
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<JObject>("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<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 ---- // ---- stage 6's spike (D282): press E, the health bar, Kits' data file, a kit swap mid-fight, adds ----
private const string SwapKit = "rnt6smg"; private const string SwapKit = "rnt6smg";