// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.Offline; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.PlayerActions.Skills; using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.Views.World; /// /// Handles buff application and management for the offline player. /// public sealed class BuffHandler { private const int BuffSlotCount = 3; private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new(); private readonly OfflinePlayer _player; private readonly IMuHelperSettings? _config; private int _nextSlotIndex; private bool _buffTimerTriggered; private DateTime? _nextPeriodicBuffTime; private IList? _cachedAutoBuffIds; private int _cachedAutoBuffSkillCount = -1; /// /// Initializes a new instance of the class. /// /// The offline player. /// The MU Helper configuration. public BuffHandler(OfflinePlayer player, IMuHelperSettings? config) { this._player = player; this._config = config; } /// /// Gets the configured buff skill IDs from the settings. With /// enabled (server-side bots), the character's learned buff skills are used instead of the explicitly /// configured slots, so each class keeps its own buffs up without any per-character configuration. /// public IList ConfiguredBuffIds { get { if (this._config is null) { return []; } if (this._config.AutoSelectBuffs && this._player.SkillList is { } skillList) { // The learned buffs only change when a new skill is learned, so the list is cached and // only rebuilt when the skill count changes - building it fresh with LINQ on every // 500ms tick of hundreds of bots was measurable CPU for no benefit. var skillCount = skillList.Skills.Count(); if (this._cachedAutoBuffIds is null || skillCount != this._cachedAutoBuffSkillCount) { var learnedBuffs = skillList.Skills .Where(s => s.Skill is { SkillType: SkillType.Buff, MagicEffectDef: not null }) .Select(s => (int)s.Skill!.Number) .OrderBy(n => n) .Take(BuffSlotCount) .ToList(); // Pad to the fixed slot count - the caller indexes all three slots; 0 means "slot empty". while (learnedBuffs.Count < BuffSlotCount) { learnedBuffs.Add(0); } this._cachedAutoBuffIds = learnedBuffs; this._cachedAutoBuffSkillCount = skillCount; } return this._cachedAutoBuffIds; } return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id]; } } /// /// Checks and applies buffs if configured and needed. /// /// True, if the loop can continue to the next step; False, if a buff was cast and the tick should end. public async ValueTask PerformBuffsAsync() { if (this._config is null) { return true; } var buffIds = this.ConfiguredBuffIds; if (buffIds.Count == 0) { return true; } this.UpdatePeriodicBuffTimer(); for (int i = 0; i < BuffSlotCount; i++) { var slotIndex = (this._nextSlotIndex + i) % BuffSlotCount; int buffId = buffIds[slotIndex]; if (buffId == 0) { continue; } var skillEntry = this._player.SkillList?.GetSkill((ushort)buffId); if (skillEntry?.Skill?.MagicEffectDef is null) { continue; } if (await this.TryApplyBuffAsync(skillEntry).ConfigureAwait(false)) { this._nextSlotIndex = (slotIndex + 1) % BuffSlotCount; this._buffTimerTriggered = false; return false; } } this._nextSlotIndex = 0; this._buffTimerTriggered = false; return true; } /// /// Attempts to apply the buff to self and, if applicable, to party members. /// /// True if a buff was applied and the tick should end. private async ValueTask TryApplyBuffAsync(SkillEntry skillEntry) { if (await this.TryApplySelfBuffAsync(skillEntry).ConfigureAwait(false)) { return true; } if (this.IsSelfOnlySkill(skillEntry)) { return false; } return await this.TryApplyPartyBuffAsync(skillEntry).ConfigureAwait(false); } /// /// Attempts to apply the buff to the player. /// For skills, delegates to the skill plugin /// which handles applying to all visible party members at once. /// /// True if the buff was applied. private async ValueTask TryApplySelfBuffAsync(SkillEntry skillEntry) { if (!this.NeedsBuff(this._player, skillEntry)) { return false; } if (skillEntry.Skill?.Target == SkillTarget.ImplicitParty) { var strategy = this._player.GameContext.PlugInManager .GetStrategy(skillEntry.Skill!.Number) ?? DefaultPlugin; await strategy.PerformSkillAsync(this._player, this._player, (ushort)skillEntry.Skill.Number).ConfigureAwait(false); return true; } await this._player.ForEachWorldObserverAsync( p => p.ShowSkillAnimationAsync(this._player, this._player, skillEntry.Skill!, true), includeThis: true).ConfigureAwait(false); await this._player.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false); return true; } /// /// Attempts to apply the buff to the first party member that needs it. /// /// True if the buff was applied to a party member. private async ValueTask TryApplyPartyBuffAsync(SkillEntry skillEntry) { if (this._config is not { SupportParty: true } || this._player.Party is not { } party) { return false; } foreach (var member in party.PartyList.OfType()) { if (member == this._player) { continue; } if (!this.NeedsPartyBuff(member, skillEntry)) { continue; } await (member as IObservable ?? this._player).ForEachWorldObserverAsync( p => p.ShowSkillAnimationAsync(this._player, member, skillEntry.Skill!, true), includeThis: true).ConfigureAwait(false); await member.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false); return true; } return false; } /// /// Determines whether the buff should be applied to the player. /// Returns true if the effect is not yet active, or if it is active /// and the periodic re-buff timer has triggered with enabled. /// private bool NeedsBuff(IAttackable target, SkillEntry skillEntry) { if (!this.IsTargetReachable(target)) { return false; } if (!this.IsEffectActive(target, skillEntry)) { return true; } return this._config!.BuffOnDuration && this._buffTimerTriggered; } /// /// Determines whether the buff should be applied to a party member. /// Returns true if the effect is not yet active, or if it is active /// and the periodic re-buff timer has triggered with enabled. /// private bool NeedsPartyBuff(IAttackable target, SkillEntry skillEntry) { if (!this.IsTargetReachable(target)) { return false; } if (!this.IsEffectActive(target, skillEntry)) { return true; } return this._config!.BuffDurationForParty && this._buffTimerTriggered; } private bool IsSelfOnlySkill(SkillEntry skillEntry) { if (skillEntry.Skill is not { } skill) { return true; } return skill.Target == SkillTarget.ImplicitPlayer || skill.TargetRestriction == SkillTargetRestriction.Self; } private bool IsTargetReachable(IAttackable target) { return target.IsActive() && this._player.IsInRange(target, this._config!.HuntingRange); } private bool IsEffectActive(IAttackable target, SkillEntry skillEntry) { var effectDef = skillEntry.Skill?.MagicEffectDef; if (effectDef is null) { return false; } try { // Eager snapshot, like the other readers of ActiveEffects (see MagicEffectsList): the // list is mutated by the effect expiry timers, and a lazy enumeration from this // (unsynchronized) helper tick raced them regularly at scale. A list shrinking in the // middle of the copy can still leave null holes in the snapshot (hence the tolerant // predicate) or throw out of the copy itself (hence the catch-all around this pure // read) - any torn read simply counts as "active", and the next tick retries. var activeEffects = target.MagicEffectList.ActiveEffects.Values.ToArray(); return activeEffects.Any(e => e?.Definition == effectDef); } catch (Exception) { return true; } } private void UpdatePeriodicBuffTimer() { if (this._config is null || this._config.BuffCastIntervalSeconds <= 0) { return; } this._nextPeriodicBuffTime ??= DateTime.UtcNow.AddSeconds(this._config.BuffCastIntervalSeconds); if (DateTime.UtcNow >= this._nextPeriodicBuffTime) { this._buffTimerTriggered = true; this._nextPeriodicBuffTime = DateTime.UtcNow.AddSeconds(this._config.BuffCastIntervalSeconds); } } }