diff --git a/src/AttributeSystem/StatAttribute.cs b/src/AttributeSystem/StatAttribute.cs index 4d53cdbff..3f5982035 100644 --- a/src/AttributeSystem/StatAttribute.cs +++ b/src/AttributeSystem/StatAttribute.cs @@ -40,7 +40,7 @@ public StatAttribute(AttributeDefinition definition, float baseValue) { get { - if (this.Definition.MaximumValue.HasValue) + if (this.Definition?.MaximumValue.HasValue is true) { return Math.Min(this.Definition.MaximumValue.Value, this._statValue); } diff --git a/src/GameLogic/DroppedItem.cs b/src/GameLogic/DroppedItem.cs index d805d7f3f..f3574d7db 100644 --- a/src/GameLogic/DroppedItem.cs +++ b/src/GameLogic/DroppedItem.cs @@ -227,7 +227,7 @@ private async ValueTask TryPickUpAsync(Player player) this._availableToPick = false; } - player.Logger.LogInformation("Item '{0}' was picked up by player '{1}' and added to his inventory.", this, player); + player.Logger.LogDebug("Item '{0}' was picked up by player '{1}' and added to his inventory.", this, player); await this.DisposeAsync().ConfigureAwait(false); return true; @@ -260,7 +260,7 @@ private async ValueTask TryStackOnItemAsync(Player player, Item stackTarge private async ValueTask DeleteItemAsync(Player player) { - player.Logger.LogInformation("Item '{0}' which was dropped by player '{1}' is getting deleted.", this, player); + player.Logger.LogDebug("Item '{0}' which was dropped by player '{1}' is getting deleted.", this, player); if (!this._wasItemPersisted) { return; diff --git a/src/GameLogic/DroppedMoney.cs b/src/GameLogic/DroppedMoney.cs index 157da759a..bbe2ec0e0 100644 --- a/src/GameLogic/DroppedMoney.cs +++ b/src/GameLogic/DroppedMoney.cs @@ -114,11 +114,11 @@ public async ValueTask TryPickUpByAsync(Player player) if (clampMoneyOnPickup && amountToAdd < this.Amount) { - player.Logger.LogInformation("Money '{0}' was partially picked up by player '{1}' - added {2} out of {3} (player at max limit).", this, player, amountToAdd, this.Amount); + player.Logger.LogDebug("Money '{0}' was partially picked up by player '{1}' - added {2} out of {3} (player at max limit).", this, player, amountToAdd, this.Amount); } else { - player.Logger.LogInformation("Money '{0}' was picked up by player '{1}' and added to his inventory.", this, player); + player.Logger.LogDebug("Money '{0}' was picked up by player '{1}' and added to his inventory.", this, player); } await this.DisposeAsync().ConfigureAwait(false); diff --git a/src/GameLogic/GameContext.cs b/src/GameLogic/GameContext.cs index 96ab17d35..f0e405356 100644 --- a/src/GameLogic/GameContext.cs +++ b/src/GameLogic/GameContext.cs @@ -136,6 +136,9 @@ public GameContext(GameConfiguration configuration, IPersistenceContextProvider /// public FeaturePlugInContainer FeaturePlugIns { get; } + /// + public OfflineLeveling.OfflineLevelingManager OfflineLevelingManager { get; } = new(); + /// public IItemPowerUpFactory ItemPowerUpFactory { get; } @@ -145,7 +148,7 @@ public GameContext(GameConfiguration configuration, IPersistenceContextProvider /// /// Gets the players by character name dictionary. /// - public IDictionary PlayersByCharacterName { get; } = new ConcurrentDictionary(); + public ConcurrentDictionary PlayersByCharacterName { get; } = new(StringComparer.OrdinalIgnoreCase); /// public DuelRoomManager DuelRoomManager { get; set; } @@ -338,7 +341,7 @@ public virtual async ValueTask RemovePlayerAsync(Player player) PlayerCounter.Add(-1); if (player.SelectedCharacter != null) { - this.PlayersByCharacterName.Remove(player.SelectedCharacter.Name); + this.PlayersByCharacterName.TryRemove(player.SelectedCharacter.Name, out _); } player.CurrentMap?.RemoveAsync(player); @@ -504,13 +507,13 @@ await this.ForEachPlayerAsync(player => private ValueTask PlayerEnteredWorldAsync(Player player) { - this.PlayersByCharacterName.Add(player.SelectedCharacter!.Name, player); + this.PlayersByCharacterName.TryAdd(player.SelectedCharacter!.Name, player); return ValueTask.CompletedTask; } private ValueTask PlayerLeftWorldAsync(Player player) { - this.PlayersByCharacterName.Remove(player.SelectedCharacter!.Name); + this.PlayersByCharacterName.TryRemove(player.SelectedCharacter!.Name, out _); return ValueTask.CompletedTask; } } \ No newline at end of file diff --git a/src/GameLogic/GameMap.cs b/src/GameLogic/GameMap.cs index ac886485b..159afdcdf 100644 --- a/src/GameLogic/GameMap.cs +++ b/src/GameLogic/GameMap.cs @@ -114,6 +114,19 @@ public IList GetAttackablesInRange(Point point, int range) return this._areaOfInterestManager.GetInRange(point, range).OfType().ToList(); } + /// + /// Gets all dropped items and money within the specified range of a point. + /// + /// The coordinates. + /// The range. + /// Dropped items and money in range. + public IList GetDropsInRange(Point point, int range) + { + return this._areaOfInterestManager.GetInRange(point, range) + .Where(l => l is DroppedItem or DroppedMoney) + .ToList(); + } + /// /// Gets the drop by id. /// diff --git a/src/GameLogic/IGameContext.cs b/src/GameLogic/IGameContext.cs index a6104002b..f42b66c5c 100644 --- a/src/GameLogic/IGameContext.cs +++ b/src/GameLogic/IGameContext.cs @@ -77,6 +77,11 @@ public interface IGameContext /// FeaturePlugInContainer FeaturePlugIns { get; } + /// + /// Gets the offline leveling manager which tracks active ghost players. + /// + OfflineLeveling.OfflineLevelingManager OfflineLevelingManager { get; } + /// /// Gets the players count of the game. /// diff --git a/src/GameLogic/InventoryStorage.cs b/src/GameLogic/InventoryStorage.cs index 391da9ef9..2ad8c858a 100644 --- a/src/GameLogic/InventoryStorage.cs +++ b/src/GameLogic/InventoryStorage.cs @@ -29,7 +29,7 @@ public InventoryStorage(Player player, IGameContext context) GetInventorySize(0), EquippableSlotsCount, 0, - new ItemStorageAdapter(player.SelectedCharacter?.Inventory ?? throw Error.NotInitializedProperty(player, "SelectedCharacter.Inventory"), FirstEquippableItemSlotIndex, player.GetInventorySize())) + new ItemStorageAdapter(player.SelectedCharacter?.Inventory ?? throw Error.NotInitializedProperty(player, "SelectedCharacter.Inventory"), FirstEquippableItemSlotIndex, player.InventorySize)) { this._player = player; this.EquippedItemsChanged += async eventArgs => await this.UpdateItemsOnChangeAsync(eventArgs.Item, eventArgs.IsEquipped).ConfigureAwait(false); diff --git a/src/GameLogic/MuHelper/IMuHelperSettings.cs b/src/GameLogic/MuHelper/IMuHelperSettings.cs new file mode 100644 index 000000000..006df0bdc --- /dev/null +++ b/src/GameLogic/MuHelper/IMuHelperSettings.cs @@ -0,0 +1,143 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MuHelper; + +/// +/// The Mu Helper player settings. +/// +public interface IMuHelperSettings +{ + /// Gets the always-active basic attack skill ID (0 = no skill, use normal attack). + int BasicSkillId { get; } + + /// Gets the first conditional skill ID. + int ActivationSkill1Id { get; } + + /// Gets the second conditional skill ID. + int ActivationSkill2Id { get; } + + /// Gets the timer interval (seconds) for ActivationSkill1 when Skill1Delay is set. + int DelayMinSkill1 { get; } + + /// Gets the timer interval (seconds) for ActivationSkill2 when Skill2Delay is set. + int DelayMinSkill2 { get; } + + /// Gets a value indicating whether to use timer for the skill 1. + bool Skill1UseTimer { get; } + + /// Gets a value indicating whether to use condition for the skill 1. + bool Skill1UseCondition { get; } + + /// Gets a value indicating whether to use the precondition for Skill1 (false = nearby, true = attacking). + bool Skill1ConditionAttacking { get; } + + /// Gets the mob count threshold for Skill1 condition: 0=2+, 1=3+, 2=4+, 3=5+. + int Skill1SubCondition { get; } + + /// Gets a value indicating whether to use timer for the skill 2. + bool Skill2UseTimer { get; } + + /// Gets a value indicating whether to use condition for the skill 2. + bool Skill2UseCondition { get; } + + /// Gets a value indicating whether to use the precondition for Skill2 (false = nearby, true = attacking). + bool Skill2ConditionAttacking { get; } + + /// Gets the mob count threshold for Skill2 condition. + int Skill2SubCondition { get; } + + /// Gets a value indicating whether to use combo mode. + bool UseCombo { get; } + + /// Gets the hunting range nibble (0-15); multiply to get tile distance. + int HuntingRange { get; } + + /// Gets the max seconds away from original position before regrouping. + int MaxSecondsAway { get; } + + /// Gets a value indicating whether to counter-attack enemies that attack from long range. + bool LongRangeCounterAttack { get; } + + /// Gets a value indicating whether to return to original spawn position when away too long. + bool ReturnToOriginalPosition { get; } + + /// Gets the Buff 0 skill id. + int BuffSkill0Id { get; } + + /// Gets the Buff 1 skill id. + int BuffSkill1Id { get; } + + /// Gets the Buff 2 skill id. + int BuffSkill2Id { get; } + + /// Gets a value indicating whether apply buffs based on duration (i.e. when the buff expires). + bool BuffOnDuration { get; } + + /// Gets a value indicating whether apply buff duration logic to party members too. + bool BuffDurationForParty { get; } + + /// Gets the buff cast interval in seconds (0 = disabled). + int BuffCastIntervalSeconds { get; } + + /// Gets a value indicating whether to use auto-heal. + bool AutoHeal { get; } + + /// Gets the self-heal threshold (% HP, e.g. 30 means heal when below 30%). + int HealThresholdPercent { get; } + + /// Gets a value indicating whether to use drain life. + bool UseDrainLife { get; } + + /// Gets a value indicating whether to use healing potion. + bool UseHealPotion { get; } + + /// Gets the potion use threshold (% HP). + int PotionThresholdPercent { get; } + + /// Gets a value indicating whether to support party. + bool SupportParty { get; } + + /// Gets a value indicating whether to auto heal party. + bool AutoHealParty { get; } + + /// Gets the party member HP threshold (%) below which healing is applied. + int HealPartyThresholdPercent { get; } + + /// Gets a value indicating whether to use dark raven. + bool UseDarkRaven { get; } + + /// Gets the dark raven mode 0 = cease, 1 = auto-attack, 2 = attack with owner. + int DarkRavenMode { get; } + + /// Gets the obtain range. + int ObtainRange { get; } + + /// Gets a value indicating whether pickup all items. + bool PickAllItems { get; } + + /// Gets a value indicating whether pickup selected items. + bool PickSelectItems { get; } + + /// Gets a value indicating whether pickup jewels. + bool PickJewel { get; } + + /// Gets a value indicating whether pickup zen. + bool PickZen { get; } + + /// Gets a value indicating whether pickup ancient items. + bool PickAncient { get; } + + /// Gets a value indicating whether pickup excellent items. + bool PickExcellent { get; } + + /// Gets a value indicating whether pickup extra items. + bool PickExtraItems { get; } + + /// Gets the extra item names. Up to 12 item name substrings; pick any dropped item whose name contains one of these. + IReadOnlyList ExtraItemNames { get; } + + /// Gets a value indicating whether to repair items. + bool RepairItem { get; } +} diff --git a/src/GameLogic/MuHelper/MuHelper.cs b/src/GameLogic/MuHelper/MuHelper.cs index 0e1232db8..87d9886e0 100644 --- a/src/GameLogic/MuHelper/MuHelper.cs +++ b/src/GameLogic/MuHelper/MuHelper.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -82,6 +82,7 @@ public async ValueTask TryStartAsync() await this._player.InvokeViewPlugInAsync(p => p.ConsumeMoneyAsync((uint)requiredMoney)).ConfigureAwait(false); this._player.Attributes?.AddElement(ActiveElement, Stats.IsMuHelperActive); + this._stopCts?.Dispose(); this._stopCts = new CancellationTokenSource(); var cts = this._stopCts.Token; this._runTask = this.RunLoopAsync(cts); @@ -104,7 +105,14 @@ public async ValueTask StopAsync() this._player.Attributes?.RemoveElement(ActiveElement, Stats.IsMuHelperActive); await stopCts.CancelAsync().ConfigureAwait(false); - await runTask.ConfigureAwait(false); + + // Skip awaiting the loop task if we are currently executing inside it + // (i.e. CollectAsync triggered StopAsync), to avoid a self-deadlock. + if (runTask.Id != Task.CurrentId) + { + await runTask.ConfigureAwait(false); + } + this._runTask = null; stopCts.Dispose(); this._stopCts = null; @@ -113,7 +121,7 @@ public async ValueTask StopAsync() } catch (Exception ex) { - this._player.Logger.LogWarning(ex, "Exception during stopping the mu helper: {0}", ex); + this._player.Logger.LogWarning(ex, "Exception during stopping the mu helper for {CharacterName}: {Error}", this._player.Name, ex.Message); } } @@ -123,26 +131,19 @@ protected override async ValueTask DisposeAsyncCore() await this.StopAsync().ConfigureAwait(false); } - /// - /// Calculates the required money for the next pay interval. - /// - /// the required money. - private int CalculateRequiredMoney() - { - var currentStage = (int)(DateTime.UtcNow.Subtract(this._startTimestamp) / this._configuration.StageInterval); - currentStage = Math.Max(0, currentStage); - currentStage = Math.Min(this._configuration.CostPerStage.Count - 1, currentStage); - - var costMultiplier = this._configuration.CostPerStage[currentStage]; - var totalLevel = (int)(this._player.Level + this._player.Attributes?[Stats.MasterLevel] ?? 0); - - return costMultiplier * totalLevel; - } + private int CalculateRequiredMoney() => + MuHelperZenCostCalculator.Calculate(this._player, this._configuration, this._startTimestamp); private async Task RunLoopAsync(CancellationToken cancellationToken) { try { + if (this._configuration.PayInterval <= TimeSpan.Zero) + { + this._player.Logger.LogDebug("MU Helper PayInterval is {PayInterval}. Stopping for {CharacterName}.", this._configuration.PayInterval, this._player.Name); + return; + } + using var timer = new PeriodicTimer(this._configuration.PayInterval); while (true) { @@ -153,7 +154,7 @@ private async Task RunLoopAsync(CancellationToken cancellationToken) } catch (OperationCanceledException) { - // we expect that ... + // expected when StopAsync cancels the token. } catch (Exception ex) { diff --git a/src/GameLogic/MuHelper/MuHelperZenCostCalculator.cs b/src/GameLogic/MuHelper/MuHelperZenCostCalculator.cs new file mode 100644 index 000000000..3e886bbb2 --- /dev/null +++ b/src/GameLogic/MuHelper/MuHelperZenCostCalculator.cs @@ -0,0 +1,38 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MuHelper; + +using MUnique.OpenMU.GameLogic.Attributes; + +/// +/// Calculates the Zen cost for the MU Helper and offline leveling based on the +/// player's total level and the elapsed stage derived from the server configuration. +/// +public static class MuHelperZenCostCalculator +{ + /// + /// Calculates the Zen amount to charge for the current pay interval. + /// + /// The player being charged. + /// The MU helper server configuration. + /// The timestamp when the session started, used to determine the current cost stage. + /// The Zen amount to deduct; 0 if the configuration has no cost entries. + public static int Calculate(Player player, MuHelperConfiguration configuration, DateTime startTimestamp) + { + if (configuration.CostPerStage.Count == 0) + { + return 0; + } + + var elapsed = DateTime.UtcNow - startTimestamp; + var currentStage = (int)(elapsed / configuration.StageInterval); + currentStage = Math.Clamp(currentStage, 0, configuration.CostPerStage.Count - 1); + + var costMultiplier = configuration.CostPerStage[currentStage]; + var totalLevel = player.Level + (int)(player.Attributes?[Stats.MasterLevel] ?? 0); + + return costMultiplier * totalLevel; + } +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/BuffHandler.cs b/src/GameLogic/OfflineLeveling/BuffHandler.cs new file mode 100644 index 000000000..8e07e490f --- /dev/null +++ b/src/GameLogic/OfflineLeveling/BuffHandler.cs @@ -0,0 +1,183 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.GameLogic.Views.World; +using MUnique.OpenMU.Interfaces; + +/// +/// Handles buff application and management for the offline leveling player. +/// +public sealed class BuffHandler +{ + private const int BuffSlotCount = 3; + + private readonly OfflineLevelingPlayer _player; + private readonly IMuHelperSettings? _config; + + private int _buffSkillIndex; + private bool _buffTimerTriggered; + private DateTime? _nextPeriodicBuffTime; + + /// + /// Initializes a new instance of the class. + /// + /// The offline leveling player. + /// The MU Helper configuration. + public BuffHandler(OfflineLevelingPlayer player, IMuHelperSettings? config) + { + this._player = player; + this._config = config; + } + + /// + /// Checks and applies buffs if configured and needed. + /// + /// True, if the loop can continue to the next step; False, if a buff was cast and the tick should end. + public async ValueTask PerformBuffsAsync() + { + if (this._config is null) + { + return true; + } + + var buffIds = this.GetConfiguredBuffIds(); + if (buffIds.Count == 0) + { + return true; + } + + this.UpdatePeriodicBuffTimer(); + + for (int i = 0; i < BuffSlotCount; i++) + { + int buffId = buffIds[this._buffSkillIndex]; + if (buffId == 0) + { + this.MoveNextSlot(); + continue; + } + + var skillEntry = this._player.SkillList?.GetSkill((ushort)buffId); + if (skillEntry?.Skill?.MagicEffectDef is null) + { + this.MoveNextSlot(); + continue; + } + + // Try to buff self + if (await this.TryBuffTargetAsync(this._player, skillEntry, false).ConfigureAwait(false)) + { + return false; + } + + // Try to buff party members + if (await this.TryBuffPartyMembersAsync(skillEntry).ConfigureAwait(false)) + { + return false; + } + + // Move to next slot if no one needed this buff + this.MoveNextSlot(); + } + + return true; + } + + private List GetConfiguredBuffIds() + { + if (this._config is null) + { + return []; + } + + return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id]; + } + + private void UpdatePeriodicBuffTimer() + { + if (this._config is null || this._config.BuffOnDuration || 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); + } + } + + private async ValueTask TryBuffPartyMembersAsync(SkillEntry skillEntry) + { + if (this._config is not { SupportParty: true } || this._player.Party is not { } party) + { + return false; + } + + foreach (var member in party.PartyList.OfType()) + { + if (member == (IAttackable)this._player) + { + continue; + } + + if (await this.TryBuffTargetAsync(member, skillEntry, true).ConfigureAwait(false)) + { + return true; + } + } + + return false; + } + + private void MoveNextSlot() + { + this._buffSkillIndex = (this._buffSkillIndex + 1) % BuffSlotCount; + if (this._buffSkillIndex == 0) + { + this._buffTimerTriggered = false; + } + } + + private async ValueTask TryBuffTargetAsync(IAttackable target, SkillEntry skillEntry, bool isPartyMember) + { + if (skillEntry.Skill?.MagicEffectDef is not { } effectDef) + { + return false; + } + + if (!target.IsActive() || !this._player.IsInRange(target, 8)) + { + return false; + } + + bool alreadyActive = target.MagicEffectList.ActiveEffects.Values + .Any(e => e.Definition == effectDef); + + bool shouldApply; + if (isPartyMember) + { + shouldApply = this._config!.BuffDurationForParty ? !alreadyActive : this._buffTimerTriggered; + } + else + { + shouldApply = !alreadyActive || this._buffTimerTriggered; + } + + if (shouldApply) + { + await target.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false); + this.MoveNextSlot(); + return true; + } + + return false; + } +} diff --git a/src/GameLogic/OfflineLeveling/CombatHandler.cs b/src/GameLogic/OfflineLeveling/CombatHandler.cs new file mode 100644 index 000000000..0fc610c32 --- /dev/null +++ b/src/GameLogic/OfflineLeveling/CombatHandler.cs @@ -0,0 +1,497 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +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.NPC; +using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions; +using MUnique.OpenMU.GameLogic.PlayerActions.Skills; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.Views.World; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Pathfinding; + +/// +/// Handles combat logic including target selection, attacks, combo attacks, and skill usage. +/// +public sealed class CombatHandler +{ + private const byte DefaultRange = 1; + private const int ComboFinisherDelayTicks = 3; + private const int InterSkillDelayTicks = 1; + + private const short DrainLifeBaseSkillId = 214; + private const short DrainLifeStrengthenerSkillId = 458; + private const short DrainLifeMasterySkillId = 462; + + private readonly OfflineLevelingPlayer _player; + private readonly IMuHelperSettings? _config; + private readonly MovementHandler _movementHandler; + private readonly Point _originPosition; + + private IAttackable? _currentTarget; + private int _nearbyMonsterCount; + private int _currentComboStep; + private int _skillCooldownTicks; + + /// + /// Initializes a new instance of the class. + /// + /// The offline leveling player. + /// The MU helper settings. + /// The movement handler. + /// The original position to hunt around. + public CombatHandler(OfflineLevelingPlayer player, IMuHelperSettings? config, MovementHandler movementHandler, Point originPosition) + { + this._player = player; + this._config = config; + this._movementHandler = movementHandler; + this._originPosition = originPosition; + } + + /// + /// Gets the remaining skill cooldown ticks. + /// + public int SkillCooldownTicks => this._skillCooldownTicks; + + /// + /// Gets the hunting range in tiles. + /// + public byte HuntingRange => CalculateHuntingRange(this._config); + + /// + /// Calculates the hunting range in tiles from the specified configuration. + /// + /// The configuration. + /// The hunting range. + public static byte CalculateHuntingRange(IMuHelperSettings? config) + { + if (config is null) + { + return DefaultRange; + } + + return (byte)Math.Max(DefaultRange, config.HuntingRange); + } + + /// + /// Decrements the skill cooldown counter by one tick. + /// + public void DecrementCooldown() + { + if (this._skillCooldownTicks > 0) + { + this._skillCooldownTicks--; + } + } + + /// + /// Performs health recovery through Drain Life attacks if configured. + /// + 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); + } + } + } + } + + /// + /// Performs combat attacks on targets. + /// + /// A value task representing the asynchronous operation. + 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 + { + var skill = this.SelectAttackSkill(); + await this.ExecuteAttackAsync(this._currentTarget, skill, false).ConfigureAwait(false); + } + } + + /// + /// Executes an attack on the specified target. + /// + /// The target. + /// The skill entry. + /// If set to true, it's a combo attack. + public 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; + } + + this._currentTarget ??= this.FindNearestMonster(); + this._nearbyMonsterCount = this.CountMonstersNearby(); + } + + 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( + 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( + 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( + p => p.ShowAreaSkillAnimationAsync(this._player, skill, target.Position, rotationByte), + includeThis: true).ConfigureAwait(false); + + var monstersInRange = this._player.CurrentMap? + .GetAttackablesInRange(target.Position, skill.Range) + .OfType() + .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)skill.Number) + ?? new TargetedSkillDefaultPlugin(); + await strategy.PerformSkillAsync(this._player, target, (ushort)skill.Number).ConfigureAwait(false); + } + + private IAttackable? FindNearestMonster() + { + if (this._player.CurrentMap is not { } map) + { + return null; + } + + return map.GetAttackablesInRange(this._originPosition, this.HuntingRange) + .OfType() + .Where(this.IsMonsterAttackable) + .MinBy(m => m.GetDistanceTo(this._player)); + } + + private int CountMonstersNearby() + { + if (this._player.CurrentMap is not { } map) + { + return 0; + } + + return map.GetAttackablesInRange(this._originPosition, this.HuntingRange) + .OfType() + .Count(this.IsMonsterAttackable); + } + + private SkillEntry? SelectAttackSkill() + { + if (this._config is null) + { + return this.GetAnyOffensiveSkill(); + } + + var s1 = this.EvaluateConditionalSkill( + this._config.ActivationSkill1Id, + this._config.Skill1UseTimer, + this._config.DelayMinSkill1, + this._config.Skill1UseCondition, + this._config.Skill1SubCondition); + if (s1 is not null) + { + return s1; + } + + var s2 = this.EvaluateConditionalSkill( + this._config.ActivationSkill2Id, + this._config.Skill2UseTimer, + this._config.DelayMinSkill2, + this._config.Skill2UseCondition, + this._config.Skill2SubCondition); + if (s2 is not null) + { + return s2; + } + + if (this._config.BasicSkillId > 0) + { + return this._player.SkillList?.GetSkill((ushort)this._config.BasicSkillId); + } + + return this.GetAnyOffensiveSkill(); + } + + private SkillEntry? EvaluateConditionalSkill(int skillId, bool useTimer, int timerInterval, bool useCond, int subCond) + { + if (skillId <= 0) + { + return null; + } + + if (useTimer && timerInterval > 0) + { + var secondsElapsed = (int)(DateTime.UtcNow - this._player.StartTimestamp).TotalSeconds; + if (secondsElapsed > 0 && secondsElapsed % timerInterval == 0) + { + return this._player.SkillList?.GetSkill((ushort)skillId); + } + } + + if (useCond) + { + int threshold = subCond switch + { + 0 => 2, + 1 => 3, + 2 => 4, + 3 => 5, + _ => int.MaxValue, + }; + + if (this._nearbyMonsterCount >= threshold) + { + return this._player.SkillList?.GetSkill((ushort)skillId); + } + } + + return null; + } + + private async ValueTask ExecuteComboAttackAsync() + { + if (this._currentTarget is null) + { + this._currentComboStep = 0; + return; + } + + var ids = this.GetConfiguredComboSkillIds(); + if (ids.Count == 0) + { + await this.ExecuteAttackAsync(this._currentTarget, this.GetAnyOffensiveSkill(), false).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 GetConfiguredComboSkillIds() + { + var ids = new List(); + 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(); + } + } + + return this.GetOffensiveSkills() + .Select(s => (byte)s.Skill!.Range) + .DefaultIfEmpty(DefaultRange) + .Max(); + } + + private IEnumerable GetOffensiveSkills() + { + return this._player.SkillList?.Skills + .Where(s => s.Skill is not null + && s.Skill.SkillType != SkillType.PassiveBoost + && s.Skill.SkillType != SkillType.Buff + && s.Skill.SkillType != SkillType.Regeneration) + ?? []; + } + + private SkillEntry? GetAnyOffensiveSkill() + { + return this.GetOffensiveSkills().FirstOrDefault(); + } + + private SkillEntry? FindDrainLifeSkill() + { + return this._player.SkillList?.Skills.FirstOrDefault(s => + s.Skill is { Number: DrainLifeBaseSkillId or DrainLifeStrengthenerSkillId or DrainLifeMasterySkillId }); + } +} diff --git a/src/GameLogic/OfflineLeveling/HealingHandler.cs b/src/GameLogic/OfflineLeveling/HealingHandler.cs new file mode 100644 index 000000000..3e74ba1cb --- /dev/null +++ b/src/GameLogic/OfflineLeveling/HealingHandler.cs @@ -0,0 +1,147 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +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.Interfaces; + +/// +/// Handles health recovery for the offline leveling player and their party. +/// +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 OfflineLevelingPlayer _player; + private readonly IMuHelperSettings? _config; + + /// + /// Initializes a new instance of the class. + /// + /// The offline leveling player. + /// The MU helper settings. + public HealingHandler(OfflineLevelingPlayer player, IMuHelperSettings? config) + { + this._player = player; + this._config = config; + } + + /// + /// Performs health recovery actions for the player and their party. + /// + /// A value task representing the asynchronous operation. + 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.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()) + { + if (member == this._player) + { + continue; + } + + if (!member.IsActive() || !this._player.IsInRange(member, 8)) + { + continue; + } + + if (this.IsHealthBelowThreshold(member, this._config.HealPartyThresholdPercent)) + { + await this._player.ApplyRegenerationAsync(member, 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); + } +} diff --git a/src/GameLogic/OfflineLeveling/ItemPickupHandler.cs b/src/GameLogic/OfflineLeveling/ItemPickupHandler.cs new file mode 100644 index 000000000..585bdeea2 --- /dev/null +++ b/src/GameLogic/OfflineLeveling/ItemPickupHandler.cs @@ -0,0 +1,118 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.GameLogic.PlayerActions.Items; +using MUnique.OpenMU.Interfaces; + +/// +/// Handles item and zen pickup for the offline leveling player. +/// +public sealed class ItemPickupHandler +{ + private const byte MinPickupRange = 1; + private const byte JewelItemGroup = 14; + + private static readonly PickupItemAction PickupAction = new(); + + private readonly OfflineLevelingPlayer _player; + private readonly IMuHelperSettings? _config; + + /// + /// Initializes a new instance of the class. + /// + /// The offline leveling player. + /// The MU Helper configuration. + public ItemPickupHandler(OfflineLevelingPlayer player, IMuHelperSettings? config) + { + this._player = player; + this._config = config; + } + + /// + /// Scans for and picks up items within configurable range. + /// + 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 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 && item.Definition?.Group == JewelItemGroup) + { + 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; + } +} diff --git a/src/GameLogic/OfflineLeveling/MovementHandler.cs b/src/GameLogic/OfflineLeveling/MovementHandler.cs new file mode 100644 index 000000000..8e33831c6 --- /dev/null +++ b/src/GameLogic/OfflineLeveling/MovementHandler.cs @@ -0,0 +1,134 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.Pathfinding; + +/// +/// Handles movement logic including walking, regrouping, and return-to-origin. +/// +public sealed class MovementHandler +{ + private const byte RegroupDistanceThreshold = 1; + + private readonly OfflineLevelingPlayer _player; + private readonly IMuHelperSettings? _config; + private readonly Point _originPosition; + + private DateTime? _outOfRangeSince; + + /// + /// Initializes a new instance of the class. + /// + /// The offline leveling player. + /// The MU Helper configuration. + /// The original spawn position. + public MovementHandler(OfflineLevelingPlayer player, IMuHelperSettings? config, Point originPosition) + { + this._player = player; + this._config = config; + this._originPosition = originPosition; + } + + /// + /// Gets the hunting range in tiles. + /// + private byte HuntingRange => CombatHandler.CalculateHuntingRange(this._config); + + /// + /// Returns the character to the original position if configured and distance/time thresholds are met. + /// + /// True, if the loop can continue; False, if a regrouping walk was initiated. + public async ValueTask 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; + } + + 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; + } + + /// + /// Moves the player closer to a target within the specified range. + /// + /// The target to move closer to. + /// The range to stop within. + public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range) + { + if (target.IsInRange(this._originPosition, this.HuntingRange)) + { + var walkTarget = this._player.CurrentMap!.Terrain.GetRandomCoordinate(target.Position, range); + await this.WalkToAsync(walkTarget).ConfigureAwait(false); + } + } + + /// + /// Walks the player to the specified target position. + /// + /// The target position to walk to. + /// True if the walk was successful; otherwise, false. + public async ValueTask 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); + } + } +} diff --git a/src/GameLogic/OfflineLeveling/NullViewPlugInContainer.cs b/src/GameLogic/OfflineLeveling/NullViewPlugInContainer.cs new file mode 100644 index 000000000..33d18f8d4 --- /dev/null +++ b/src/GameLogic/OfflineLeveling/NullViewPlugInContainer.cs @@ -0,0 +1,21 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.PlugIns; + +/// +/// A view plugin container that returns null, suppressing all network traffic for players. +/// +internal sealed class NullViewPlugInContainer : ICustomPlugInContainer +{ + /// + public T? GetPlugIn() + where T : class, IViewPlugIn + { + return null; + } +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs new file mode 100644 index 000000000..09dbe0ccc --- /dev/null +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs @@ -0,0 +1,164 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using System.Threading; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.GameLogic.Views.MuHelper; + +/// +/// Server-side AI that drives an ghost after the real +/// client disconnects. Mirrors the C++ CMuHelper::Work() loop including: +/// +/// Basic / conditional / combo skill attack selection +/// Self-buff application (up to 3 configured buff skills) +/// Auto-heal / drain-life based on HP % +/// Return-to-origin regrouping +/// Item pickup (Zen, Jewels, Excellent, Ancient, and named extra items) +/// Skill and movement animations broadcast to nearby observers +/// +/// Party support is not implemented. +/// +public sealed class OfflineLevelingIntelligence : IDisposable +{ + private readonly OfflineLevelingPlayer _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 Timer? _aiTimer; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The offline leveling player. + public OfflineLevelingIntelligence(OfflineLevelingPlayer 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); + + if (config is null) + { + this._player.Logger.LogDebug("Offline leveling started for {CharacterName} without a valid MU Helper configuration.", this._player.Name); + } + else + { + this._player.Logger.LogDebug("Offline leveling configuration for {CharacterName}: MuHelperSettings={Settings}.", this._player.Name, config); + } + } + + /// Starts the 500 ms AI timer. + public void Start() + { + this._aiTimer ??= new Timer( + state => _ = this.SafeTickAsync(), + null, + TimeSpan.FromSeconds(1), + TimeSpan.FromMilliseconds(500)); + } + + /// + public void Dispose() + { + if (this._disposed) + { + return; + } + + this._disposed = true; + this._aiTimer?.Dispose(); + this._aiTimer = null; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100", Justification = "Timer callback — exceptions are caught internally.")] + private async Task SafeTickAsync() + { + try + { + await this.TickAsync().ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // expected during shutdown + } + catch (Exception ex) + { + this._player.Logger.LogError(ex, "Error in offline leveling AI tick for {Name}.", this._player.CharacterName); + } + } + + private async ValueTask TickAsync() + { + if (!this.CanPerformActions()) + { + return; + } + + await this._zenHandler.DeductZenAsync().ConfigureAwait(false); + + await this._repairHandler.PerformRepairsAsync().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 bool CanPerformActions() + { + return this._player.IsAlive && this._player.PlayerState.CurrentState == PlayerState.EnteredWorld; + } + + private bool IsOnSkillCooldown() + { + if (this._combatHandler.SkillCooldownTicks > 0) + { + this._combatHandler.DecrementCooldown(); + return true; + } + + return false; + } +} diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs new file mode 100644 index 000000000..3c842c88d --- /dev/null +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs @@ -0,0 +1,165 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.MuHelper; + +/// +/// Manages active sessions. +/// +public sealed class OfflineLevelingManager +{ + private readonly System.Collections.Concurrent.ConcurrentDictionary _activePlayers = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// Starts an offline leveling session by replacing the real player with a ghost. + /// + /// The real player who typed the command. + /// The pre-validated account login name. + /// true if the offline session was started successfully. + public async ValueTask StartAsync(Player realPlayer, string loginName) + { + var account = realPlayer.Account; + var character = realPlayer.SelectedCharacter; + + if (account is null || character is null) + { + return false; + } + + var sentinel = new OfflineLevelingPlayer(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(account, character).ConfigureAwait(false)) + { + this._activePlayers.TryRemove(loginName, out _); + return false; + } + + return true; + } + catch + { + this._activePlayers.TryRemove(loginName, out _); + throw; + } + } + + /// + /// Stops and removes the offline leveling session for the given account, if one exists. + /// + /// The account login name. + public async ValueTask StopAsync(string loginName) + { + if (this._activePlayers.TryRemove(loginName, out var offlinePlayer)) + { + await offlinePlayer.StopAsync().ConfigureAwait(false); + } + } + + /// + /// Returns whether an offline leveling session is currently active for . + /// + /// The account login name. + public bool IsActive(string loginName) => this._activePlayers.ContainsKey(loginName); + + private async ValueTask TransitionToOfflineAsync(Player realPlayer, string loginName) + { + await this.LogOffFromLoginServerAsync(realPlayer, loginName).ConfigureAwait(false); + + realPlayer.SuppressDisconnectedEvent(); + await realPlayer.DisconnectAsync().ConfigureAwait(false); + realPlayer.PersistenceContext.Dispose(); + } + + /// + /// Calculates and deducts the initial Zen cost for starting an offline leveling session. + /// The cost is based on the first MuHelper cost stage multiplied by the player's total level. + /// + /// The player to charge. + /// true if the cost was successfully charged or no cost applies; otherwise false. + private bool TryChargeInitialZenCost(Player player) + { + var initialCost = this.CalculateInitialZenCost(player); + + return initialCost <= 0 || player.TryRemoveMoney(initialCost); + } + + /// + /// Calculates the initial Zen cost for the given player based on the MuHelper configuration + /// and the player's combined normal and master level. + /// + /// The player for whom to calculate the cost. + /// The Zen amount to charge; 0 if no cost applies. + private int CalculateInitialZenCost(Player player) + { + var config = player.GameContext.FeaturePlugIns.GetPlugIn()?.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; + } + + /// + /// 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. + /// + /// The player being transitioned to offline leveling. + /// The account login name. + 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 offlevel start."); + } + } + + /// + /// Removes the sentinel entry from the active players dictionary and disposes the ghost. + /// Used when startup fails before the ghost is fully initialized. + /// + /// The account login name. + /// The ghost player to dispose. + private async ValueTask RemoveAndDisposeAsync(string loginName, OfflineLevelingPlayer sentinel) + { + this._activePlayers.TryRemove(loginName, out _); + await sentinel.DisposeAsync().ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs new file mode 100644 index 000000000..2588e0041 --- /dev/null +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs @@ -0,0 +1,121 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.PlugIns; + +/// +/// An offline player that continues leveling after the real client disconnects. +/// +public sealed class OfflineLevelingPlayer : Player +{ + private OfflineLevelingIntelligence? _intelligence; + + /// + /// Initializes a new instance of the class. + /// + /// The game context. + public OfflineLevelingPlayer(IGameContext gameContext) + : base(gameContext) + { + } + + /// + /// Gets the character name. + /// + public string? CharacterName => this.SelectedCharacter?.Name; + + /// + /// Gets the start timestamp of the offline leveling session. + /// + public DateTime StartTimestamp { get; private set; } + + /// + /// Initializes the offline player from captured references. + /// + /// The account. + /// The character. + /// true if successfully started. + public async ValueTask InitializeAsync(Account account, Character character) + { + try + { + this.StartTimestamp = DateTime.UtcNow; + this.Account = account; + this.PersistenceContext.Attach(account); + + await this.AdvanceToEnteredWorldStateAsync().ConfigureAwait(false); + + await this.SetupCharacterAsync(character).ConfigureAwait(false); + + this.StartIntelligence(); + + this.Logger.LogDebug( + "Offline leveling 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 {CharacterName}.", this.CharacterName); + return false; + } + } + + private async ValueTask AdvanceToEnteredWorldStateAsync() + { + // 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) + { + // Add to context and set character. + await this.GameContext.AddPlayerAsync(this).ConfigureAwait(false); + await this.SetSelectedCharacterAsync(character).ConfigureAwait(false); + + if (this.SelectedCharacter is { } selectedCharacter) + { + this.PersistenceContext.Attach(selectedCharacter); + } + } + + private void StartIntelligence() + { + this._intelligence = new OfflineLevelingIntelligence(this); + this._intelligence.Start(); + } + + /// + /// Stops the offline player and removes it from the world. + /// + public async ValueTask StopAsync() + { + this._intelligence?.Dispose(); + this._intelligence = null; + try + { + await this.SaveProgressAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to save progress of offline leveling player {CharacterName}.", this.CharacterName); + } + + await this.DisconnectAsync().ConfigureAwait(false); + } + + /// + protected override ICustomPlugInContainer CreateViewPlugInContainer() + => new NullViewPlugInContainer(); +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/RepairHandler.cs b/src/GameLogic/OfflineLeveling/RepairHandler.cs new file mode 100644 index 000000000..1c217ee9f --- /dev/null +++ b/src/GameLogic/OfflineLeveling/RepairHandler.cs @@ -0,0 +1,111 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.GameLogic.Views.Inventory; + +/// +/// Handles auto-repair of equipped items during offline leveling. +/// +internal sealed class RepairHandler +{ + private readonly Player _player; + private readonly IMuHelperSettings? _config; + private readonly ItemPriceCalculator _priceCalculator; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + /// The MU Helper configuration. + public RepairHandler(Player player, IMuHelperSettings? config) + { + this._player = player; + this._config = config; + this._priceCalculator = new ItemPriceCalculator(); + } + + /// + /// Performs repairs on equipped items if the configuration allows it. + /// + 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; + } + + await this.RepairItemInSlotAsync(i).ConfigureAwait(false); + } + } + + private async ValueTask RepairItemInSlotAsync(byte slot) + { + var item = this._player.Inventory?.GetItem(slot); + if (item is null) + { + return; + } + + var maxDurability = item.GetMaximumDurabilityOfOnePiece(); + if (maxDurability == 0 || item.Durability >= maxDurability) + { + return; + } + + // NPC discount is false because we repair "in the field" + var price = this._priceCalculator.CalculateRepairPrice(item, false); + this._player.Logger.LogDebug( + "Item {ItemName} in slot {Slot} needs repair. Durability: {Durability}/{MaxDurability}. Cost: {RepairCost}. Player Zen: {PlayerZen}.", + item.Definition?.Name, + slot, + item.Durability, + maxDurability, + price, + this._player.Money); + + if (price > 0 && this._player.TryRemoveMoney((int)price)) + { + item.Durability = maxDurability; + await this._player + .InvokeViewPlugInAsync(p => p.ItemDurabilityChangedAsync(item, false)) + .ConfigureAwait(false); + + this._player.Logger.LogDebug( + "Successfully auto-repaired item {ItemName} in slot {Slot} for {RepairCost} Zen by character {CharacterName}.", + item.Definition?.Name, + slot, + price, + this._player.Name); + } + else if (price > 0) + { + this._player.Logger.LogDebug( + "Insufficient Zen to repair item {ItemName} in slot {Slot} (Cost: {RepairCost}, Zen: {PlayerZen}) for character {CharacterName}.", + item.Definition?.Name, + slot, + price, + this._player.Money, + this._player.Name); + } + else + { + // Price is 0 or less, no action required. + } + } +} diff --git a/src/GameLogic/OfflineLeveling/ZenConsumptionHandler.cs b/src/GameLogic/OfflineLeveling/ZenConsumptionHandler.cs new file mode 100644 index 000000000..4dcbb8e76 --- /dev/null +++ b/src/GameLogic/OfflineLeveling/ZenConsumptionHandler.cs @@ -0,0 +1,60 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.GameLogic.Views.MuHelper; + +/// +/// Handles the periodic Zen consumption during offline leveling. +/// +internal sealed class ZenConsumptionHandler +{ + private readonly OfflineLevelingPlayer _player; + private readonly MuHelperConfiguration _configuration; + private DateTime _lastPayTimestamp; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public ZenConsumptionHandler(OfflineLevelingPlayer player) + { + this._player = player; + this._configuration = player.GameContext.FeaturePlugIns.GetPlugIn()?.Configuration + ?? new MuHelperConfiguration(); + this._lastPayTimestamp = player.StartTimestamp; + } + + /// + /// Deducts Zen from the player based on the server configuration and the player's level. + /// + public async Task DeductZenAsync() + { + if (DateTime.UtcNow - this._lastPayTimestamp < this._configuration.PayInterval) + { + return; + } + + 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(p => p.ConsumeMoneyAsync((uint)amount)) + .ConfigureAwait(false); + } + else if (amount > 0) + { + this._player.Logger.LogDebug("Offline leveling stopped for {CharacterName} due to insufficient Zen.", this._player.Name); + await this._player.StopAsync().ConfigureAwait(false); + } + else + { + // Price is 0 or less, no action required. + } + } +} \ No newline at end of file diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index d38609f1b..927c96887 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -12,6 +12,7 @@ namespace MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.GuildWar; using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Pet; using MUnique.OpenMU.GameLogic.PlayerActions; @@ -39,6 +40,14 @@ namespace MUnique.OpenMU.GameLogic; /// public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacker, ITrader, IPartyMember, IRotatable, IHasBucketInformation, ISupportWalk, IMovable, ILoggerOwner { + private static readonly MagicEffectDefinition GMEffect = new GMMagicEffectDefinition + { + InformObservers = true, + Name = "GM MARK", + Number = 28, + StopByDeath = false, + }; + private readonly AsyncLock _moveLock = new(); private readonly Walker _walker; @@ -422,6 +431,11 @@ public Point RandomPosition /// public BackupItemStorage? BackupInventory { get; set; } + /// + /// Gets or sets the deserialized MU Helper player settings. + /// + public IMuHelperSettings? MuHelperSettings { get; set; } + /// /// Gets the appearance data. /// @@ -460,6 +474,22 @@ public Point RandomPosition /// public MiniGameContext? CurrentMiniGame { get; set; } + /// + /// Gets the size of the inventory of the current player. + /// + public byte InventorySize + { + get + { + if (this.SelectedCharacter is not { } selectedCharacter) + { + return 0; + } + + return (byte)InventoryConstants.GetInventorySize(selectedCharacter.InventoryExtensions); + } + } + /// /// Gets the pet command manager. /// @@ -510,14 +540,6 @@ public IPetCommandManager? PetCommandManager /// protected virtual bool IsPlayerStoreOpeningAfterEnterSupported => true; - private static readonly MagicEffectDefinition GMEffect = new GMMagicEffectDefinition - { - InformObservers = true, - Name = "GM MARK", - Number = 28, - StopByDeath = false, - }; - /// /// Sets the selected character. /// @@ -1400,6 +1422,17 @@ public async Task RegenerateAsync() } } + /// + /// Clears all subscribers from the event so that + /// will not raise it. Used by offline leveling to prevent + /// GameServer.OnPlayerDisconnectedAsync from double-saving and double-logging off + /// after the real client disconnects. + /// + public void SuppressDisconnectedEvent() + { + this.PlayerDisconnected = null; + } + /// /// Disconnects the player from the game. Remote connections will be closed and data will be saved. /// @@ -1748,20 +1781,6 @@ public override string ToString() return $"Account: [{accountName}], Character:[{characterName}]"; } - /// - /// Gets the size of the inventory of the specified player. - /// - /// The size of the inventory. - public byte GetInventorySize() - { - if (this.SelectedCharacter is not { } selectedCharacter) - { - return 0; - } - - return (byte)InventoryConstants.GetInventorySize(selectedCharacter.InventoryExtensions); - } - /// /// Resets the pet behavior. /// @@ -2025,6 +2044,10 @@ private async ValueTask RegenerateHeroStateAsync() { currentCharacter.State++; } + else + { + // State is already Normal, no change needed + } await this.ForEachWorldObserverAsync(p => p.UpdateCharacterHeroStateAsync(this), true).ConfigureAwait(false); currentCharacter.StateRemainingSeconds = currentCharacter.State == HeroState.Normal @@ -2134,7 +2157,7 @@ private async ValueTask HitAsync(HitInfo hitInfo, IAttacker attacker, Skill? ski if (attacker is IAttackable or AttackerSurrogate) { - var attackableAttacker = (attacker as AttackerSurrogate)?.Owner ?? (IAttackable)attacker; + var attackableAttacker = attacker is AttackerSurrogate surrogate ? surrogate.Owner : (IAttackable)attacker; var reflectPercentage = this.Attributes[Stats.DamageReflection]; if (reflectPercentage > 0) @@ -2780,4 +2803,4 @@ public GMMagicEffectDefinition() this.PowerUpDefinitions = new List(0); } } -} +} \ No newline at end of file diff --git a/src/GameLogic/PlayerActions/HitAction.cs b/src/GameLogic/PlayerActions/HitAction.cs index 49795a39c..7419be551 100644 --- a/src/GameLogic/PlayerActions/HitAction.cs +++ b/src/GameLogic/PlayerActions/HitAction.cs @@ -28,19 +28,19 @@ public async ValueTask HitAsync(Player player, IAttackable target, byte attackAn if (attributes[Stats.IsStunned] > 0) { - player.Logger.LogWarning($"Probably Hacker - player {player} is attacking in stunned state"); + player.Logger.LogWarning("Probably Hacker - player {Player} is attacking in stunned state", player); return; } if (attributes[Stats.IsAsleep] > 0) { - player.Logger.LogWarning($"Probably Hacker - player {player} is attacking in asleep state"); + player.Logger.LogWarning("Probably Hacker - player {Player} is attacking in asleep state", player); return; } if (player.IsAtSafezone()) { - player.Logger.LogWarning($"Probably Hacker - player {player} is attacking from safezone"); + player.Logger.LogWarning("Probably Hacker - player {Player} is attacking from safezone", player); return; } @@ -49,6 +49,11 @@ public async ValueTask HitAsync(Player player, IAttackable target, byte attackAn return; } + if (attributes[Stats.IsMuHelperActive] > 0 && target is Player) + { + return; + } + if (target is IObservable targetAsObservable) { using var readerLock = await targetAsObservable.ObserverLock.ReaderLockAsync(); @@ -88,7 +93,7 @@ public async ValueTask HitAsync(Player player, IAttackable target, byte attackAn } // Currently, we just support one effect for monsters. - // E.g. Poison for Poison Bull Fighters. + // E.g. Poison for Poison BullFighters. if (skill.MagicEffectDef is { Duration: not null } effectDefinition && !target.MagicEffectList.ActiveEffects.ContainsKey(effectDefinition.Number) && effectDefinition.PowerUpDefinitions.FirstOrDefault() is { Boost: not null } powerUpDef) diff --git a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs index 8f3f564c4..e2d3f1ae6 100644 --- a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs +++ b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs @@ -161,7 +161,7 @@ private async ValueTask MoveNormalAsync(Player player, byte fromSlot, byte toSlo player.Inventory, (byte)(InventoryRows + (player.SelectedCharacter!.InventoryExtensions * RowsOfOneExtension)), EquippableSlotsCount, - (byte)(EquippableSlotsCount + player.GetInventorySize())); + (byte)(EquippableSlotsCount + player.InventorySize)); break; case Storages.PersonalStore when player.ShopStorage is not null: result = new StorageInfo( diff --git a/src/GameLogic/PlayerActions/LoginAction.cs b/src/GameLogic/PlayerActions/LoginAction.cs index 1a42fc384..4ce29048d 100644 --- a/src/GameLogic/PlayerActions/LoginAction.cs +++ b/src/GameLogic/PlayerActions/LoginAction.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -23,70 +23,141 @@ public class LoginAction public async ValueTask LoginAsync(Player player, string username, string password) { using var loggerScope = player.Logger.BeginScope(this.GetType()); - Account? account; + + var account = await this.AuthenticateAccountAsync(player, username, password).ConfigureAwait(false); + if (account is null || !await this.ValidateAccountStateAsync(player, account).ConfigureAwait(false)) + { + return; + } + + var (success, finalAccount) = await this.TryEstablishSessionAsync(player, username, password, account).ConfigureAwait(false); + if (success && finalAccount is { }) + { + await this.FinishLoginAsync(player, username, finalAccount).ConfigureAwait(false); + } + } + + private async ValueTask AuthenticateAccountAsync(Player player, string username, string password) + { try { - account = await player.PersistenceContext.GetAccountByLoginNameAsync(username, password).ConfigureAwait(false); + var account = await player.PersistenceContext.GetAccountByLoginNameAsync(username, password).ConfigureAwait(false); + if (account is null) + { + player.Logger.LogInformation($"Account not found or invalid password, username: [{username}]."); + await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.InvalidPassword)).ConfigureAwait(false); + } + + return account; } catch (Exception ex) { player.Logger.LogError(ex, "Login Failed."); await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.ConnectionError)).ConfigureAwait(false); - return; + return null; } + } - if (account is null) - { - player.Logger.LogInformation($"Account not found or invalid password, username: [{username}]"); - await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.InvalidPassword)).ConfigureAwait(false); - } - else if (account.State == AccountState.Banned) + private async ValueTask ValidateAccountStateAsync(Player player, Account account) + { + if (account.State == AccountState.Banned) { await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.AccountBlocked)).ConfigureAwait(false); + return false; } - else if (account.State == AccountState.TemporarilyBanned) + + if (account.State == AccountState.TemporarilyBanned) { await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.TemporaryBlocked)).ConfigureAwait(false); + return false; } - else + + return true; + } + + private async ValueTask<(bool Success, Account? Account)> TryEstablishSessionAsync(Player player, string username, string password, Account account) + { + try { - try + await using var context = await player.PlayerState.TryBeginAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + if (!context.Allowed) { - await using var context = await player.PlayerState.TryBeginAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); - if (context.Allowed - && player.GameContext is IGameServerContext gameServerContext - && (account.IsTemplate || await gameServerContext.LoginServer.TryLoginAsync(username, gameServerContext.Id).ConfigureAwait(false))) - { - player.Account = account; - player.Logger.LogDebug("Login successful, username: [{0}]", username); - - if (player.IsTemplatePlayer) - { - foreach (var character in account.Characters) - { - var counter = Interlocked.Increment(ref _templateCounter); - character.Name = $"_{counter}"; - } - } - - await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.Ok)).ConfigureAwait(false); - } - else + await this.HandleAlreadyConnectedAsync(player, username).ConfigureAwait(false); + return (false, null); + } + + if (player.GameContext is not IGameServerContext gameServerContext) + { + return (false, null); + } + + if (!account.IsTemplate && !await gameServerContext.LoginServer.TryLoginAsync(username, gameServerContext.Id).ConfigureAwait(false)) + { + context.Allowed = false; + await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.AccountAlreadyConnected)).ConfigureAwait(false); + await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false); + return (false, null); + } + + var finalAccount = account; + if (player.GameContext.OfflineLevelingManager.IsActive(username)) + { + finalAccount = await this.HandleOfflineSessionHandoverAsync(player, username, password, account).ConfigureAwait(false); + if (finalAccount is null) { context.Allowed = false; - await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.AccountAlreadyConnected)).ConfigureAwait(false); - - if (player.GameContext is IGameServerContext gameServerContext2) - { - await gameServerContext2.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext2.Id, username).ConfigureAwait(false); - } + return (false, null); } } - catch (Exception ex) + + return (true, finalAccount); + } + catch (Exception ex) + { + player.Logger.LogError(ex, "Unexpected error during login through login server."); + await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.ConnectionError)).ConfigureAwait(false); + return (false, null); + } + } + + private async ValueTask HandleOfflineSessionHandoverAsync(Player player, string username, string password, Account account) + { + player.Logger.LogInformation("Account {username} has an active offline session. Stopping it and reloading account data.", username); + await player.GameContext.OfflineLevelingManager.StopAsync(username).ConfigureAwait(false); + player.PersistenceContext.Detach(account); + var reloadedAccount = await player.PersistenceContext.GetAccountByLoginNameAsync(username, password).ConfigureAwait(false); + if (reloadedAccount is null) + { + player.Logger.LogError("Failed to reload account {username} after stopping offline session.", username); + await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.ConnectionError)).ConfigureAwait(false); + } + + return reloadedAccount; + } + + private async ValueTask HandleAlreadyConnectedAsync(Player player, string username) + { + await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.AccountAlreadyConnected)).ConfigureAwait(false); + if (player.GameContext is IGameServerContext gameServerContext) + { + await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false); + } + } + + private async ValueTask FinishLoginAsync(Player player, string username, Account account) + { + player.Account = account; + player.Logger.LogDebug($"Login successful, username: [{username}]."); + + if (player.IsTemplatePlayer) + { + foreach (var character in account.Characters) { - player.Logger.LogError(ex, "Unexpected error during login through login server"); - await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.ConnectionError)).ConfigureAwait(false); + var counter = Interlocked.Increment(ref _templateCounter); + character.Name = $"_{counter}"; } } + + await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.Ok)).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs index 6296787e2..a94ab40ad 100644 --- a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs +++ b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -82,7 +82,8 @@ private static IEnumerable GetTargets(Player player, Point targetAr { if (extraTarget?.CheckSkillTargetRestrictions(player, skill) is true && player.IsInRange(extraTarget.Position, skill.Range + 2) - && !extraTarget.IsAtSafezone()) + && !extraTarget.IsAtSafezone() + && !(player.Attributes?[Stats.IsMuHelperActive] > 0 && extraTarget is Player)) { yield return extraTarget; } @@ -130,7 +131,8 @@ private static IEnumerable GetTargetsInRange(Player player, Point t targetsInRange = targetsInRange.Where(a => a.GetDistanceTo(targetAreaCenter) < skill.AreaSkillSettings.TargetAreaDiameter * 0.5f); } - if (!player.GameContext.Configuration.AreaSkillHitsPlayer) + if (!player.GameContext.Configuration.AreaSkillHitsPlayer + || player.Attributes?[Stats.IsMuHelperActive] > 0) { targetsInRange = targetsInRange.Where(a => a is not Player); } diff --git a/src/GameLogic/PlugIns/ChatCommands/OfflineLevelingChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/OfflineLevelingChatCommandPlugIn.cs new file mode 100644 index 000000000..65e0226de --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/OfflineLevelingChatCommandPlugIn.cs @@ -0,0 +1,87 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.GameLogic.OfflineLeveling; +using MUnique.OpenMU.PlugIns; + +/// +/// Handles the /offlevel chat command. +/// +/// Logs the login-server entry off so the real player can re-connect at any time. +/// Disconnects the real client connection. +/// Creates a silent ghost player on the current map using the character's MU Helper config. +/// On next login the ghost is automatically stopped before character selection. +/// +/// +[Guid("A1C4E7F2-3B8D-4A09-8E5C-2D6F0B3A7E14")] +[PlugIn] +[Display( + Name = nameof(PlugInResources.OfflineLevelingChatCommandPlugIn_Name), + Description = nameof(PlugInResources.OfflineLevelingChatCommandPlugIn_Description), + ResourceType = typeof(PlugInResources))] +[ChatCommandHelp(Command, CharacterStatus.Normal)] +public sealed class OfflineLevelingChatCommandPlugIn : IChatCommandPlugIn +{ + private const string Command = "/offlevel"; + + /// + public string Key => Command; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.Normal; + + /// + public async ValueTask HandleCommandAsync(Player player, string command) + { + if (player.SelectedCharacter is null) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.OfflineLevelingNoCharacterSelected)).ConfigureAwait(false); + return; + } + + if (!player.IsAlive) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.OfflineLevelingMustBeAlive)).ConfigureAwait(false); + return; + } + + if (player.CurrentMap is null) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.OfflineLevelingNotOnMap)).ConfigureAwait(false); + return; + } + + if (player.Attributes?[Stats.IsMuHelperActive] <= 0) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.OfflineLevelingMuHelperNotRunning)).ConfigureAwait(false); + return; + } + + var loginName = player.Account?.LoginName; + if (loginName is null) + { + return; + } + + var manager = player.GameContext.OfflineLevelingManager; + + if (manager.IsActive(loginName)) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.OfflineLevelingAlreadyActive)).ConfigureAwait(false); + return; + } + + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.OfflineLevelingStarted)).ConfigureAwait(false); + + if (!await manager.StartAsync(player, loginName).ConfigureAwait(false)) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.OfflineLevelingFailed)).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index 9149549f4..7a70883a9 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -1580,5 +1580,68 @@ public static string YourChatBanRemovedByGameMaster { return ResourceManager.GetString("YourChatBanRemovedByGameMaster", resourceCulture); } } + + /// + /// Looks up a localized string similar to Offline leveling started. You can log back in at any time to stop it.. + /// + public static string OfflineLevelingStarted { + get { + return ResourceManager.GetString("OfflineLevelingStarted", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not start offline leveling. Try again in a safe spot.. + /// + public static string OfflineLevelingFailed { + get { + return ResourceManager.GetString("OfflineLevelingFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Offline leveling is already active for this account.. + /// + public static string OfflineLevelingAlreadyActive { + get { + return ResourceManager.GetString("OfflineLevelingAlreadyActive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You must be alive to start offline leveling.. + /// + public static string OfflineLevelingMustBeAlive { + get { + return ResourceManager.GetString("OfflineLevelingMustBeAlive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You must be on a map to start offline leveling.. + /// + public static string OfflineLevelingNotOnMap { + get { + return ResourceManager.GetString("OfflineLevelingNotOnMap", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No character is selected.. + /// + public static string OfflineLevelingNoCharacterSelected { + get { + return ResourceManager.GetString("OfflineLevelingNoCharacterSelected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Mu Helper is not running.. + /// + public static string OfflineLevelingMuHelperNotRunning { + get { + return ResourceManager.GetString("OfflineLevelingMuHelperNotRunning", resourceCulture); + } + } } -} +} \ No newline at end of file diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx index 304ee6743..a85d7ce09 100644 --- a/src/GameLogic/Properties/PlayerMessage.resx +++ b/src/GameLogic/Properties/PlayerMessage.resx @@ -624,4 +624,25 @@ The character has no stat attribute '{0}'. + + Offline leveling started. You can log back in at any time to stop it. + + + Could not start offline leveling. Try again in a safe spot. + + + Offline leveling is already active for this account. + + + You must be alive to start offline leveling. + + + You must be on a map to start offline leveling. + + + No character is selected. + + + MU Helper must be running to start offline leveling. + \ No newline at end of file diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 258a76f79..e17680494 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -3130,5 +3130,41 @@ public static string WeatherUpdatePlugIn_Name { return ResourceManager.GetString("WeatherUpdatePlugIn_Name", resourceCulture); } } + + /// + /// Looks up a localized string similar to Offline Leveling Command. + /// + public static string OfflineLevelingChatCommandPlugIn_Name { + get { + return ResourceManager.GetString("OfflineLevelingChatCommandPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Allows players to start offline leveling. + /// + public static string OfflineLevelingChatCommandPlugIn_Description { + get { + return ResourceManager.GetString("OfflineLevelingChatCommandPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Allows players to start offline leveling. + /// + public static string OfflineLevelingStopOnLoginPlugIn_Name { + get { + return ResourceManager.GetString("OfflineLevelingStopOnLoginPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Allows players to start offline leveling. + /// + public static string OfflineLevelingStopOnLoginPlugIn_Description { + get { + return ResourceManager.GetString("OfflineLevelingStopOnLoginPlugIn_Description", resourceCulture); + } + } } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 4e828642e..1c7601891 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1,4 +1,4 @@ - +