feat: API 5, bosses, passive NPCs and the example profiles (stage 6) #10
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user