//
// 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);
}
}
}