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";