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 1169 additions and 38 deletions
Showing only changes of commit 3972bccf34 - Show all commits

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" };
}
@@ -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<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"]);
@@ -1906,6 +1908,250 @@ 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).
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 ----
private const string SwapKit = "rnt6smg";