baseline: OpenMU upstream b5a0961 (fresh source)
This commit is contained in:
268
src/GameLogic/Offline/BuffHandler.cs
Normal file
268
src/GameLogic/Offline/BuffHandler.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
// <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.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;
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public IList<int> ConfiguredBuffIds
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this._config is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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>
|
||||
/// 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;
|
||||
}
|
||||
|
||||
return target.MagicEffectList.ActiveEffects.Values
|
||||
.Any(e => e.Definition == effectDef);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
545
src/GameLogic/Offline/CombatHandler.cs
Normal file
545
src/GameLogic/Offline/CombatHandler.cs
Normal file
@@ -0,0 +1,545 @@
|
||||
// <copyright file="CombatHandler.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.GameLogic.Attributes;
|
||||
using MUnique.OpenMU.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.NPC;
|
||||
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
|
||||
using MUnique.OpenMU.GameLogic.PlugIns;
|
||||
using MUnique.OpenMU.GameLogic.Views.World;
|
||||
using MUnique.OpenMU.Pathfinding;
|
||||
|
||||
/// <summary>
|
||||
/// Handles combat logic including target selection, attacks, combo attacks, and skill usage.
|
||||
/// </summary>
|
||||
public sealed class CombatHandler
|
||||
{
|
||||
private const byte DefaultRange = 1;
|
||||
private const byte BowRange = 6;
|
||||
private const int ComboFinisherDelayTicks = 3;
|
||||
private const int InterSkillDelayTicks = 1;
|
||||
private const int MinComboSkillCount = 3;
|
||||
|
||||
private const short DrainLifeBaseSkillId = 214;
|
||||
private const short DrainLifeStrengthenerSkillId = 458;
|
||||
private const short DrainLifeMasterySkillId = 462;
|
||||
|
||||
private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new();
|
||||
|
||||
private readonly OfflinePlayer _player;
|
||||
private readonly IMuHelperSettings? _config;
|
||||
private readonly MovementHandler _movementHandler;
|
||||
private readonly Point _originPosition;
|
||||
private readonly ConditionalSkillSlot[] _conditionalSkillSlots;
|
||||
|
||||
private IAttackable? _currentTarget;
|
||||
private int _nearbyMonsterCount;
|
||||
private int _currentComboStep;
|
||||
private int _skillCooldownTicks;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CombatHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="player">The offline player.</param>
|
||||
/// <param name="config">The MU helper settings.</param>
|
||||
/// <param name="movementHandler">The movement handler.</param>
|
||||
/// <param name="originPosition">The original position to hunt around.</param>
|
||||
public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler, Point originPosition)
|
||||
{
|
||||
this._player = player;
|
||||
this._config = config;
|
||||
this._movementHandler = movementHandler;
|
||||
this._originPosition = originPosition;
|
||||
this._conditionalSkillSlots = config is null ? [] :
|
||||
[
|
||||
new ConditionalSkillSlot(config.ActivationSkill1Id, config.Skill1UseTimer, config.DelayMinSkill1, config.Skill1UseCondition, config.Skill1ConditionAttacking, config.Skill1SubCondition),
|
||||
new ConditionalSkillSlot(config.ActivationSkill2Id, config.Skill2UseTimer, config.DelayMinSkill2, config.Skill2UseCondition, config.Skill2ConditionAttacking, config.Skill2SubCondition),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remaining skill cooldown ticks.
|
||||
/// </summary>
|
||||
public int SkillCooldownTicks => this._skillCooldownTicks;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hunting range in tiles.
|
||||
/// </summary>
|
||||
public byte HuntingRange => CalculateHuntingRange(this._config);
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the hunting range in tiles from the specified configuration.
|
||||
/// </summary>
|
||||
/// <param name="config">The configuration.</param>
|
||||
/// <returns>The hunting range.</returns>
|
||||
public static byte CalculateHuntingRange(IMuHelperSettings? config)
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
return DefaultRange;
|
||||
}
|
||||
|
||||
return (byte)Math.Max(DefaultRange, config.HuntingRange);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrements the skill cooldown counter by one tick.
|
||||
/// </summary>
|
||||
public void DecrementCooldown()
|
||||
{
|
||||
if (this._skillCooldownTicks > 0)
|
||||
{
|
||||
this._skillCooldownTicks--;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs combat attacks on targets.
|
||||
/// </summary>
|
||||
public async ValueTask PerformAttackAsync()
|
||||
{
|
||||
this.RefreshTarget();
|
||||
|
||||
if (this._currentTarget is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
byte attackRange = this.GetEffectiveAttackRange();
|
||||
if (!this.IsTargetInAttackRange(this._currentTarget, attackRange))
|
||||
{
|
||||
await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._config?.UseCombo == true)
|
||||
{
|
||||
await this.ExecuteComboAttackAsync().ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.ExecuteAttackAsync(this._currentTarget).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs health recovery through Drain Life attacks if configured.
|
||||
/// </summary>
|
||||
public async ValueTask PerformDrainLifeRecoveryAsync()
|
||||
{
|
||||
if (this._config is null || this._player.Attributes is null || !this._config.UseDrainLife)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double maxHp = this._player.Attributes[Stats.MaximumHealth];
|
||||
if (maxHp <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double hp = this._player.Attributes[Stats.CurrentHealth];
|
||||
int hpPercent = (int)(hp * 100.0 / maxHp);
|
||||
if (hpPercent <= this._config.HealThresholdPercent)
|
||||
{
|
||||
var drainSkill = this.FindDrainLifeSkill();
|
||||
if (drainSkill is not null)
|
||||
{
|
||||
this.RefreshTarget();
|
||||
if (this._currentTarget is not null)
|
||||
{
|
||||
await this.ExecuteAttackAsync(this._currentTarget, drainSkill, false).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask ExecuteAttackAsync(IAttackable target)
|
||||
{
|
||||
var skill = this.SelectAttackSkill();
|
||||
if (skill == null && this._config?.FallbackBasicAttack != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ExecuteAttackAsync(target, skill, false).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skillEntry, bool isCombo)
|
||||
{
|
||||
this._player.Rotation = this._player.GetDirectionTo(target);
|
||||
|
||||
if (skillEntry?.Skill is not { } skill)
|
||||
{
|
||||
await this.ExecutePhysicalAttackAsync(target).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (skill.SkillType is SkillType.AreaSkillAutomaticHits or SkillType.AreaSkillExplicitTarget or SkillType.AreaSkillExplicitHits)
|
||||
{
|
||||
await this.ExecuteAreaSkillAttackAsync(target, skillEntry, isCombo).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.ExecuteTargetedSkillAttackAsync(target, skill).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshTarget()
|
||||
{
|
||||
if (this._currentTarget is { } t && !this.IsTargetStillValid(t))
|
||||
{
|
||||
this._currentTarget = null;
|
||||
}
|
||||
|
||||
if (this._currentTarget is null)
|
||||
{
|
||||
var monsters = this.GetAttackableMonstersInHuntingRange().ToList();
|
||||
this._currentTarget = monsters.MinBy(m => m.GetDistanceTo(this._player));
|
||||
this._nearbyMonsterCount = monsters.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
this._nearbyMonsterCount = this.GetAttackableMonstersInHuntingRange().Count();
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<Monster> GetAttackableMonstersInHuntingRange()
|
||||
{
|
||||
if (this._player.CurrentMap is not { } map)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return map.GetAttackablesInRange(this._originPosition, this.HuntingRange)
|
||||
.OfType<Monster>()
|
||||
.Where(this.IsMonsterAttackable);
|
||||
}
|
||||
|
||||
private bool IsTargetInAttackRange(IAttackable target, byte range)
|
||||
{
|
||||
return target.IsInRange(this._player.Position, range);
|
||||
}
|
||||
|
||||
private bool IsTargetStillValid(IAttackable target)
|
||||
{
|
||||
return target.IsAlive
|
||||
&& !target.IsAtSafezone()
|
||||
&& !target.IsTeleporting
|
||||
&& target.IsInRange(this._originPosition, this.HuntingRange);
|
||||
}
|
||||
|
||||
private bool IsMonsterAttackable(Monster monster)
|
||||
{
|
||||
return monster.IsAlive
|
||||
&& !monster.IsAtSafezone()
|
||||
&& monster.Definition.ObjectKind == NpcObjectKind.Monster;
|
||||
}
|
||||
|
||||
private async ValueTask ExecutePhysicalAttackAsync(IAttackable target)
|
||||
{
|
||||
await target.AttackByAsync(this._player, null, false).ConfigureAwait(false);
|
||||
|
||||
await this._player.ForEachWorldObserverAsync<IShowAnimationPlugIn>(
|
||||
p => p.ShowAnimationAsync(this._player, 120, target, this._player.Rotation),
|
||||
includeThis: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask ExecuteAreaSkillAttackAsync(IAttackable target, SkillEntry skillEntry, bool isCombo)
|
||||
{
|
||||
var skill = skillEntry.Skill!;
|
||||
|
||||
if (isCombo)
|
||||
{
|
||||
await this._player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(
|
||||
p => p.ShowComboAnimationAsync(this._player, target),
|
||||
includeThis: true).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var rotationByte = (byte)(this._player.Position.GetAngleDegreeTo(target.Position) / 360.0 * 255.0);
|
||||
await this._player.ForEachWorldObserverAsync<IShowAreaSkillAnimationPlugIn>(
|
||||
p => p.ShowAreaSkillAnimationAsync(this._player, skill, target.Position, rotationByte),
|
||||
includeThis: true).ConfigureAwait(false);
|
||||
|
||||
var monstersInRange = this._player.CurrentMap?
|
||||
.GetAttackablesInRange(target.Position, skill.Range)
|
||||
.OfType<Monster>()
|
||||
.Where(this.IsMonsterAttackable)
|
||||
?? [];
|
||||
|
||||
foreach (var monster in monstersInRange)
|
||||
{
|
||||
await monster.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skill skill)
|
||||
{
|
||||
var strategy = this._player.GameContext.PlugInManager.GetStrategy<short, ITargetedSkillPlugin>(skill.Number)
|
||||
?? DefaultPlugin;
|
||||
await strategy.PerformSkillAsync(this._player, target, (ushort)skill.Number).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private SkillEntry? SelectAttackSkill()
|
||||
{
|
||||
if (this._config is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// If no skills are configured at all, don't attack.
|
||||
if (this._config.BasicSkillId == 0
|
||||
&& this._config.ActivationSkill1Id == 0
|
||||
&& this._config.ActivationSkill2Id == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var slot in this._conditionalSkillSlots)
|
||||
{
|
||||
var skill = this.EvaluateConditionalSkill(slot);
|
||||
if (skill is not null && this.HasEnoughResources(skill))
|
||||
{
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._config.BasicSkillId > 0)
|
||||
{
|
||||
var basicSkill = this._player.SkillList?.GetSkill((ushort)this._config.BasicSkillId);
|
||||
if (basicSkill is not null && this.HasEnoughResources(basicSkill))
|
||||
{
|
||||
return basicSkill;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether the skill in the given slot should fire this tick.
|
||||
/// </summary>
|
||||
private SkillEntry? EvaluateConditionalSkill(ConditionalSkillSlot slot)
|
||||
{
|
||||
if (slot.SkillId <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (slot.UseTimer && !slot.UseCondition)
|
||||
{
|
||||
if (slot.TimerIntervalSeconds <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var secondsSinceLastUse = (DateTime.UtcNow - slot.LastUseTime).TotalSeconds;
|
||||
if (secondsSinceLastUse >= slot.TimerIntervalSeconds)
|
||||
{
|
||||
slot.LastUseTime = DateTime.UtcNow;
|
||||
return this._player.SkillList?.GetSkill((ushort)slot.SkillId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (slot.UseCondition && !slot.UseTimer)
|
||||
{
|
||||
int threshold = slot.SubCondition switch
|
||||
{
|
||||
0 => 2,
|
||||
1 => 3,
|
||||
2 => 4,
|
||||
3 => 5,
|
||||
_ => int.MaxValue,
|
||||
};
|
||||
|
||||
int monsterCount = slot.ConditionAttacking
|
||||
? this.CountMonstersAttackingPlayer()
|
||||
: this._nearbyMonsterCount;
|
||||
|
||||
if (monsterCount >= threshold)
|
||||
{
|
||||
return this._player.SkillList?.GetSkill((ushort)slot.SkillId);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private int CountMonstersAttackingPlayer()
|
||||
{
|
||||
return this.GetAttackableMonstersInHuntingRange()
|
||||
.Count(m => m.IsTargetingPlayer(this._player));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the player has enough mana AND ability (AG) to cast the skill.
|
||||
/// Both resources are consumed by skills via <see cref="Skill.ConsumeRequirements"/>.
|
||||
/// </summary>
|
||||
private bool HasEnoughResources(SkillEntry? skillEntry)
|
||||
{
|
||||
if (skillEntry?.Skill is not { } skill || this._player.Attributes is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var requirement in skill.ConsumeRequirements)
|
||||
{
|
||||
int required = this._player.GetRequiredValue(requirement);
|
||||
if (this._player.Attributes[requirement.Attribute] < required)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async ValueTask ExecuteComboAttackAsync()
|
||||
{
|
||||
if (this._currentTarget is null)
|
||||
{
|
||||
this._currentComboStep = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var ids = this.GetConfiguredComboSkillIds();
|
||||
if (ids.Count < MinComboSkillCount)
|
||||
{
|
||||
await this.ExecuteAttackAsync(this._currentTarget).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var skillId = (ushort)ids[this._currentComboStep % ids.Count];
|
||||
var skillEntry = this._player.SkillList?.GetSkill(skillId);
|
||||
|
||||
if (skillEntry?.Skill is { } currentSkill && !this._currentTarget.IsInRange(this._player.Position, (byte)currentSkill.Range + 1))
|
||||
{
|
||||
this._currentComboStep = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
bool isAreaSkill = skillEntry?.Skill?.SkillType is SkillType.AreaSkillAutomaticHits or SkillType.AreaSkillExplicitTarget or SkillType.AreaSkillExplicitHits;
|
||||
var comboState = this._player.ComboState;
|
||||
var stateBefore = comboState?.CurrentState;
|
||||
|
||||
if (isAreaSkill)
|
||||
{
|
||||
bool isActuallyCombo = false;
|
||||
if (skillEntry?.Skill is { } skill && comboState is not null)
|
||||
{
|
||||
isActuallyCombo = await comboState.RegisterSkillAsync(skill).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await this.ExecuteAttackAsync(this._currentTarget, skillEntry, isActuallyCombo).ConfigureAwait(false);
|
||||
|
||||
if (isActuallyCombo)
|
||||
{
|
||||
this._skillCooldownTicks = ComboFinisherDelayTicks;
|
||||
this._currentComboStep = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
this._skillCooldownTicks = InterSkillDelayTicks;
|
||||
this._currentComboStep++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.ExecuteAttackAsync(this._currentTarget, skillEntry, false).ConfigureAwait(false);
|
||||
|
||||
var stateAfter = comboState?.CurrentState;
|
||||
if (stateBefore != comboState?.InitialState && stateAfter == comboState?.InitialState)
|
||||
{
|
||||
this._skillCooldownTicks = ComboFinisherDelayTicks;
|
||||
this._currentComboStep = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
this._skillCooldownTicks = InterSkillDelayTicks;
|
||||
this._currentComboStep++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<int> GetConfiguredComboSkillIds()
|
||||
{
|
||||
var ids = new List<int>();
|
||||
if (this._config is not null)
|
||||
{
|
||||
if (this._config.BasicSkillId > 0)
|
||||
{
|
||||
ids.Add(this._config.BasicSkillId);
|
||||
}
|
||||
|
||||
if (this._config.ActivationSkill1Id > 0)
|
||||
{
|
||||
ids.Add(this._config.ActivationSkill1Id);
|
||||
}
|
||||
|
||||
if (this._config.ActivationSkill2Id > 0)
|
||||
{
|
||||
ids.Add(this._config.ActivationSkill2Id);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private byte GetEffectiveAttackRange()
|
||||
{
|
||||
if (this._config is null)
|
||||
{
|
||||
return DefaultRange;
|
||||
}
|
||||
|
||||
var skillIds = this._config.UseCombo
|
||||
? this.GetConfiguredComboSkillIds()
|
||||
: (this._config.BasicSkillId > 0 ? [this._config.BasicSkillId] : []);
|
||||
|
||||
if (skillIds.Count > 0)
|
||||
{
|
||||
var ranges = skillIds
|
||||
.Select(id => this._player.SkillList?.GetSkill((ushort)id)?.Skill?.Range ?? 0)
|
||||
.Where(r => r > 0)
|
||||
.ToList();
|
||||
|
||||
if (ranges.Count > 0)
|
||||
{
|
||||
return (byte)ranges.Min();
|
||||
}
|
||||
}
|
||||
|
||||
if (this._player.Attributes is { } attributes
|
||||
&& (attributes[Stats.IsBowEquipped] > 0 || attributes[Stats.IsCrossBowEquipped] > 0))
|
||||
{
|
||||
return BowRange;
|
||||
}
|
||||
|
||||
return DefaultRange;
|
||||
}
|
||||
|
||||
private SkillEntry? FindDrainLifeSkill()
|
||||
{
|
||||
return this._player.SkillList?.Skills.FirstOrDefault(s =>
|
||||
s.Skill is { Number: DrainLifeBaseSkillId or DrainLifeStrengthenerSkillId or DrainLifeMasterySkillId });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds the configuration for one conditional skill slot, along with its mutable last-use timestamp.
|
||||
/// </summary>
|
||||
private sealed class ConditionalSkillSlot(int skillId, bool useTimer, int timerIntervalSeconds, bool useCondition, bool conditionAttacking, int subCondition)
|
||||
{
|
||||
public int SkillId { get; } = skillId;
|
||||
public bool UseTimer { get; } = useTimer;
|
||||
public int TimerIntervalSeconds { get; } = timerIntervalSeconds;
|
||||
public bool UseCondition { get; } = useCondition;
|
||||
public bool ConditionAttacking { get; } = conditionAttacking;
|
||||
public int SubCondition { get; } = subCondition;
|
||||
public DateTime LastUseTime { get; set; }
|
||||
}
|
||||
}
|
||||
154
src/GameLogic/Offline/HealingHandler.cs
Normal file
154
src/GameLogic/Offline/HealingHandler.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
// <copyright file="HealingHandler.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.Configuration;
|
||||
using MUnique.OpenMU.DataModel.Configuration.Items;
|
||||
using MUnique.OpenMU.DataModel.Entities;
|
||||
using MUnique.OpenMU.GameLogic.Attributes;
|
||||
using MUnique.OpenMU.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions;
|
||||
using MUnique.OpenMU.GameLogic.Views.World;
|
||||
using MUnique.OpenMU.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Handles health recovery for the offline player and their party.
|
||||
/// </summary>
|
||||
public sealed class HealingHandler
|
||||
{
|
||||
private static readonly ItemConsumeAction ConsumeAction = new();
|
||||
|
||||
private static readonly ItemIdentifier[] HealthPotionPriority =
|
||||
[
|
||||
ItemConstants.LargeHealingPotion,
|
||||
ItemConstants.MediumHealingPotion,
|
||||
ItemConstants.SmallHealingPotion,
|
||||
ItemConstants.Apple,
|
||||
];
|
||||
|
||||
private readonly OfflinePlayer _player;
|
||||
private readonly IMuHelperSettings? _config;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HealingHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="player">The offline player.</param>
|
||||
/// <param name="config">The MU helper settings.</param>
|
||||
public HealingHandler(OfflinePlayer player, IMuHelperSettings? config)
|
||||
{
|
||||
this._player = player;
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs health recovery actions for the player and their party.
|
||||
/// </summary>
|
||||
public async ValueTask PerformHealthRecoveryAsync()
|
||||
{
|
||||
if (this._config is null || this._player.Attributes is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await this.PerformSelfHealingAsync().ConfigureAwait(false);
|
||||
|
||||
await this.PerformPartyHealingAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask PerformSelfHealingAsync()
|
||||
{
|
||||
if (this.IsHealthBelowThreshold(this._player, this._config!.HealThresholdPercent))
|
||||
{
|
||||
var healSkill = this.FindSkillByType(SkillType.Regeneration);
|
||||
if (healSkill is not null && this._config.AutoHeal)
|
||||
{
|
||||
await this._player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(
|
||||
p => p.ShowSkillAnimationAsync(this._player, this._player, healSkill.Skill!, true),
|
||||
includeThis: true).ConfigureAwait(false);
|
||||
await this._player.ApplyRegenerationAsync(this._player, healSkill).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._config!.UseHealPotion && this.IsHealthBelowThreshold(this._player, this._config.PotionThresholdPercent))
|
||||
{
|
||||
await this.UseHealthPotionAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask PerformPartyHealingAsync()
|
||||
{
|
||||
if (!this._config!.AutoHealParty || this._player.Party is not { } party)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var healSkill = this.FindSkillByType(SkillType.Regeneration);
|
||||
if (healSkill is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var member in party.PartyList.OfType<Player>())
|
||||
{
|
||||
if (member == this._player)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!member.IsActive() || !this._player.IsInRange(member, this._config!.HuntingRange))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.IsHealthBelowThreshold(member, this._config.HealPartyThresholdPercent))
|
||||
{
|
||||
await this._player.ForEachWorldObserverAsync<IShowSkillAnimationPlugIn>(
|
||||
p => p.ShowSkillAnimationAsync(this._player, member, healSkill.Skill!, true),
|
||||
includeThis: true).ConfigureAwait(false);
|
||||
|
||||
await member.ApplyRegenerationAsync(this._player, healSkill).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsHealthBelowThreshold(IAttackable target, int thresholdPercent)
|
||||
{
|
||||
if (target.Attributes is not { } attributes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double hp = attributes[Stats.CurrentHealth];
|
||||
double maxHp = attributes[Stats.MaximumHealth];
|
||||
return maxHp > 0 && (hp * 100.0 / maxHp) <= thresholdPercent;
|
||||
}
|
||||
|
||||
private async ValueTask UseHealthPotionAsync()
|
||||
{
|
||||
if (this._player.Inventory is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var identifier in HealthPotionPriority)
|
||||
{
|
||||
var potion = this._player.Inventory.Items
|
||||
.FirstOrDefault(i => i.Definition?.Group == identifier.Group
|
||||
&& i.Definition.Number == identifier.Number);
|
||||
if (potion is not null)
|
||||
{
|
||||
await ConsumeAction.HandleConsumeRequestAsync(
|
||||
this._player, potion.ItemSlot, potion.ItemSlot, FruitUsage.Undefined).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SkillEntry? FindSkillByType(SkillType type)
|
||||
{
|
||||
return this._player.SkillList?.Skills.FirstOrDefault(s => s.Skill?.SkillType == type);
|
||||
}
|
||||
}
|
||||
140
src/GameLogic/Offline/ItemPickupHandler.cs
Normal file
140
src/GameLogic/Offline/ItemPickupHandler.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
// <copyright file="ItemPickupHandler.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.Configuration.Items;
|
||||
using MUnique.OpenMU.DataModel.Entities;
|
||||
using MUnique.OpenMU.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.PlayerActions.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Handles item and zen pickup for the offline player.
|
||||
/// </summary>
|
||||
public sealed class ItemPickupHandler
|
||||
{
|
||||
private const byte MinPickupRange = 1;
|
||||
|
||||
private static readonly PickupItemAction PickupAction = new();
|
||||
|
||||
private static readonly HashSet<ItemIdentifier> Jewels =
|
||||
[
|
||||
ItemConstants.JewelOfChaos,
|
||||
ItemConstants.JewelOfBless,
|
||||
ItemConstants.JewelOfSoul,
|
||||
ItemConstants.JewelOfLife,
|
||||
ItemConstants.JewelOfCreation,
|
||||
ItemConstants.JewelOfGuardian,
|
||||
ItemConstants.Gemstone,
|
||||
ItemConstants.JewelOfHarmony,
|
||||
ItemConstants.LowerRefineStone,
|
||||
ItemConstants.HigherRefineStone,
|
||||
];
|
||||
|
||||
private readonly OfflinePlayer _player;
|
||||
private readonly IMuHelperSettings? _config;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ItemPickupHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="player">The offline player.</param>
|
||||
/// <param name="config">The MU Helper configuration.</param>
|
||||
public ItemPickupHandler(OfflinePlayer player, IMuHelperSettings? config)
|
||||
{
|
||||
this._player = player;
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans for and picks up items within a configurable range.
|
||||
/// </summary>
|
||||
public async ValueTask PickupItemsAsync()
|
||||
{
|
||||
if (this._config is null || this._player.CurrentMap is not { } map)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._config.PickAllItems && !this._config.PickSelectItems)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
byte range = (byte)Math.Max(this._config.ObtainRange, MinPickupRange);
|
||||
var drops = map.GetDropsInRange(this._player.Position, range);
|
||||
|
||||
foreach (var drop in drops)
|
||||
{
|
||||
if (this.ShouldPickUpDrop(drop))
|
||||
{
|
||||
await PickupAction.PickupItemAsync(this._player, drop.Id).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsJewel(Item item)
|
||||
{
|
||||
if (item.Definition is not { } definition)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Jewels.Contains(new(definition.Number, definition.Group));
|
||||
}
|
||||
|
||||
private bool ShouldPickUpDrop(IIdentifiable drop)
|
||||
{
|
||||
if (this._config!.PickAllItems)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this._config.PickSelectItems)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (drop is DroppedMoney && this._config.PickZen)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (drop is DroppedItem droppedItem)
|
||||
{
|
||||
return this.ShouldPickUp(droppedItem.Item);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool ShouldPickUp(Item item)
|
||||
{
|
||||
if (this._config is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._config.PickJewel && IsJewel(item))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._config.PickAncient && item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._config.PickExcellent && item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._config.PickExtraItems && item.Definition is { } definition)
|
||||
{
|
||||
return this._config.ExtraItemNames.Any(name => definition.Name.ToString()?.Contains(name, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
134
src/GameLogic/Offline/MovementHandler.cs
Normal file
134
src/GameLogic/Offline/MovementHandler.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
// <copyright file="MovementHandler.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.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.Pathfinding;
|
||||
|
||||
/// <summary>
|
||||
/// Handles movement logic including walking, regrouping, and return-to-origin.
|
||||
/// </summary>
|
||||
public sealed class MovementHandler
|
||||
{
|
||||
private const byte RegroupDistanceThreshold = 1;
|
||||
|
||||
private readonly OfflinePlayer _player;
|
||||
private readonly IMuHelperSettings? _config;
|
||||
private readonly Point _originPosition;
|
||||
|
||||
private DateTime? _outOfRangeSince;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovementHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="player">The offline player.</param>
|
||||
/// <param name="config">The MU Helper configuration.</param>
|
||||
/// <param name="originPosition">The original spawn position.</param>
|
||||
public MovementHandler(OfflinePlayer player, IMuHelperSettings? config, Point originPosition)
|
||||
{
|
||||
this._player = player;
|
||||
this._config = config;
|
||||
this._originPosition = originPosition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hunting range in tiles.
|
||||
/// </summary>
|
||||
private byte HuntingRange => CombatHandler.CalculateHuntingRange(this._config);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the character to the original position if configured and distance/time thresholds are met.
|
||||
/// </summary>
|
||||
/// <returns>True, if the loop can continue; False, if a regrouping walk was initiated.</returns>
|
||||
public async ValueTask<bool> RegroupAsync()
|
||||
{
|
||||
if (this._config is null || !this._config.ReturnToOriginalPosition)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.ShouldRegroup(out var distance))
|
||||
{
|
||||
await this.WalkToAsync(this._originPosition).ConfigureAwait(false);
|
||||
this._outOfRangeSince = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (distance <= RegroupDistanceThreshold)
|
||||
{
|
||||
this._outOfRangeSince = null;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the player closer to a target within the specified range.
|
||||
/// </summary>
|
||||
/// <param name="target">The target to move closer to.</param>
|
||||
/// <param name="range">The range to stop within.</param>
|
||||
public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range)
|
||||
{
|
||||
if (this._player.CurrentMap is { } map && target.IsInRange(this._originPosition, this.HuntingRange))
|
||||
{
|
||||
var walkTarget = map.Terrain.GetRandomCoordinate(target.Position, range);
|
||||
await this.WalkToAsync(walkTarget).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the player to the specified target position.
|
||||
/// </summary>
|
||||
/// <param name="target">The target position to walk to.</param>
|
||||
/// <returns>True if the walk was successful; otherwise, false.</returns>
|
||||
private async ValueTask<bool> WalkToAsync(Point target)
|
||||
{
|
||||
if (this._player.IsWalking || this._player.CurrentMap is not { } map)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var pathFinder = await this._player.GameContext.PathFinderPool.GetAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
pathFinder.ResetPathFinder();
|
||||
var path = pathFinder.FindPath(this._player.Position, target, map.Terrain.AIgrid, false);
|
||||
if (path is null || path.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stepsCount = Math.Min(path.Count, 16);
|
||||
var steps = new WalkingStep[stepsCount];
|
||||
for (int i = 0; i < stepsCount; i++)
|
||||
{
|
||||
var node = path[i];
|
||||
var prevPos = i == 0 ? this._player.Position : steps[i - 1].To;
|
||||
steps[i] = new WalkingStep(prevPos, node.Point, prevPos.GetDirectionTo(node.Point));
|
||||
}
|
||||
|
||||
await this._player.WalkToAsync(target, steps).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._player.GameContext.PathFinderPool.Return(pathFinder);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldRegroup(out double distance)
|
||||
{
|
||||
distance = this._player.GetDistanceTo(this._originPosition);
|
||||
if (distance <= RegroupDistanceThreshold)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
this._outOfRangeSince ??= DateTime.UtcNow;
|
||||
var secondsAway = (DateTime.UtcNow - this._outOfRangeSince.Value).TotalSeconds;
|
||||
|
||||
return secondsAway >= this._config!.MaxSecondsAway || distance > this.HuntingRange;
|
||||
}
|
||||
}
|
||||
34
src/GameLogic/Offline/OfflineMapChangePlugIn.cs
Normal file
34
src/GameLogic/Offline/OfflineMapChangePlugIn.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
// <copyright file="OfflineMapChangePlugIn.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 System.Threading.Tasks;
|
||||
using MUnique.OpenMU.GameLogic.Views.World;
|
||||
|
||||
/// <summary>
|
||||
/// Simulates a map change response for an offline character.
|
||||
/// </summary>
|
||||
internal sealed class OfflineMapChangePlugIn : IMapChangePlugIn
|
||||
{
|
||||
private readonly OfflinePlayer _player;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OfflineMapChangePlugIn"/> class.
|
||||
/// </summary>
|
||||
/// <param name="player">The offline player.</param>
|
||||
public OfflineMapChangePlugIn(OfflinePlayer player)
|
||||
{
|
||||
this._player = player;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask MapChangeAsync()
|
||||
{
|
||||
await this._player.ClientReadyAfterMapChangeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask MapChangeFailedAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
154
src/GameLogic/Offline/OfflinePlayer.cs
Normal file
154
src/GameLogic/Offline/OfflinePlayer.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
// <copyright file="OfflinePlayer.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.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.Views;
|
||||
using MUnique.OpenMU.PlugIns;
|
||||
|
||||
/// <summary>
|
||||
/// An offline player that continues leveling after the real client disconnects.
|
||||
/// </summary>
|
||||
public sealed class OfflinePlayer : Player
|
||||
{
|
||||
private OfflinePlayerMuHelper? _intelligence;
|
||||
private Task? _intelligenceDisposeTask;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OfflinePlayer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="gameContext">The game context.</param>
|
||||
public OfflinePlayer(IGameContext gameContext)
|
||||
: base(gameContext)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the login name this offline player belongs to.
|
||||
/// </summary>
|
||||
public string? AccountLoginName => this.Account?.LoginName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the start timestamp of the offline session.
|
||||
/// </summary>
|
||||
public DateTime StartTimestamp { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the offline player by loading the account fresh from the database.
|
||||
/// </summary>
|
||||
/// <param name="loginName">The account login name.</param>
|
||||
/// <param name="characterName">The character name to continue with.</param>
|
||||
/// <returns><c>true</c> if successfully started.</returns>
|
||||
public async ValueTask<bool> InitializeAsync(string loginName, string characterName)
|
||||
{
|
||||
try
|
||||
{
|
||||
this.StartTimestamp = DateTime.UtcNow;
|
||||
|
||||
var account = await this.PersistenceContext.GetAccountByLoginNameAsync(loginName).ConfigureAwait(false);
|
||||
if (account is null)
|
||||
{
|
||||
this.Logger.LogError("Failed to load account {LoginName} for offline session.", loginName);
|
||||
return false;
|
||||
}
|
||||
|
||||
var character = account.Characters?.FirstOrDefault(c => c.Name == characterName);
|
||||
if (character is null)
|
||||
{
|
||||
this.Logger.LogError("Character {CharacterName} not found in account {LoginName}.", characterName, loginName);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.Account = account;
|
||||
|
||||
await this.AdvanceToCharacterSelectionStateAsync().ConfigureAwait(false);
|
||||
|
||||
await this.SetupCharacterAsync(character).ConfigureAwait(false);
|
||||
|
||||
await this.ClientReadyAfterMapChangeAsync().ConfigureAwait(false);
|
||||
|
||||
this.StartIntelligence();
|
||||
|
||||
this.Logger.LogDebug(
|
||||
"Offline player started for character {CharacterName} on map {Map} at {Position}.",
|
||||
character.Name,
|
||||
character.CurrentMap?.Name,
|
||||
this.Position);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Failed to initialize offline player for {LoginName}.", loginName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the offline player and removes it from the world.
|
||||
/// </summary>
|
||||
public async ValueTask StopAsync()
|
||||
{
|
||||
await this.DisconnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask InternalDisconnectAsync()
|
||||
{
|
||||
if (this._intelligence is { } intelligence)
|
||||
{
|
||||
this._intelligence = null;
|
||||
this._intelligenceDisposeTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await intelligence.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Error disposing intelligence for offline player {AccountLoginName}.", this.AccountLoginName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await base.InternalDisconnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask DisposeAsyncCore()
|
||||
{
|
||||
if (this._intelligenceDisposeTask is { } disposeTask)
|
||||
{
|
||||
await disposeTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await base.DisposeAsyncCore().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ICustomPlugInContainer<IViewPlugIn> CreateViewPlugInContainer()
|
||||
=> new OfflineViewPlugInContainer(this);
|
||||
|
||||
private async ValueTask AdvanceToCharacterSelectionStateAsync()
|
||||
{
|
||||
// Advance state to allow the intelligence to perform actions.
|
||||
await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.LoginScreen).ConfigureAwait(false);
|
||||
await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.Authenticated).ConfigureAwait(false);
|
||||
await this.PlayerState.TryAdvanceToAsync(GameLogic.PlayerState.CharacterSelection).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask SetupCharacterAsync(Character character)
|
||||
{
|
||||
await this.GameContext.AddPlayerAsync(this).ConfigureAwait(false);
|
||||
await this.SetSelectedCharacterAsync(character).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void StartIntelligence()
|
||||
{
|
||||
this._intelligence = new OfflinePlayerMuHelper(this);
|
||||
this._intelligence.Start();
|
||||
}
|
||||
}
|
||||
198
src/GameLogic/Offline/OfflinePlayerManager.cs
Normal file
198
src/GameLogic/Offline/OfflinePlayerManager.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
// <copyright file="OfflinePlayerManager.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.GameLogic.Attributes;
|
||||
using MUnique.OpenMU.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.Views.Login;
|
||||
|
||||
/// <summary>
|
||||
/// Manages active <see cref="OfflinePlayer"/> sessions.
|
||||
/// </summary>
|
||||
public sealed class OfflinePlayerManager
|
||||
{
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, OfflinePlayer> _activePlayers =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a snapshot of all currently active offline players.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<OfflinePlayer> OfflinePlayers
|
||||
=> this._activePlayers.Values.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Starts an offline player session by replacing the real player with a copy.
|
||||
/// </summary>
|
||||
/// <param name="realPlayer">The real player who typed the command.</param>
|
||||
/// <param name="loginName">The pre-validated account login name.</param>
|
||||
/// <returns><c>true</c> if the offline session was started successfully.</returns>
|
||||
public async ValueTask<bool> StartAsync(Player realPlayer, string loginName)
|
||||
{
|
||||
var characterName = realPlayer.SelectedCharacter?.Name;
|
||||
|
||||
if (string.IsNullOrEmpty(characterName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sentinel = new OfflinePlayer(realPlayer.GameContext);
|
||||
|
||||
// Atomically claim the slot to prevent racing during initialization.
|
||||
if (!this._activePlayers.TryAdd(loginName, sentinel))
|
||||
{
|
||||
await sentinel.DisposeAsync().ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.TryChargeInitialZenCost(realPlayer))
|
||||
{
|
||||
await this.RemoveAndDisposeAsync(loginName, sentinel).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await this.TransitionToOfflineAsync(realPlayer, loginName).ConfigureAwait(false);
|
||||
|
||||
if (!await sentinel.InitializeAsync(loginName, characterName).ConfigureAwait(false))
|
||||
{
|
||||
await this.RemoveAndDisposeAsync(loginName, sentinel).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await this.RemoveAndDisposeAsync(loginName, sentinel).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the offline session for the given account, if one exists.
|
||||
/// </summary>
|
||||
/// <param name="loginName">The account login name.</param>
|
||||
public async ValueTask StopAsync(string loginName)
|
||||
{
|
||||
if (!this._activePlayers.TryRemove(loginName, out var offlinePlayer))
|
||||
{
|
||||
// The session might have been started and stopped outside the manager (e.g. in tests).
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await offlinePlayer.StopAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
offlinePlayer.Logger.LogError(ex, "Error stopping offline player session for {0}.", loginName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await offlinePlayer.GameContext.RemovePlayerAsync(offlinePlayer).ConfigureAwait(false);
|
||||
await offlinePlayer.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether an offline player session is currently active for <paramref name="loginName"/>.
|
||||
/// </summary>
|
||||
/// <param name="loginName">The account login name.</param>
|
||||
public bool IsActive(string loginName) => this._activePlayers.ContainsKey(loginName);
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the active offline player for the given account login name.
|
||||
/// </summary>
|
||||
/// <param name="loginName">The account login name.</param>
|
||||
/// <param name="player">The offline player, if found.</param>
|
||||
/// <returns><c>true</c> if an active session exists; otherwise <c>false</c>.</returns>
|
||||
public bool TryGetPlayer(string loginName, out OfflinePlayer? player)
|
||||
=> this._activePlayers.TryGetValue(loginName, out player);
|
||||
|
||||
private async ValueTask TransitionToOfflineAsync(Player realPlayer, string loginName)
|
||||
{
|
||||
await this.LogOffFromLoginServerAsync(realPlayer, loginName).ConfigureAwait(false);
|
||||
|
||||
// Send a close-game packet so the client exits cleanly without auto-reconnecting.
|
||||
// DisconnectAsync will then fire PlayerDisconnected, which triggers
|
||||
// RemovePlayerAsync (player list cleanup) and OnPlayerDisconnectedAsync (dispose).
|
||||
await realPlayer.InvokeViewPlugInAsync<ILogoutPlugIn>(p => p.LogoutAsync(LogoutType.CloseGame)).ConfigureAwait(false);
|
||||
|
||||
await realPlayer.DisconnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates and deducts the initial Zen cost for starting an offline player session.
|
||||
/// The cost is based on the first MuHelper cost stage multiplied by the player's total level.
|
||||
/// </summary>
|
||||
/// <param name="player">The player to charge.</param>
|
||||
/// <returns><c>true</c> if the cost was successfully charged or no cost applies; otherwise <c>false</c>.</returns>
|
||||
private bool TryChargeInitialZenCost(Player player)
|
||||
{
|
||||
var initialCost = this.CalculateInitialZenCost(player);
|
||||
|
||||
return initialCost <= 0 || player.TryRemoveMoney(initialCost);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the initial Zen cost for the given player based on the MuHelper configuration
|
||||
/// and the player's combined normal and master level.
|
||||
/// </summary>
|
||||
/// <param name="player">The player for whom to calculate the cost.</param>
|
||||
/// <returns>The Zen amount to charge; 0 if no cost applies.</returns>
|
||||
private int CalculateInitialZenCost(Player player)
|
||||
{
|
||||
var config = player.GameContext.FeaturePlugIns.GetPlugIn<MuHelperFeaturePlugIn>()?.Configuration
|
||||
?? new MuHelperConfiguration();
|
||||
|
||||
var costPerStage = config.CostPerStage.FirstOrDefault();
|
||||
if (costPerStage <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var totalLevel = player.Level + (int)(player.Attributes?[Stats.MasterLevel] ?? 0);
|
||||
|
||||
return costPerStage * totalLevel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to log the player off from the login server so the account slot is freed
|
||||
/// for reconnection while the ghost session is running. Failures are logged and swallowed
|
||||
/// because the offline session can still proceed without this step.
|
||||
/// </summary>
|
||||
/// <param name="player">The player being transitioned to offline.</param>
|
||||
/// <param name="loginName">The account login name.</param>
|
||||
private async ValueTask LogOffFromLoginServerAsync(Player player, string loginName)
|
||||
{
|
||||
if (player.GameContext is not IGameServerContext gsCtx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await gsCtx.LoginServer.LogOffAsync(loginName, gsCtx.Id).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
player.Logger.LogWarning(ex, "Could not log off from login server during offline player start.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the sentinel entry from the active players dictionary and disposes the ghost.
|
||||
/// Used when startup fails before the ghost is fully initialized.
|
||||
/// </summary>
|
||||
/// <param name="loginName">The account login name.</param>
|
||||
/// <param name="sentinel">The ghost player to dispose.</param>
|
||||
private async ValueTask RemoveAndDisposeAsync(string loginName, OfflinePlayer sentinel)
|
||||
{
|
||||
this._activePlayers.TryRemove(loginName, out _);
|
||||
await sentinel.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
240
src/GameLogic/Offline/OfflinePlayerMuHelper.cs
Normal file
240
src/GameLogic/Offline/OfflinePlayerMuHelper.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
// <copyright file="OfflinePlayerMuHelper.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 System.Threading;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side intelligence that drives an <see cref="OfflinePlayer"/> after the real
|
||||
/// client disconnects. Mirrors the C++ <c>CMuHelper::Work()</c> loop including:
|
||||
/// <list type="bullet">
|
||||
/// <item>Basic / conditional / combo skill attack selection</item>
|
||||
/// <item>Buff application (up to 3 configured buff skills)</item>
|
||||
/// <item>Heal / drain-life based on HP %</item>
|
||||
/// <item>Return-to-origin regrouping</item>
|
||||
/// <item>Item pickup (Zen, Jewels, Excellent, Ancient, and named extra items)</item>
|
||||
/// <item>Skill and movement animations broadcast to nearby observers</item>
|
||||
/// <item>Pet control</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class OfflinePlayerMuHelper : AsyncDisposable
|
||||
{
|
||||
private readonly OfflinePlayer _player;
|
||||
|
||||
private readonly CombatHandler _combatHandler;
|
||||
private readonly BuffHandler _buffHandler;
|
||||
private readonly ItemPickupHandler _itemPickupHandler;
|
||||
private readonly MovementHandler _movementHandler;
|
||||
private readonly RepairHandler _repairHandler;
|
||||
private readonly ZenConsumptionHandler _zenHandler;
|
||||
private readonly HealingHandler _healingHandler;
|
||||
private readonly PetHandler _petHandler;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly EventHandler<DeathInformation> _deathHandler;
|
||||
private readonly PeriodicTimer _timer = new(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
private Task? _loopTask;
|
||||
|
||||
private bool _isDead;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OfflinePlayerMuHelper"/> class.
|
||||
/// </summary>
|
||||
/// <param name="player">The offline player.</param>
|
||||
public OfflinePlayerMuHelper(OfflinePlayer player)
|
||||
{
|
||||
this._player = player;
|
||||
var originalPosition = player.Position;
|
||||
var config = player.MuHelperSettings;
|
||||
|
||||
this._buffHandler = new BuffHandler(player, config);
|
||||
this._healingHandler = new HealingHandler(player, config);
|
||||
this._itemPickupHandler = new ItemPickupHandler(player, config);
|
||||
this._movementHandler = new MovementHandler(player, config, originalPosition);
|
||||
this._combatHandler = new CombatHandler(player, config, this._movementHandler, originalPosition);
|
||||
this._repairHandler = new RepairHandler(player, config);
|
||||
this._zenHandler = new ZenConsumptionHandler(player);
|
||||
this._petHandler = new PetHandler(player, config);
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
this._player.Logger.LogDebug("Offline player started for {CharacterName} without a valid MU Helper configuration.", this._player.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._player.Logger.LogDebug("Offline player configuration for {CharacterName}: MuHelperSettings={Settings}.", this._player.Name, config);
|
||||
}
|
||||
|
||||
this._deathHandler = (_, e) => this.OnPlayerDied(e);
|
||||
this._player.Died += this._deathHandler;
|
||||
}
|
||||
|
||||
/// <summary>Starts the AI loop and a separate pet AI.</summary>
|
||||
public void Start()
|
||||
{
|
||||
_ = this._petHandler.InitializeAsync();
|
||||
this._loopTask = this.RunLoopAsync(this._cts.Token);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask DisposeAsyncCore()
|
||||
{
|
||||
this._timer.Dispose();
|
||||
await this._cts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (this._loopTask is { } loopTask)
|
||||
{
|
||||
try
|
||||
{
|
||||
await loopTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
// Expected during shutdown.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._player.Logger.LogError(ex, "Error in offline player helper loop task for {AccountLoginName}.", this._player.AccountLoginName);
|
||||
}
|
||||
}
|
||||
|
||||
await this._petHandler.StopAsync().ConfigureAwait(false);
|
||||
await base.DisposeAsyncCore().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this._player.Died -= this._deathHandler;
|
||||
this._cts.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private void OnPlayerDied(DeathInformation e)
|
||||
{
|
||||
this._player.Logger.LogDebug("Offline player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName);
|
||||
this._isDead = true;
|
||||
}
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (await this._timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await this.SafeTickAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SafeTickAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.TickAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// expected during shutdown
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._player.Logger.LogError(ex, "Error in offline player helper tick for {AccountLoginName}.", this._player.AccountLoginName);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask TickAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (await this.HandleDeathAsync().ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._player.PlayerState.CurrentState != PlayerState.EnteredWorld)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await this._zenHandler.DeductZenAsync().ConfigureAwait(false))
|
||||
{
|
||||
if (this._player.Account?.LoginName is { } loginName)
|
||||
{
|
||||
await this._player.GameContext.OfflinePlayerManager.StopAsync(loginName).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this._repairHandler.PerformRepairsAsync().ConfigureAwait(false);
|
||||
await this._petHandler.CheckPetDurabilityAsync().ConfigureAwait(false);
|
||||
|
||||
if (this.IsOnSkillCooldown())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// CMuHelper::Work() order: Buff → RecoverHealth → ObtainItem → Regroup → Attack.
|
||||
if (!await this._buffHandler.PerformBuffsAsync().ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await this._healingHandler.PerformHealthRecoveryAsync().ConfigureAwait(false);
|
||||
await this._combatHandler.PerformDrainLifeRecoveryAsync().ConfigureAwait(false);
|
||||
await this._itemPickupHandler.PickupItemsAsync().ConfigureAwait(false);
|
||||
|
||||
if (this._player.IsWalking)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await this._movementHandler.RegroupAsync().ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await this._combatHandler.PerformAttackAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask<bool> HandleDeathAsync()
|
||||
{
|
||||
if (this._isDead)
|
||||
{
|
||||
if (!this._player.IsAlive)
|
||||
{
|
||||
// Player hasn't respawned yet, skip the tick and check again on the next interval.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._player.Account?.LoginName is { } loginName)
|
||||
{
|
||||
this._player.Logger.LogInformation("Offline player died and successfully respawned. Stopping session for {0}.", loginName);
|
||||
await this._player.GameContext.OfflinePlayerManager.StopAsync(loginName).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsOnSkillCooldown()
|
||||
{
|
||||
if (this._combatHandler.SkillCooldownTicks > 0)
|
||||
{
|
||||
this._combatHandler.DecrementCooldown();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
17
src/GameLogic/Offline/OfflineRespawnPlugIn.cs
Normal file
17
src/GameLogic/Offline/OfflineRespawnPlugIn.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
// <copyright file="OfflineRespawnPlugIn.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 System.Threading.Tasks;
|
||||
using MUnique.OpenMU.GameLogic.Views.World;
|
||||
|
||||
/// <summary>
|
||||
/// Simulates a respawn response for an offline character.
|
||||
/// </summary>
|
||||
internal sealed class OfflineRespawnPlugIn : IRespawnAfterDeathPlugIn
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public ValueTask RespawnAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
45
src/GameLogic/Offline/OfflineViewPlugInContainer.cs
Normal file
45
src/GameLogic/Offline/OfflineViewPlugInContainer.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
// <copyright file="OfflineViewPlugInContainer.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.GameLogic.Views;
|
||||
using MUnique.OpenMU.GameLogic.Views.World;
|
||||
using MUnique.OpenMU.PlugIns;
|
||||
|
||||
/// <summary>
|
||||
/// A plugin container for the <see cref="OfflinePlayer"/>, providing stub implementations
|
||||
/// of client-facing views necessary for successful safe-zone respawns.
|
||||
/// </summary>
|
||||
internal sealed class OfflineViewPlugInContainer : ICustomPlugInContainer<IViewPlugIn>
|
||||
{
|
||||
private readonly IRespawnAfterDeathPlugIn _respawnPlugIn = new OfflineRespawnPlugIn();
|
||||
private readonly IMapChangePlugIn _mapChangePlugIn;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OfflineViewPlugInContainer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="player">The offline player.</param>
|
||||
public OfflineViewPlugInContainer(OfflinePlayer player)
|
||||
{
|
||||
this._mapChangePlugIn = new OfflineMapChangePlugIn(player);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public T? GetPlugIn<T>()
|
||||
where T : class, IViewPlugIn
|
||||
{
|
||||
if (typeof(T) == typeof(IRespawnAfterDeathPlugIn))
|
||||
{
|
||||
return (T)this._respawnPlugIn;
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(IMapChangePlugIn))
|
||||
{
|
||||
return (T)this._mapChangePlugIn;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
116
src/GameLogic/Offline/PetHandler.cs
Normal file
116
src/GameLogic/Offline/PetHandler.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
// <copyright file="PetHandler.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.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.Pet;
|
||||
using MUnique.OpenMU.GameLogic.PlayerActions.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Handles pet behavior initialization and management for the offline player.
|
||||
/// </summary>
|
||||
internal sealed class PetHandler
|
||||
{
|
||||
private readonly OfflinePlayer _player;
|
||||
private readonly IMuHelperSettings? _config;
|
||||
private readonly IPetCommandManager? _petCommandManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PetHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="player">The offline player.</param>
|
||||
/// <param name="config">The MU Helper configuration.</param>
|
||||
/// <param name="petCommandManager">Optional pet command manager for testing.</param>
|
||||
public PetHandler(OfflinePlayer player, IMuHelperSettings? config, IPetCommandManager? petCommandManager = null)
|
||||
{
|
||||
this._player = player;
|
||||
this._config = config;
|
||||
this._petCommandManager = petCommandManager;
|
||||
}
|
||||
|
||||
private IPetCommandManager? PetCommandManager => this._petCommandManager ?? this._player.PetCommandManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the dark raven behavior if configured.
|
||||
/// The raven runs its own internal attack loop independently of the player's tick.
|
||||
/// </summary>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.InitializeDarkRavenAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// expected during shutdown
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._player.Logger.LogError(ex, "Error initializing pet for character {CharacterName}.", this._player.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the pet durability and sets the pet to idle if it has run out.
|
||||
/// </summary>
|
||||
public async ValueTask CheckPetDurabilityAsync()
|
||||
{
|
||||
if (this.PetCommandManager is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (this._player.Inventory?.GetItem(InventoryConstants.PetSlot) is { Durability: 0 })
|
||||
{
|
||||
await this.PetCommandManager.SetBehaviourAsync(PetBehaviour.Idle, null).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// expected during shutdown
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._player.Logger.LogError(ex, "Error checking pet durability for {AccountLoginName}.", this._player.AccountLoginName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the pet behavior.
|
||||
/// </summary>
|
||||
public async ValueTask StopAsync()
|
||||
{
|
||||
if (this.PetCommandManager is { } petCommandManager)
|
||||
{
|
||||
try
|
||||
{
|
||||
await petCommandManager.SetBehaviourAsync(PetBehaviour.Idle, null).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._player.Logger.LogError(ex, "Error stopping pet for {AccountLoginName}.", this._player.AccountLoginName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask InitializeDarkRavenAsync()
|
||||
{
|
||||
if (this._config is not { UseDarkRaven: true } || this.PetCommandManager is not { } petCommandManager)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var behaviour = this._config.DarkRavenMode switch
|
||||
{
|
||||
1 => PetBehaviour.AttackRandom,
|
||||
2 => PetBehaviour.AttackWithOwner,
|
||||
_ => PetBehaviour.Idle,
|
||||
};
|
||||
|
||||
await petCommandManager.SetBehaviourAsync(behaviour, null).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
88
src/GameLogic/Offline/RepairHandler.cs
Normal file
88
src/GameLogic/Offline/RepairHandler.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
// <copyright file="RepairHandler.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.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.PlayerActions.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Handles auto-repair of equipped items for the offline player.
|
||||
/// </summary>
|
||||
internal sealed class RepairHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// The durability health threshold (inclusive, in percent) below which a repair is triggered.
|
||||
/// Mirrors the client's <c>DEFAULT_DURABILITY_THRESHOLD</c> constant in MuHelper.cpp.
|
||||
/// </summary>
|
||||
private const int DurabilityRepairThresholdPercent = 50;
|
||||
|
||||
private readonly Player _player;
|
||||
private readonly IMuHelperSettings? _config;
|
||||
private readonly ItemRepairAction _repairAction = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RepairHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="player">The player.</param>
|
||||
/// <param name="config">The MU Helper configuration.</param>
|
||||
public RepairHandler(Player player, IMuHelperSettings? config)
|
||||
{
|
||||
this._player = player;
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs repairs on equipped items if the configuration allows it
|
||||
/// and the item's durability is at or below <see cref="DurabilityRepairThresholdPercent"/>%.
|
||||
/// </summary>
|
||||
public async ValueTask PerformRepairsAsync()
|
||||
{
|
||||
if (this._config is not { RepairItem: true })
|
||||
{
|
||||
this._player.Logger.LogDebug("Auto-repair is disabled by MU Helper configuration for character {CharacterName}.", this._player.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
for (byte i = InventoryConstants.FirstEquippableItemSlotIndex;
|
||||
i <= InventoryConstants.LastEquippableItemSlotIndex;
|
||||
i++)
|
||||
{
|
||||
if (i == InventoryConstants.PetSlot)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var item = this._player.Inventory?.GetItem(i);
|
||||
if (item is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!NeedsDurabilityRepair(item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await this._repairAction.RepairItemAsync(this._player, i).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the item's durability health is at or below
|
||||
/// <see cref="DurabilityRepairThresholdPercent"/>%, using ceiling-integer arithmetic
|
||||
/// to match the client formula: <c>iHealth = (durability * 100 + max - 1) / max</c>.
|
||||
/// </summary>
|
||||
private static bool NeedsDurabilityRepair(Item item)
|
||||
{
|
||||
var max = item.GetMaximumDurabilityOfOnePiece();
|
||||
if (max == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var durabilityHealthPercent = ((int)item.Durability * 100 + max - 1) / max;
|
||||
return durabilityHealthPercent <= DurabilityRepairThresholdPercent;
|
||||
}
|
||||
}
|
||||
61
src/GameLogic/Offline/ZenConsumptionHandler.cs
Normal file
61
src/GameLogic/Offline/ZenConsumptionHandler.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
// <copyright file="ZenConsumptionHandler.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.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.Views.MuHelper;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the periodic Zen consumption for the offline player.
|
||||
/// </summary>
|
||||
internal sealed class ZenConsumptionHandler
|
||||
{
|
||||
private readonly OfflinePlayer _player;
|
||||
private readonly MuHelperConfiguration _configuration;
|
||||
private DateTime _lastPayTimestamp;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ZenConsumptionHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="player">The player.</param>
|
||||
public ZenConsumptionHandler(OfflinePlayer player)
|
||||
{
|
||||
this._player = player;
|
||||
this._configuration = player.GameContext.FeaturePlugIns.GetPlugIn<MuHelperFeaturePlugIn>()?.Configuration
|
||||
?? new MuHelperConfiguration();
|
||||
this._lastPayTimestamp = player.StartTimestamp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deducts Zen from the player based on the server configuration and the player's level.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> if the player can continue; <c>false</c> if insufficient Zen.</returns>
|
||||
public async ValueTask<bool> DeductZenAsync()
|
||||
{
|
||||
if (DateTime.UtcNow - this._lastPayTimestamp < this._configuration.PayInterval)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var amount = MuHelperZenCostCalculator.Calculate(this._player, this._configuration, this._player.StartTimestamp);
|
||||
|
||||
if (amount > 0 && this._player.TryRemoveMoney(amount))
|
||||
{
|
||||
this._lastPayTimestamp = DateTime.UtcNow;
|
||||
await this._player
|
||||
.InvokeViewPlugInAsync<IMuHelperStatusUpdatePlugIn>(p => p.ConsumeMoneyAsync((uint)amount))
|
||||
.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (amount > 0)
|
||||
{
|
||||
this._player.Logger.LogDebug("Insufficient Zen for {CharacterName}.", this._player.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user