From 3972bccf3405fe2c9c139864b32e89bac4369e97 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 5 Oct 2026 10:47:00 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- plugin.toml | 9 +- plugin/RunicNPC.cs | 948 ++++++++++++++++++++++++++++++++++++++++-- tools/RunicNpcTest.cs | 250 ++++++++++- 3 files changed, 1169 insertions(+), 38 deletions(-) diff --git a/plugin.toml b/plugin.toml index 66c490f..40bda9e 100644 --- a/plugin.toml +++ b/plugin.toml @@ -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 ───────────────────────────────────────────────────────── # diff --git a/plugin/RunicNPC.cs b/plugin/RunicNPC.cs index 5670545..c120c3b 100644 --- a/plugin/RunicNPC.cs +++ b/plugin/RunicNPC.cs @@ -11,6 +11,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core; using Oxide.Core.Plugins; +using Oxide.Game.Rust.Cui; using UnityEngine; using UnityEngine.AI; @@ -58,7 +59,7 @@ namespace Oxide.Plugins /// 2 is stage 2's: the whole of PLAN.md §4, documented in docs/runicnpc/API.md. /// /// - private const int ApiVersion = 4; + private const int ApiVersion = 5; /// /// Every hook this plugin implements. rnpc.status reports which have fired, because @@ -74,7 +75,10 @@ namespace Oxide.Plugins "OnEntitySpawned", "OnEntityTakeDamage", "CanBeTargeted", - "CanEntityTakeDamage" + "CanEntityTakeDamage", + "OnPluginLoaded", + "OnPlayerInput", + "OnPlayerDisconnected" }; private readonly Dictionary _hookCounts = new Dictionary(); @@ -230,9 +234,84 @@ namespace Oxide.Plugins /// The kit's extras it uses (D260); all off by default, and each one used is used up (D265). [JsonProperty("kitUse")] public KitUse KitUse = new KitUse(); + // ---- stage 6 (D273–D279) ---- + + /// The boss box (D273): null for an ordinary NPC. Any role but passive may carry it. + [JsonProperty("boss")] public BossBlock Boss; + + /// What pressing E on it does (D278); a passive profile's only, and required there. + [JsonProperty("use")] public UseBlock Use; + [JsonIgnore] public bool IsSentry => Role == "sentry"; [JsonIgnore] public bool IsGuard => Role == "guard"; + + /// D278, D279: never fights, press E to talk; cannot be hurt unless its profile says so. + [JsonIgnore] public bool IsPassive => Role == "passive"; + } + + /// + /// D273–D277: a health bar, phases at health thresholds, and lines said in game. Phases + /// change the NPC's own copy of its profile, never the shared one. + /// + public sealed class BossBlock + { + /// D274: players within this many metres see the bar, and so does everyone who has hurt it. + [JsonProperty("barDistance")] public float BarDistance = 100f; + + /// D277: chat, or popup where PopupNotifications is loaded (chat where it is not). + [JsonProperty("announce")] public string Announce = "chat"; + + /// D286: said to everyone on the server. Blank says nothing. + [JsonProperty("spawnLine")] public string SpawnLine = ""; + + /// D286: said to everyone on the server. {killer} is the killing blow's player. + [JsonProperty("deathLine")] public string DeathLine = ""; + + [JsonProperty("phases")] public List Phases = new List(); + } + + /// D275: what happens when a boss falls to a fraction of its health. Every part is optional. + public sealed class Phase + { + [JsonProperty("at")] public float At; + + /// Replaces the profile's damageDealt from here on. + [JsonProperty("damageDealt", NullValueHandling = NullValueHandling.Ignore)] public float? DamageDealt; + + /// Replaces the profile's aimCone from here on. + [JsonProperty("aimCone", NullValueHandling = NullValueHandling.Ignore)] public float? AimCone; + + /// A multiple of Rust's own speed for the NPC: 1.3 is a third faster. + [JsonProperty("speed", NullValueHandling = NullValueHandling.Ignore)] public float? Speed; + + /// Replaces the profile's ranges from here on. + [JsonProperty("ranges", NullValueHandling = NullValueHandling.Ignore)] public Ranges Ranges; + + /// A Kits kit it swaps to: strip, give, equip (stage 6 spike, finding 3). + [JsonProperty("kit", NullValueHandling = NullValueHandling.Ignore)] public string Kit; + + [JsonProperty("adds", NullValueHandling = NullValueHandling.Ignore)] public Adds Adds; + + /// D286: said only to the players near the boss and those who have hurt it. + [JsonProperty("line", NullValueHandling = NullValueHandling.Ignore)] public string Line; + } + + /// D275, D287: NPCs of another profile placed around the boss. They fight on after it dies. + public sealed class Adds + { + [JsonProperty("profile")] public string Profile; + [JsonProperty("count")] public int Count = 1; + } + + /// D278: a chat line picked at random from lines, or a window with a title, text and Close. + public sealed class UseBlock + { + /// chat or window. + [JsonProperty("mode")] public string Mode = "chat"; + [JsonProperty("lines")] public List Lines = new List(); + [JsonProperty("title")] public string Title = ""; + [JsonProperty("text")] public string Text = ""; } /// D260: the behaviours a kit's extra items allow, each opted into. @@ -354,11 +433,35 @@ namespace Oxide.Plugins private void ReadProfileFile() { - _profileFile = ReadData(DataProfiles); + JObject raw = ReadData(DataProfiles); + try + { + if (raw["profiles"] is JObject) + foreach (JProperty p in ((JObject)raw["profiles"]).Properties()) + NormalizePassive(p.Value as JObject); + _profileFile = raw.ToObject() ?? new ProfileFile(); + } + catch (Exception e) + { + PrintError($"data/{DataProfiles}.json could not be read and is treated as empty: {e.Message}"); + _profileFile = new ProfileFile(); + } + if (_profileFile.Profiles == null) _profileFile.Profiles = new Dictionary(); if (_profileFile.Factions == null) _profileFile.Factions = new List(); } + /// + /// D279: a passive NPC cannot be hurt unless its profile says it can. hurtByPlayers is on + /// by default for every other role, so a passive profile that leaves it out is given "off" as + /// it is read, wherever a profile comes in: the file, a site's push, or rnpc.profile. + /// + private static void NormalizePassive(JObject profile) + { + if (profile != null && (string)profile["role"] == "passive" && profile["hurtByPlayers"] == null) + profile["hurtByPlayers"] = false; + } + private void SaveProfiles() => Interface.Oxide.DataFileSystem.WriteObject(DataProfiles, _profileFile); private void SaveRoutes() => Interface.Oxide.DataFileSystem.WriteObject(DataRoutes, _routeFile); @@ -411,10 +514,12 @@ namespace Oxide.Plugins return $"kits: this server has no kit '{kit}'"; if (ResolvePrefab(p.Prefab) == null) return $"prefab: '{p.Prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)"; - if (p.Role != "roamer" && p.Role != "sentry" && p.Role != "guard") - return $"role: '{p.Role}' is not roamer, sentry or guard"; + if (p.Role != "roamer" && p.Role != "sentry" && p.Role != "guard" && p.Role != "passive") + return $"role: '{p.Role}' is not roamer, sentry, guard or passive"; string movement = ValidateMovement(p.Movement); if (movement != null) return movement; + if (p.Movement.Mode == "stand" && !p.IsPassive) + return "movement.mode: stand is a passive NPC's; a fighting NPC that stands is a sentry"; if (!(p.Health > 0f)) return "health: must be above 0"; if (p.DamageDealt < 0f) return "damageDealt: must not be negative"; if (p.DamageTaken == null || p.DamageTaken.Head < 0f || p.DamageTaken.Body < 0f || p.DamageTaken.Legs < 0f) @@ -442,6 +547,83 @@ namespace Oxide.Plugins if (p.Turrets != "default" && p.Turrets != "ignore" && p.Turrets != "always") return $"turrets: '{p.Turrets}' is not default, ignore or always"; if (p.KitUse == null) return "kitUse: an object, even an empty one"; + + // Stage 6 (D273–D279). + if (p.IsPassive) + { + if (p.Boss != null) return "boss: a passive NPC never fights, so it cannot be a boss"; + if (p.Use == null) return "use: a passive NPC says what pressing E on it does (D278)"; + } + + if (p.Use != null && !p.IsPassive) + return "use: only a passive NPC answers E (D278)"; + if (p.Use != null) + { + string use = ValidateUse(p.Use); + if (use != null) return use; + } + + if (p.Boss != null) + { + string boss = ValidateBoss(p.Boss); + if (boss != null) return boss; + } + + return null; + } + + private const int MaxLine = 256; + private const int MaxWindowText = 2000; + private const int MaxAdds = 20; + + private static string ValidateUse(UseBlock u) + { + if (u.Mode != "chat" && u.Mode != "window") + return $"use.mode: '{u.Mode}' is not chat or window"; + if (u.Mode == "chat" && (u.Lines == null || u.Lines.Count == 0 || u.Lines.Any(string.IsNullOrWhiteSpace))) + return "use.lines: a chat answer gives at least one line, and no blank ones"; + if (u.Lines != null && u.Lines.Any(l => l != null && l.Length > MaxLine)) + return $"use.lines: each line is at most {MaxLine} characters"; + if (u.Mode == "window" && string.IsNullOrWhiteSpace(u.Title) && string.IsNullOrWhiteSpace(u.Text)) + return "use: a window has a title or some text"; + if ((u.Title ?? "").Length > 80) return "use.title: at most 80 characters"; + if ((u.Text ?? "").Length > MaxWindowText) return $"use.text: at most {MaxWindowText} characters"; + return null; + } + + private string ValidateBoss(BossBlock b) + { + if (!(b.BarDistance > 0f && b.BarDistance <= 1000f)) return "boss.barDistance: above 0 and at most 1000 metres"; + if (b.Announce != "chat" && b.Announce != "popup") return $"boss.announce: '{b.Announce}' is not chat or popup"; + if ((b.SpawnLine ?? "").Length > MaxLine || (b.DeathLine ?? "").Length > MaxLine) + return $"boss: a line is at most {MaxLine} characters"; + if (b.Phases == null) return "boss.phases: a list, even an empty one"; + var seen = new HashSet(); + for (int i = 0; i < b.Phases.Count; i++) + { + Phase ph = b.Phases[i]; + string at = $"boss.phases[{i}]"; + if (ph == null) return $"{at}: it is empty"; + if (!(ph.At > 0f && ph.At < 1f)) return $"{at}.at: a fraction of its health between 0 and 1"; + if (!seen.Add(ph.At)) return $"{at}.at: two phases at {ph.At}"; + if (ph.DamageDealt.HasValue && ph.DamageDealt.Value < 0f) return $"{at}.damageDealt: must not be negative"; + if (ph.AimCone.HasValue && ph.AimCone.Value < 0f) return $"{at}.aimCone: must not be negative"; + if (ph.Speed.HasValue && !(ph.Speed.Value > 0f && ph.Speed.Value <= 5f)) return $"{at}.speed: above 0 and at most 5 (times Rust's own)"; + if (ph.Ranges != null && (!(ph.Ranges.Sense > 0f) || !(ph.Ranges.Attack > 0f) || ph.Ranges.LoseTarget < ph.Ranges.Sense || ph.Ranges.Chase < 0f)) + return $"{at}.ranges: sense and attack above 0, loseTarget at least sense, chase not negative"; + if (ph.Kit != null && !KitExists(ph.Kit)) return $"{at}.kit: this server has no kit '{ph.Kit}'"; + if (ph.Adds != null) + { + if (ph.Adds.Profile == null || !NameRule.IsMatch(ph.Adds.Profile)) return $"{at}.adds.profile: a profile name"; + if (ph.Adds.Count < 1 || ph.Adds.Count > MaxAdds) return $"{at}.adds.count: 1 to {MaxAdds}"; + Profile add; + if (_profileFile.Profiles.TryGetValue(ph.Adds.Profile, out add) && add != null && add.Boss != null) + return $"{at}.adds.profile: '{ph.Adds.Profile}' is a boss, and a boss does not summon bosses"; + } + + if ((ph.Line ?? "").Length > MaxLine) return $"{at}.line: at most {MaxLine} characters"; + } + return null; } @@ -500,17 +682,21 @@ namespace Oxide.Plugins if (m == null) return "movement: missing"; string kind, route; if (!ParseMode(m.Mode, out kind, out route)) - return $"movement.mode: '{m.Mode}' is not wander, monument or route:"; + return $"movement.mode: '{m.Mode}' is not wander, monument, route: or (passive only) stand"; if (kind == "wander" && !(m.Radius > 0f)) return "movement.radius: a wanderer's radius must be above 0"; return null; } + /// A sentry, or a passive NPC whose movement is stand (D288): it holds its spot, on the navmesh or off it. + private static bool Still(Profile p, Movement placementMovement) => + p.IsSentry || (p.IsPassive && (placementMovement ?? p.Movement)?.Mode == "stand"); + private static bool ParseMode(string mode, out string kind, out string route) { kind = null; route = null; - if (mode == "wander" || mode == "monument") + if (mode == "wander" || mode == "monument" || mode == "stand") { kind = mode; return true; @@ -613,7 +799,7 @@ namespace Oxide.Plugins #region The swap (D223, D232) // - // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 2573b149-ba5d-458a-9b83-7f5f32c76afc. + // Generated by tools/fieldlist from Rust's Assembly-CSharp.dll, module 5c6eb1c7-ffa2-4b1e-a218-0a66bcc56f9e. // Do not edit by hand: regenerate after a Rust update (D232). private static readonly string[] SwapNpcFields = { @@ -636,24 +822,14 @@ namespace Oxide.Plugins "NPCPlayer.damageScale", "NPCPlayer.shortRange", "NPCPlayer.attackLengthMaxShortRangeScale", + "BasePlayer.debugCommandedNpcs", "BasePlayer.GestureViewModel", - "BasePlayer.currentTeam", - "BasePlayer.PetEntity", "BasePlayer.lastPlayerVisibility", "BasePlayer.eyeHistory", "BasePlayer.tickHistory", "BasePlayer.fallDamageEffect", "BasePlayer.drownEffect", - "BasePlayer.playerFlags", "BasePlayer.Belt", - "BasePlayer.weaponMoveSpeedScale", - "BasePlayer.clothingBlocksAiming", - "BasePlayer.clothingMoveSpeedReduction", - "BasePlayer.clothingWaterSpeedBonus", - "BasePlayer.clothingAccuracyBonus", - "BasePlayer.equippingBlocked", - "BasePlayer.eggVision", - "BasePlayer.designingAIEntity", "BaseCombatEntity.spawnDeployableCorpseOnDeath", "BaseCombatEntity.deployableCorpsePrefab", "BaseCombatEntity.skeletonProperties", @@ -668,6 +844,7 @@ namespace Oxide.Plugins "BaseCombatEntity.sendsMeleeHitNotification", "BaseCombatEntity.markAttackerHostile", "BaseCombatEntity.faction", + "BaseEntity.faces", "BaseEntity.bounds", "BaseEntity.impactEffect", "BaseEntity.enableSaving", @@ -803,7 +980,7 @@ namespace Oxide.Plugins /// swapped for ours. Everything the brain reads once, as it starts, is set here (stage 1, /// Q3: Senses.Init copies the sense values a single time). /// - private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, out string error) + private RunicNpcPlayer CreateNpc(Profile p, Vector3 at, float yaw, bool still, out string error) { error = null; BaseEntity made = GameManager.server.CreateEntity(ResolvePrefab(p.Prefab), at, Quaternion.Euler(0f, yaw, 0f), false); @@ -835,7 +1012,8 @@ namespace Oxide.Plugins // Our own sleep replaces an AI zone's (D235): a zone must never put ours to sleep. brain.AllowedToSleep = false; - brain.Sentry = p.IsSentry; + // A sentry, and a passive NPC that stands (D288), hold their spot and turn. + brain.Sentry = still; return npc; } @@ -893,6 +1071,21 @@ namespace Oxide.Plugins public new float EngagementRange() => Profile != null ? Profile.Ranges.Attack : base.EngagementRange(); + /// + /// D279: a passive NPC never fights. Rust's design picks its target from every player it has + /// sensed (HumanNPC.GetBestTarget reads Senses.Players, which IsTarget + /// does not filter) and shoots through AttackTick; both come through IAIAttack, which + /// this class re-implements, so a passive one has no target and no attack. + /// + public new BaseEntity GetBestTarget() => Profile != null && Profile.IsPassive ? null : base.GetBestTarget(); + + public new void AttackTick(float delta, BaseEntity target, bool targetIsLOS) + { + if (Profile != null && Profile.IsPassive) + return; + base.AttackTick(delta, target, targetIsLOS); + } + // ---- stage 5: whom it fights, beside Rust's own design (D231, D254–D272) ---- /// @@ -933,7 +1126,23 @@ namespace Oxide.Plugins /// escorting (D257, D269). IAISenses is re-implemented so Rust's senses ask here. /// public new bool IsTarget(BaseEntity entity) => - base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity)); + (Profile == null || !Profile.IsPassive) && base.IsTarget(entity) && (_self == null || !_self.Spares(this, entity)); + + // ---- stage 6: the boss (D273–D277) ---- + + /// The phases it has entered, by their at; each is entered once. + public readonly HashSet PhasesFired = new HashSet(); + + /// The phase it is in: 0 before the first. + public int Phase; + + /// Rust's own navigator speed at spawn, which a phase's speed multiplies. + public float BaseSpeed = -1f; + + /// Its bar needs a redraw: set on every hit, cleared by the next redraw (at most four a second). + public bool BarDirty; + + public bool IsBoss => Profile != null && Profile.Boss != null; public new bool IsThreat(BaseEntity entity) => IsTarget(entity); @@ -1022,7 +1231,20 @@ namespace Oxide.Plugins Credit(attackerId, _healthBefore - health); if (Profile != null && !IsDead()) { + // D279: Rust's own Hurt teaches the NPC its attacker; a passive one forgets at once. + if (Profile.IsPassive && Brain?.Senses?.Memory != null) + { + Brain.Senses.Memory.Targets.Clear(); + Brain.Senses.Memory.Threats.Clear(); + } + _self?.CheckThresholds(this); + if (IsBoss) + { + BarDirty = true; + _self?.CheckPhases(this, attackerId); + } + // An unkillable NPC (D271) takes nothing and still answers the attack. if (info != null && info.Initiator != null) { @@ -1902,7 +2124,7 @@ namespace Oxide.Plugins private void NoteDefender(RunicNpcPlayer npc) { - if (npc.HasAlly || npc.Escort != null) + if ((npc.HasAlly || npc.Escort != null) && !npc.Profile.IsPassive) _defenders.Add(npc); else _defenders.Remove(npc); @@ -1933,6 +2155,8 @@ namespace Oxide.Plugins /// private void Fight(RunicNpcPlayer npc, RunicNpcBrain brain, float delta) { + if (npc.Profile.IsPassive) + return; float now = Time.time; HashSet hunts = HuntsOf(npc); BaseCombatEntity t = npc.NpcTarget; @@ -2110,6 +2334,9 @@ namespace Oxide.Plugins /// Called from the NPC's own Hurt, after Rust's. private void NpcHurt(RunicNpcPlayer npc, HitInfo info) { + // D279: a passive NPC never fights back, and raises no alarm. + if (npc.Profile.IsPassive) + return; BaseEntity source = info.Initiator; BaseCombatEntity attacker = TurretOf(source) ?? TurretOf(info.Weapon != null ? info.Weapon.GetParentEntity() : null) ?? source as BaseCombatEntity; if (attacker == null || attacker == npc || attacker.IsDestroyed || attacker.IsDead()) @@ -2158,7 +2385,8 @@ namespace Oxide.Plugins /// Tells one of ours about an attacker: Rust's memory for a player, our own target for anything else. private void Learn(RunicNpcPlayer npc, BaseCombatEntity attacker, bool real) { - if (Spares(npc, attacker)) + // D279: a passive NPC never fights, so an ally's alarm does not reach it. + if (npc.Profile.IsPassive || Spares(npc, attacker)) return; npc.RnBrain?.Rouse(); if (real) @@ -2899,14 +3127,21 @@ namespace Oxide.Plugins Movement movement = movementOverride ?? p.Movement; string kind, route; ParseMode(movement.Mode, out kind, out route); - if (!p.IsSentry && kind == "route" && !_routeFile.Routes.ContainsKey(route)) + if (kind == "stand" && !p.IsPassive) + { + error = "movement stand is a passive NPC's; a fighting NPC that stands is a sentry"; + return null; + } + + bool still = p.IsSentry || kind == "stand"; + if (!still && kind == "route" && !_routeFile.Routes.ContainsKey(route)) { error = $"route '{route}' does not exist"; return null; } Vector3 position = at; - if (!p.IsSentry) + if (!still) { // Rust's sampler reaches further down than across: without the height check, a spot // on a roof finds the ground below it (stage 2). @@ -2926,7 +3161,11 @@ namespace Oxide.Plugins return null; } - RunicNpcPlayer npc = CreateNpc(p, position, yaw, out error); + // A boss's phases change its stats, so it gets its own copy of the profile (D275). + if (p.Boss != null) + p = Effective(p, null); + + RunicNpcPlayer npc = CreateNpc(p, position, yaw, still, out error); if (npc == null) return null; @@ -2960,6 +3199,8 @@ namespace Oxide.Plugins _npcs[npc.NetId] = npc; Index(npc, true); Interface.CallHook("OnRunicNpcSpawned", npc, profileName, owner); + if (p.Boss != null) + BossSpawned(npc); return npc; } @@ -2969,6 +3210,8 @@ namespace Oxide.Plugins if (!_npcs.Remove(npc.NetId)) return; Index(npc, false); + if (npc.IsBoss) + ClearBar(npc); if (npc.PlacementId != null) { @@ -3008,6 +3251,8 @@ namespace Oxide.Plugins var contributors = new Dictionary(npc.Contributors); Unregister(npc); Interface.CallHook("OnRunicNpcDied", npc, npc.ProfileName, npc.Owner, info, contributors); + if (npc.IsBoss) + BossDied(npc, info, contributors); } private void NoteRegrounded(RunicNpcPlayer npc, Vector3 from, Vector3 to) @@ -3089,7 +3334,7 @@ namespace Oxide.Plugins { if (_placementsDirty) SavePlacements(); - if (!_ready || _unloading) + if (!_ready || _unloading || _holdForKits) return; float now = Time.realtimeSinceStartup; @@ -3219,13 +3464,13 @@ namespace Oxide.Plugins // A scattered spot that leaves a small floor takes the placement's own spot instead. Vector2 offset = UnityEngine.Random.insideUnitCircle * Scatter; at = spot + new Vector3(offset.x, 0f, offset.y); - if (!p.IsSentry && OffMeshReason(at) != null) + if (!Still(p, data.Movement) && OffMeshReason(at) != null) at = spot; } // D239: the spot was on the navmesh when it was placed. If the world has changed under it (a // player-built floor destroyed), the NPC stands on the nearest navmesh until the spot returns. - string off = p.IsSentry ? null : OffMeshReason(at); + string off = Still(p, data.Movement) ? null : OffMeshReason(at); NavMeshHit nearest; if (off != null && SampleNavmesh(at, Fallback, out nearest)) { @@ -3283,6 +3528,588 @@ namespace Oxide.Plugins #endregion + #region Bosses (stage 6: D273–D277, D286, D287) + + [PluginReference] private Plugin PopupNotifications; + + private const string BarName = "rnpc.bar"; + private const string BarFill = "rnpc.bar.fill"; + private const string BarText = "rnpc.bar.text"; + + /// How far from the boss its adds are placed (D275), before each point is put on the navmesh. + private const float AddRing = 6f; + + /// The bosses alive now. + private readonly HashSet _bosses = new HashSet(); + + /// Whose bar each player sees, by Steam id: one boss at a time, the nearest that qualifies (D274). + private readonly Dictionary _barShown = new Dictionary(); + + private int _barTick; + + private void BossSpawned(RunicNpcPlayer npc) + { + _bosses.Add(npc); + BossBlock b = npc.Profile.Boss; + if (!string.IsNullOrWhiteSpace(b.SpawnLine)) + Say(b, BossText(b.SpawnLine, npc, null), null); + Interface.CallHook("OnRunicNpcBossSpawned", npc, npc.ProfileName, npc.Owner); + } + + private void BossDied(RunicNpcPlayer npc, HitInfo info, Dictionary contributors) + { + BossBlock b = npc.Profile.Boss; + BasePlayer killer = info != null ? info.InitiatorPlayer : null; + if (killer != null && killer.IsNpc) + killer = null; + if (!string.IsNullOrWhiteSpace(b.DeathLine)) + Say(b, BossText(b.DeathLine, npc, killer), null); + Interface.CallHook("OnRunicNpcBossDied", npc, npc.ProfileName, npc.Owner, info, contributors); + } + + /// A line's placeholders: {name} is the boss's name, {killer} the killing blow's player. + private static string BossText(string line, RunicNpcPlayer npc, BasePlayer killer) => + line.Replace("{name}", npc.RnName).Replace("{killer}", killer != null ? killer.displayName : ""); + + /// + /// D277, D286: a line said in game, to everyone ( null) or to the players + /// given. A popup where the profile asks for one and PopupNotifications is loaded; chat otherwise. + /// + private void Say(BossBlock b, string text, IEnumerable to) + { + bool popup = b.Announce == "popup" && PopupNotifications != null && PopupNotifications.IsLoaded; + if (to == null) + { + if (popup) + PopupNotifications.Call("CreatePopupNotification", text, null, 0f); + else + PrintToChat(text); + return; + } + + foreach (BasePlayer p in to) + { + if (p == null || !p.IsConnected) + continue; + if (popup) + PopupNotifications.Call("CreatePopupNotification", text, p, 0f); + else + p.ChatMessage(text); + } + } + + /// D286: the players a phase line reaches: within the bar's distance, or who have hurt it. + private static List Near(RunicNpcPlayer npc) + { + float r = npc.Profile.Boss.BarDistance; + Vector3 at = npc.transform.position; + return BasePlayer.activePlayerList + .Where(p => p != null && !p.IsNpc && ((p.transform.position - at).sqrMagnitude <= r * r || npc.Contributors.ContainsKey((ulong)p.userID))) + .ToList(); + } + + /// Called from the boss's Hurt: enters every phase its health has fallen to, highest first. + private void CheckPhases(RunicNpcPlayer npc, ulong attackerId) + { + float fraction = npc.health / npc.MaxHealth(); + foreach (Phase ph in npc.Profile.Boss.Phases.OrderByDescending(x => x.At)) + if (fraction <= ph.At && npc.PhasesFired.Add(ph.At)) + EnterPhase(npc, ph); + } + + /// D275: a phase changes the boss's own copy of its profile (Spawn made it), never the shared one. + private void EnterPhase(RunicNpcPlayer npc, Phase ph) + { + npc.Phase = npc.PhasesFired.Count; + Profile p = npc.Profile; + RunicNpcBrain brain = npc.RnBrain; + + if (ph.DamageDealt.HasValue) + { + p.DamageDealt = ph.DamageDealt.Value; + npc.damageScale = p.DamageDealt; + } + + if (ph.AimCone.HasValue) + { + p.AimCone = ph.AimCone.Value; + npc.aimConeScale = p.AimCone; + } + + if (ph.Ranges != null) + { + p.Ranges = new Ranges { Sense = ph.Ranges.Sense, LoseTarget = ph.Ranges.LoseTarget, Chase = ph.Ranges.Chase, Attack = ph.Ranges.Attack }; + if (brain != null) + { + brain.SenseRange = p.Ranges.Sense; + brain.TargetLostRange = p.Ranges.LoseTarget; + } + } + + // Rust sets the agent's speed from the navigator's on every move, so this holds. + if (ph.Speed.HasValue && brain?.Navigator != null) + { + if (npc.BaseSpeed < 0f) + npc.BaseSpeed = brain.Navigator.Speed; + brain.Navigator.Speed = npc.BaseSpeed * ph.Speed.Value; + } + + // The spike (finding 3): strip, give, equip. Without EquipWeapon it holds nothing. + if (ph.Kit != null) + { + npc.inventory.Strip(); + object given = Kits?.Call("GiveKit", npc, ph.Kit); + if (given is string) + PrintWarning($"Kits did not give '{ph.Kit}' to {npc.RnName} ({npc.ProfileName}) in phase {npc.Phase}: {given}"); + else + npc.Kit = ph.Kit; + npc.EquipWeapon(); + } + + if (ph.Adds != null) + SummonAdds(npc, ph.Adds); + + if (!string.IsNullOrWhiteSpace(ph.Line)) + Say(p.Boss, BossText(ph.Line, npc, null), Near(npc)); + + Interface.CallHook("OnRunicNpcBossPhase", npc, npc.ProfileName, npc.Owner, npc.Phase, ph.At); + } + + /// + /// D275, D287: adds of another profile on a ring around the boss, one per server frame (the + /// spike measured 4–8 ms each). They belong to the boss's owner, so an event's teardown removes + /// them, and they fight on after the boss dies. Each learns the boss's attackers nearby. + /// + private void SummonAdds(RunicNpcPlayer boss, Adds adds) + { + Profile p; + if (!_profiles.TryGetValue(adds.Profile, out p)) + { + PrintWarning($"{boss.RnName} ({boss.ProfileName}) cannot summon '{adds.Profile}': " + + (_refused.ContainsKey(adds.Profile) ? "it is refused: " + _refused[adds.Profile] : "there is no such profile")); + return; + } + + Vector3 centre = boss.transform.position; + string owner = boss.Owner; + var attackers = boss.Contributors.Keys.ToList(); + float turn = UnityEngine.Random.value * Mathf.PI * 2f; + int i = 0; + Action next = null; + next = () => + { + if (_unloading || i >= adds.Count) + return; + float a = turn + i * Mathf.PI * 2f / adds.Count; + Vector3 ring = centre + new Vector3(Mathf.Cos(a), 0f, Mathf.Sin(a)) * AddRing; + string error; + NavMeshHit hit; + RunicNpcPlayer add = null; + if (SampleNavmesh(ring, 3f, out hit)) + add = Spawn(adds.Profile, p, hit.position, UnityEngine.Random.Range(0f, 360f), owner, null, null, -1, out error); + else + error = "no navmesh within 3 m of its spot"; + if (add == null) + PrintWarning($"{boss.RnName} ({boss.ProfileName}): add {i + 1} of {adds.Count} ('{adds.Profile}') not placed: {error}"); + else + foreach (ulong id in attackers) + { + BasePlayer who = BasePlayer.FindByID(id); + if (who != null && who.IsAlive() && Vector3.Distance(who.transform.position, add.transform.position) <= 100f) + Learn(add, who, true); + } + + i++; + if (i < adds.Count) + NextFrame(next); + }; + next(); + } + + // ---- the bar (D274): the stage 6 spike's shape (finding 2) ---- + + /// + /// Four times a second: redraw each hurt boss's bar in place for its viewers. Once a second: + /// work out who sees which bar, adding and removing whole bars. + /// + private void BarTick() + { + if (_bosses.Count == 0 && _barShown.Count == 0) + return; + if (++_barTick % 4 == 0) + SweepViewers(); + foreach (RunicNpcPlayer boss in _bosses) + { + if (!boss.BarDirty || boss.IsDestroyed) + continue; + boss.BarDirty = false; + string json = null; + foreach (KeyValuePair kv in _barShown) + { + if (kv.Value != boss) + continue; + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p == null || !p.IsConnected) + continue; + if (json == null) + json = BarJson(boss, true); + CuiHelper.AddUi(p, json); + } + } + } + + private void SweepViewers() + { + var want = new Dictionary(); + foreach (BasePlayer p in BasePlayer.activePlayerList) + { + if (p == null || p.IsNpc || !p.IsConnected) + continue; + ulong id = (ulong)p.userID; + RunicNpcPlayer best = null; + float bestD = float.MaxValue; + foreach (RunicNpcPlayer b in _bosses) + { + if (b == null || b.IsDestroyed || b.IsDead()) + continue; + float d = (b.transform.position - p.transform.position).sqrMagnitude; + float r = b.Profile.Boss.BarDistance; + if ((d <= r * r || b.Contributors.ContainsKey(id)) && d < bestD) + { + best = b; + bestD = d; + } + } + + if (best != null) + want[id] = best; + } + + foreach (KeyValuePair kv in _barShown.ToList()) + { + RunicNpcPlayer now; + if (want.TryGetValue(kv.Key, out now) && now == kv.Value) + continue; + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p != null && p.IsConnected) + CuiHelper.DestroyUi(p, BarName); + _barShown.Remove(kv.Key); + } + + foreach (KeyValuePair kv in want) + { + if (_barShown.ContainsKey(kv.Key)) + continue; + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p == null) + continue; + CuiHelper.AddUi(p, BarJson(kv.Value, false)); + _barShown[kv.Key] = kv.Value; + } + } + + /// A boss gone (death, despawn, unload): its bar comes off every screen showing it. + private void ClearBar(RunicNpcPlayer npc) + { + _bosses.Remove(npc); + foreach (KeyValuePair kv in _barShown.Where(x => x.Value == npc).ToList()) + { + BasePlayer p = BasePlayer.FindByID(kv.Key); + if (p != null && p.IsConnected) + CuiHelper.DestroyUi(p, BarName); + _barShown.Remove(kv.Key); + } + } + + /// + /// The bar: a dark strip at the top of the screen, a red fill and the boss's name and health. + /// A redraw sends only the fill and the text, changed in place with CUI's update. + /// + private static string BarJson(RunicNpcPlayer boss, bool update) + { + float fraction = Mathf.Clamp01(boss.health / boss.MaxHealth()); + string text = $"{boss.RnName} {Mathf.CeilToInt(boss.health)} / {Mathf.CeilToInt(boss.MaxHealth())}"; + var c = new CuiElementContainer(); + if (!update) + c.Add(new CuiPanel { Image = { Color = "0.1 0.1 0.1 0.75" }, RectTransform = { AnchorMin = "0.35 0.905", AnchorMax = "0.65 0.935" } }, "Hud", BarName); + c.Add(new CuiPanel { Image = { Color = "0.72 0.12 0.1 0.9" }, RectTransform = { AnchorMin = "0 0", AnchorMax = $"{fraction.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)} 1" } }, BarName, BarFill); + c.Add(new CuiLabel { Text = { Text = text, FontSize = 13, Align = TextAnchor.MiddleCenter, Color = "1 1 1 0.95" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, BarName, BarText); + if (update) + foreach (CuiElement e in c) + e.Update = true; + return CuiHelper.ToJson(c); + } + + #endregion + + #region Passive NPCs and press E (stage 6: D278, D279, D288) + + private const string WindowName = "rnpc.window"; + + /// How far a player's E reaches: Rust's own use distance is about this. + private const float UseReach = 3f; + + private static int _useMask; + + private readonly Dictionary _nextUse = new Dictionary(); + + /// + /// Neither framework raises a hook for E on an NPC, and Rust ignores it (stage 6 spike, finding + /// 1): so every player tick is checked for E pressed, at about 7 ns, and only then is a ray cast. + /// + private void OnPlayerInput(BasePlayer player, InputState input) + { + MarkHook("OnPlayerInput"); + if (input == null || !input.WasJustPressed(BUTTON.USE) || player == null || player.IsNpc) + return; + if (_useMask == 0) + _useMask = LayerMask.GetMask("Player (Server)", "Construction", "Deployed", "World", "Terrain", "Default", "Tree"); + + RaycastHit hit; + if (!Physics.Raycast(player.eyes.HeadRay(), out hit, UseReach, _useMask, QueryTriggerInteraction.Ignore)) + return; + RunicNpcPlayer npc = hit.GetEntity() as RunicNpcPlayer; + if (npc?.Profile == null || !npc.Profile.IsPassive || npc.Profile.Use == null || npc.IsDead()) + return; + + ulong id = (ulong)player.userID; + float now = Time.realtimeSinceStartup, next; + if (_nextUse.TryGetValue(id, out next) && now < next) + return; + _nextUse[id] = now + 1f; + Use(npc, player); + } + + /// D278: a chat line picked at random, or a window. {player} in either is the player's name. + private void Use(RunicNpcPlayer npc, BasePlayer player) + { + UseBlock u = npc.Profile.Use; + if (u.Mode == "chat") + { + string line = u.Lines[UnityEngine.Random.Range(0, u.Lines.Count)].Replace("{player}", player.displayName); + player.ChatMessage($"{npc.RnName}: {line}"); + } + else + OpenWindow(player, npc, u); + + // One that stands turns to whoever spoke to it. + if (npc.RnBrain != null && npc.RnBrain.Sentry) + npc.SetAimDirection((player.eyes.position - npc.eyes.position).normalized); + + Interface.CallHook("OnRunicNpcUsed", npc, npc.ProfileName, player); + } + + private static void OpenWindow(BasePlayer player, RunicNpcPlayer npc, UseBlock u) + { + CuiHelper.DestroyUi(player, WindowName); + string title = string.IsNullOrWhiteSpace(u.Title) ? npc.RnName : u.Title; + var c = new CuiElementContainer(); + c.Add(new CuiPanel + { + Image = { Color = "0.08 0.08 0.08 0.92" }, + RectTransform = { AnchorMin = "0.32 0.3", AnchorMax = "0.68 0.72" }, + CursorEnabled = true + }, "Overlay", WindowName); + c.Add(new CuiLabel + { + Text = { Text = title.Replace("{player}", player.displayName), FontSize = 18, Align = TextAnchor.MiddleLeft, Color = "0.91 0.76 0.44 1" }, + RectTransform = { AnchorMin = "0.05 0.85", AnchorMax = "0.95 0.96" } + }, WindowName); + c.Add(new CuiLabel + { + Text = { Text = (u.Text ?? "").Replace("{player}", player.displayName), FontSize = 14, Align = TextAnchor.UpperLeft, Color = "0.92 0.92 0.92 1" }, + RectTransform = { AnchorMin = "0.05 0.17", AnchorMax = "0.95 0.82" } + }, WindowName); + c.Add(new CuiButton + { + Button = { Close = WindowName, Color = "0.3 0.3 0.3 0.9" }, + Text = { Text = "Close", FontSize = 14, Align = TextAnchor.MiddleCenter }, + RectTransform = { AnchorMin = "0.4 0.03", AnchorMax = "0.6 0.12" } + }, WindowName); + CuiHelper.AddUi(player, c); + } + + private void OnPlayerDisconnected(BasePlayer player, string reason) + { + MarkHook("OnPlayerDisconnected"); + if (player == null) + return; + ulong id = (ulong)player.userID; + _barShown.Remove(id); + _nextUse.Remove(id); + } + + #endregion + + #region The example profiles (stage 6: D280, D284, D289) + + private const string DataState = "RunicNPC/state"; + + /// The permission every example kit needs, which nobody is granted: no player can claim one by name (D284). + private const string ExamplePermission = "runicnpc.examplekits"; + + private sealed class StateFile + { + /// D284: set on the first load that wrote them, and never cleared. + [JsonProperty("examplesWritten")] public bool ExamplesWritten; + } + + /// Placements wait while Kits reloads with the example kits (D283 reloads RunicNPC with it). + private bool _holdForKits; + + private static JObject ExItem(string shortname, int amount, int position, float condition = 0f) => + JObject.FromObject(new { Shortname = shortname, Skin = 0UL, Amount = amount, Condition = condition, MaxCondition = condition, Ammo = 0, Position = position, Frequency = -1 }); + + private static JObject ExKit(string name, JObject[] main, JObject[] wear, JObject[] belt) => new JObject + { + ["Name"] = name, ["Description"] = "A RunicNPC example kit, for its example NPCs", ["RequiredPermission"] = ExamplePermission, + ["MaximumUses"] = 0, ["RequiredAuth"] = 0, ["Cooldown"] = 0, ["Cost"] = 0, ["IsHidden"] = true, + ["CopyPasteFile"] = "", ["KitImage"] = "", + ["MainItems"] = new JArray(main), ["WearItems"] = new JArray(wear), ["BeltItems"] = new JArray(belt) + }; + + private static JObject[] Wear(params string[] items) => items.Select((s, i) => ExItem(s, 1, i)).ToArray(); + + /// D289: the four kits. A short name this Rust does not know is left out with a warning. + private static Dictionary ExampleKits() => new Dictionary + { + ["rnpc_raider"] = ExKit("rnpc_raider", new[] { ExItem("ammo.rifle", 120, 0) }, + Wear("hoodie", "pants", "shoes.boots", "mask.bandana"), new[] { ExItem("rifle.semiauto", 1, 0, 100f) }), + ["rnpc_campguard"] = ExKit("rnpc_campguard", new[] { ExItem("ammo.pistol", 200, 0) }, + Wear("metal.facemask", "metal.plate.torso", "hoodie", "pants", "shoes.boots"), new[] { ExItem("smg.thompson", 1, 0, 100f) }), + ["rnpc_sniper"] = ExKit("rnpc_sniper", new[] { ExItem("ammo.rifle", 60, 0) }, + Wear("hat.boonie", "hoodie", "pants", "shoes.boots"), new[] { ExItem("rifle.bolt", 1, 0, 100f) }), + ["rnpc_juggernaut"] = ExKit("rnpc_juggernaut", new[] { ExItem("ammo.rifle", 300, 0) }, + Wear("heavy.plate.helmet", "heavy.plate.jacket", "heavy.plate.pants", "shoes.boots"), new[] { ExItem("lmg.m249", 1, 0, 100f) }) + }; + + private static JObject ExRanges(float sense, float lose, float chase, float attack) => + JObject.FromObject(new { sense, loseTarget = lose, chase, attack }); + + /// D289: the four profiles. + private static Dictionary ExampleProfiles() => new Dictionary + { + ["raider"] = new JObject + { + ["names"] = new JArray("Raider"), ["kits"] = new JArray("rnpc_raider"), ["role"] = "roamer", + ["movement"] = JObject.FromObject(new { mode = "wander", radius = 40f }), ["health"] = 150f, + ["ranges"] = ExRanges(30f, 40f, 40f, 30f) + }, + ["campguard"] = new JObject + { + ["names"] = new JArray("Camp Guard"), ["kits"] = new JArray("rnpc_campguard"), ["role"] = "guard", + ["movement"] = JObject.FromObject(new { mode = "wander", radius = 10f }), ["health"] = 200f, + ["ranges"] = ExRanges(35f, 45f, 30f, 30f) + }, + ["sniper"] = new JObject + { + ["names"] = new JArray("Sniper"), ["kits"] = new JArray("rnpc_sniper"), ["role"] = "sentry", ["health"] = 120f, + ["aimCone"] = 1f, ["ranges"] = ExRanges(120f, 130f, 0f, 120f) + }, + ["juggernaut"] = new JObject + { + ["names"] = new JArray("Juggernaut"), ["kits"] = new JArray("rnpc_juggernaut"), ["role"] = "roamer", + ["movement"] = JObject.FromObject(new { mode = "wander", radius = 30f }), ["health"] = 2500f, + ["ranges"] = ExRanges(40f, 50f, 40f, 35f), + ["boss"] = JObject.FromObject(new + { + barDistance = 100f, + announce = "chat", + spawnLine = "The Juggernaut has risen!", + deathLine = "The Juggernaut has fallen.", + phases = new object[] + { + new { at = 0.5f, adds = new { profile = "raider", count = 3 }, line = "The Juggernaut calls for help!" }, + new { at = 0.25f, damageDealt = 1.5f, speed = 1.3f, line = "The Juggernaut is enraged!" } + } + }) + } + }; + + /// + /// D284: on the first load only, the four example kits go into Kits' own data file (Kits has no + /// API that creates one, D280) and the four profiles into ours, each only where its name is free. + /// The flag is set before anything is written, so this runs once whatever happens. Kits is then + /// reloaded, which reloads RunicNPC too (D283); placements wait for it. + /// + private void WriteExamplesOnce() + { + StateFile state = ReadData(DataState); + if (state.ExamplesWritten) + return; + state.ExamplesWritten = true; + Interface.Oxide.DataFileSystem.WriteObject(DataState, state); + + var addedKits = new List(); + try + { + JObject file = Interface.Oxide.DataFileSystem.ReadObject("Kits/kits_data") ?? new JObject(); + JObject kits = file["_kits"] as JObject; + if (kits == null) + file["_kits"] = kits = new JObject(); + foreach (KeyValuePair kv in ExampleKits()) + { + if (kits.Properties().Any(x => string.Equals(x.Name, kv.Key, StringComparison.OrdinalIgnoreCase))) + continue; + foreach (string list in new[] { "MainItems", "WearItems", "BeltItems" }) + { + var known = new JArray(); + foreach (JToken item in (JArray)kv.Value[list]) + { + if (ItemManager.FindItemDefinition((string)item["Shortname"]) != null) + known.Add(item); + else + PrintWarning($"Example kit {kv.Key}: this Rust has no item '{item["Shortname"]}', so it is left out."); + } + + kv.Value[list] = known; + } + + kits[kv.Key] = kv.Value; + addedKits.Add(kv.Key); + } + + if (addedKits.Count > 0) + Interface.Oxide.DataFileSystem.WriteObject("Kits/kits_data", file); + } + catch (Exception e) + { + PrintError($"The example kits could not be written into Kits' data file: {e.Message}"); + addedKits.Clear(); + } + + var addedProfiles = new List(); + if (!_profileFile.Managed) + { + foreach (KeyValuePair kv in ExampleProfiles()) + { + if (_profileFile.Profiles.ContainsKey(kv.Key)) + continue; + _profileFile.Profiles[kv.Key] = kv.Value.ToObject(); + addedProfiles.Add(kv.Key); + } + + if (addedProfiles.Count > 0) + SaveProfiles(); + } + + Puts($"First load: example kits written into Kits' data file: {(addedKits.Count > 0 ? string.Join(", ", addedKits.ToArray()) : "none (their names are taken)")}; " + + $"example profiles: {(addedProfiles.Count > 0 ? string.Join(", ", addedProfiles.ToArray()) : _profileFile.Managed ? "none (a site manages this server's profiles)" : "none (their names are taken)")}. " + + "They are never written again, even if you delete them."); + + if (addedKits.Count == 0) + return; + + // Kits reads its file only when it loads (stage 6 spike, finding 5). The reload takes RunicNPC down and + // back with it (D283); if this framework does not, OnPluginLoaded("Kits") picks the profiles up. + _holdForKits = true; + timer.Once(60f, () => _holdForKits = false); + string command = Framework() == "carbon" ? "c.reload" : "oxide.reload"; + timer.Once(1f, () => ConsoleSystem.Run(ConsoleSystem.Option.Server, command, "Kits")); + } + + #endregion + #region Lifecycle and hooks private void Init() @@ -3298,12 +4125,14 @@ namespace Oxide.Plugins MarkHook("OnServerInitialized"); ResolveFields(); LoadData(); + WriteExamplesOnce(); RevalidateProfiles(); RebuildPlacements(); TrackWorld(); PatchSentries(); _ready = true; timer.Every(1f, TickPlacements); + timer.Every(0.25f, BarTick); Puts($"RunicNPC {Version} loaded, API {ApiVersion}: {_profiles.Count} profile(s), {_placements.Count} placement(s), " + $"{_routeFile.Routes.Count} route(s). {CostWarning(PlannedTotal())}"); TickPlacements(); @@ -3338,9 +4167,32 @@ namespace Oxide.Plugins Puts($"{plugin.Name} unloaded: its {n} NPC(s) were removed."); } + /// + /// Kits came back (a reload, or the example kits' own, D284): the profiles' kits are checked again + /// and held placements go on. Where the framework reloads RunicNPC with Kits (D283), this is moot. + /// + private void OnPluginLoaded(Plugin plugin) + { + MarkHook("OnPluginLoaded"); + if (plugin == null || plugin.Name != "Kits" || !_ready) + return; + _holdForKits = false; + RevalidateProfiles(); + TickPlacements(); + } + private void Unload() { _unloading = true; + foreach (BasePlayer p in BasePlayer.activePlayerList) + { + if (p == null || !p.IsConnected) + continue; + CuiHelper.DestroyUi(p, BarName); + CuiHelper.DestroyUi(p, WindowName); + } + + _barShown.Clear(); UnpatchSentries(); Despawn(_npcs.Values); _npcs.Clear(); @@ -3438,6 +4290,8 @@ namespace Oxide.Plugins ["ally"] = n.AllyClan != 0L ? "clan:" + n.AllyClan : n.AllyTeam != 0UL ? "team:" + n.AllyTeam : n.AllyPlayer != 0UL ? "player:" + n.AllyPlayer : null, ["tether"] = n.Tether, ["shotsMarked"] = n.ShotsMarked, + ["role"] = n.Profile?.Role, + ["boss"] = n.IsBoss ? new Dictionary { ["phase"] = n.Phase, ["damagers"] = n.Contributors.Count } : null, ["kitUsed"] = new Dictionary { ["heals"] = n.Heals, ["throws"] = n.Throws, ["rockets"] = n.Rockets, ["meleeHits"] = n.MeleeHits } }; } @@ -3458,6 +4312,9 @@ namespace Oxide.Plugins Dictionary profiles; try { + if (all != null) + foreach (JProperty p in all.Properties()) + NormalizePassive(p.Value as JObject); profiles = all != null ? all.ToObject>() : new Dictionary(); } catch (Exception e) @@ -3513,7 +4370,7 @@ namespace Oxide.Plugins return error; Profile profile; - if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !profile.IsSentry) + if (NavmeshReady() && _profiles.TryGetValue(data.Profile, out profile) && !Still(profile, data.Movement)) { string off = OffMeshReason(data.Position.V); if (off != null) @@ -4170,7 +5027,7 @@ namespace Oxide.Plugins if (!NavmeshReady()) return "The map's navmesh is still being built (minutes, on a map's first boot). Try again shortly."; - if (!p.IsSentry) + if (!Still(p, data.Movement)) { string off = OffMeshReason(data.Position.V); if (off != null) @@ -4970,6 +5827,20 @@ namespace Oxide.Plugins return null; } + // Stage 6: the boss box and press E switch on with their defaults and off with none; their own + // fields are then set one at a time (boss.spawnLine …, use.mode …), and a list of objects as + // JSON: boss.phases [{"at":0.5,"line":"…"}]. + if ((field == "boss" || field == "use") && (value == "on" || value == "none" || value == "-")) + { + o[field] = value == "on" ? (o[field] is JObject ? o[field] : JObject.FromObject(field == "boss" ? (object)new BossBlock() : new UseBlock { Lines = new List { "Hello." } })) : null; + _profileFile.Profiles[name] = o.ToObject(); + return null; + } + + // D279: a profile made passive starts unhurtable; "hurtByPlayers on" changes that. + if (field == "role" && value == "passive" && (string)o["role"] != "passive") + o["hurtByPlayers"] = false; + JToken target = o.SelectToken(field); if (target == null || target is JObject) return target == null @@ -4978,7 +5849,18 @@ namespace Oxide.Plugins JToken replacement; float f; - if (target is JArray) + if (target is JArray && value.TrimStart().StartsWith("[")) + { + try + { + replacement = JArray.Parse(value); + } + catch (Exception e) + { + return $"'{field}' could not be read as JSON: {e.Message}"; + } + } + else if (target is JArray) { string[] parts = value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToArray(); if (field == "healthThresholds") diff --git a/tools/RunicNpcTest.cs b/tools/RunicNpcTest.cs index 753f264..a7fdb5a 100644 --- a/tools/RunicNpcTest.cs +++ b/tools/RunicNpcTest.cs @@ -293,6 +293,7 @@ namespace Oxide.Plugins if (withEach) all["t_each"] = Prof(name: "TEach"); Stage5Profiles(all); + Stage6Profiles(all); return Api>("RunicNpc_SetProfiles", all) ?? new Dictionary { ["?"] = "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("RunicNpc_ApiVersion") == 4, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); + Check("api.version", Api("RunicNpc_ApiVersion") == 5, "RunicNpc_ApiVersion=" + Api("RunicNpc_ApiVersion")); JObject profiles = Api("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("RunicNpc_DespawnOwner", Owner); } + // ---- stage 6: bosses, passive NPCs and the examples (D273–D289) ---- + + private readonly List _bossEvents = new List(); + private readonly List _used = new List(); + private readonly Dictionary _bossContributors = new Dictionary(); + + 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 contributors) + { + _bossEvents.Add("died:" + profile); + _bossContributors.Clear(); + foreach (KeyValuePair 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; + } + + /// A stand-in looks at an NPC's chest, as a player about to press E does (PlayerEyes.HeadRay). + private static void Look(BasePlayer p, BasePlayer at) + { + Vector3 dir = (at.CenterPoint() - p.eyes.position).normalized; + p.eyes.rotation = Quaternion.LookRotation(dir); + } + + /// E, just pressed, through RunicNPC's own OnPlayerInput. + private void PressE(BasePlayer p) + { + var input = new InputState(); + input.current.buttons = (int)BUTTON.USE; + input.previous.buttons = 0; + RunicNPC?.Call("OnPlayerInput", p, input); + } + + /// A stand-in shoots an NPC in 40-damage hits until its health is below a fraction (its kit's armour soaks some). + 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 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("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 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 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("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 db = Describe(boss); + Check("s6.boss.listed", db?["boss"] is Dictionary && Convert.ToInt32(((Dictionary)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 && Convert.ToInt32(((Dictionary)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("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("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("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";