baseline: OpenMU upstream b5a0961 (fresh source)

This commit is contained in:
Acentech Dev
2026-07-14 19:00:35 +03:00
parent 450402e47d
commit 36fc125d5c
11968 changed files with 748705 additions and 0 deletions

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

View 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; }
}
}

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

View 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;
}
}

View 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;
}
}

View 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;
}

View 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();
}
}

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

View 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;
}
}

View 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;
}

View 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;
}
}

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

View 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;
}
}

View 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;
}
}