Make the server-side bots hold up as a population (cherry picked from commit 88535b63a958fee9803d5fc3a4bdac5a3318d221)
331 lines
12 KiB
C#
331 lines
12 KiB
C#
// <copyright file="BuffHandler.cs" company="MUnique">
|
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
|
// </copyright>
|
|
|
|
namespace MUnique.OpenMU.GameLogic.Offline;
|
|
|
|
using MUnique.OpenMU.DataModel.Entities;
|
|
using MUnique.OpenMU.GameLogic.Bots;
|
|
using MUnique.OpenMU.GameLogic.MuHelper;
|
|
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
|
|
using MUnique.OpenMU.GameLogic.PlugIns;
|
|
using MUnique.OpenMU.GameLogic.Views.World;
|
|
|
|
/// <summary>
|
|
/// Handles buff application and management for the offline player.
|
|
/// </summary>
|
|
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<int>? _cachedAutoBuffIds;
|
|
private int _cachedAutoBuffSkillCount = -1;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="BuffHandler"/> class.
|
|
/// </summary>
|
|
/// <param name="player">The offline player.</param>
|
|
/// <param name="config">The MU Helper configuration.</param>
|
|
public BuffHandler(OfflinePlayer player, IMuHelperSettings? config)
|
|
{
|
|
this._player = player;
|
|
this._config = config;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the configured buff skill IDs from the settings. With <see cref="IMuHelperSettings.AutoSelectBuffs"/>
|
|
/// 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.
|
|
/// </summary>
|
|
public IList<int> 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];
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks and applies buffs if configured and needed.
|
|
/// </summary>
|
|
/// <returns>True, if the loop can continue to the next step; False, if a buff was cast and the tick should end.</returns>
|
|
public async ValueTask<bool> 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
|
|
|| !this.CanCast(skillEntry.Skill))
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether the character currently meets the skill's own requirements, and can therefore cast it
|
|
/// at all. A character keeps its skills across a reset but not the level which unlocked them, so a
|
|
/// veteran back at level 12 still owns Swell Life, which asks for level 120.
|
|
/// <para>
|
|
/// Skipping it here is what keeps the whole helper running. A buff which targets the party goes
|
|
/// through the skill plugin, which refuses an unmet requirement deep inside and silently - and this
|
|
/// handler reports it as applied regardless. The buff step then ends every tick believing it had
|
|
/// just buffed, so the steps behind it, picking up loot and attacking, never ran at all: the
|
|
/// character stood in the world doing nothing, for good.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="skill">The skill to cast.</param>
|
|
private bool CanCast(Skill skill)
|
|
=> BotProgression.MeetsRequirements(skill, attribute => this._player.Attributes?[attribute]);
|
|
|
|
/// <summary>
|
|
/// Attempts to apply the buff to self and, if applicable, to party members.
|
|
/// </summary>
|
|
/// <returns>True if a buff was applied and the tick should end.</returns>
|
|
private async ValueTask<bool> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to apply the buff to the player.
|
|
/// For <see cref="SkillTarget.ImplicitParty"/> skills, delegates to the skill plugin
|
|
/// which handles applying to all visible party members at once.
|
|
/// </summary>
|
|
/// <returns>True if the buff was applied.</returns>
|
|
private async ValueTask<bool> TryApplySelfBuffAsync(SkillEntry skillEntry)
|
|
{
|
|
if (!this.NeedsBuff(this._player, skillEntry))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (skillEntry.Skill?.Target == SkillTarget.ImplicitParty)
|
|
{
|
|
var strategy = this._player.GameContext.PlugInManager
|
|
.GetStrategy<short, ITargetedSkillPlugin>(skillEntry.Skill!.Number)
|
|
?? DefaultPlugin;
|
|
|
|
await strategy.PerformSkillAsync(this._player, this._player, (ushort)skillEntry.Skill.Number).ConfigureAwait(false);
|
|
return true;
|
|
}
|
|
|
|
await this._player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to apply the buff to the first party member that needs it.
|
|
/// </summary>
|
|
/// <returns>True if the buff was applied to a party member.</returns>
|
|
private async ValueTask<bool> 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<IAttackable>())
|
|
{
|
|
if (member == this._player)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!this.NeedsPartyBuff(member, skillEntry))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
await (member as IObservable ?? this._player).ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="IMuHelperSettings.BuffOnDuration"/> enabled.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="IMuHelperSettings.BuffDurationForParty"/> enabled.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
} |