From f0e05b178a1ce0d06ff19ace102098ed0adbeac1 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:57:29 -0300 Subject: [PATCH 01/21] fix: removing entry from plugin timetable updates in real-time --- .../Components/Form/ValueListWrapper.cs | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Web/Shared/Components/Form/ValueListWrapper.cs b/src/Web/Shared/Components/Form/ValueListWrapper.cs index e06407816..860abc01e 100644 --- a/src/Web/Shared/Components/Form/ValueListWrapper.cs +++ b/src/Web/Shared/Components/Form/ValueListWrapper.cs @@ -64,25 +64,28 @@ public void CopyTo(TValue[] array, int arrayIndex) /// public bool Remove(TValue item) { - if (this._innerList.Remove(item)) + var wrapperIndex = this.FindIndex(v => Equals(v.Value, item)); + if (wrapperIndex >= 0) { - var wrapperIndex = this.FindIndex(v => Equals(v.Value, item)); - if (wrapperIndex >= 0) - { - this[wrapperIndex].PropertyChanged -= this.OnValueChanged; - this.RemoveAt(wrapperIndex); - for (int i = wrapperIndex; i < this._innerList.Count; i++) - { - this[i].Index = i; - } - } - + this.RemoveAt(wrapperIndex); return true; } return false; } + /// + public new void RemoveAt(int index) + { + this._innerList.RemoveAt(index); + this[index].PropertyChanged -= this.OnValueChanged; + base.RemoveAt(index); + for (int i = index; i < this._innerList.Count; i++) + { + this[i].Index = i; + } + } + /// public bool IsReadOnly => this._innerList.IsReadOnly; From 121816ae23d841dd5253307398d1cb9e321b67cc Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:12:26 -0300 Subject: [PATCH 02/21] code review: fix gemini-code-assist warnings --- src/Web/Shared/Components/Form/ValueListWrapper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Web/Shared/Components/Form/ValueListWrapper.cs b/src/Web/Shared/Components/Form/ValueListWrapper.cs index 860abc01e..242b7ef38 100644 --- a/src/Web/Shared/Components/Form/ValueListWrapper.cs +++ b/src/Web/Shared/Components/Form/ValueListWrapper.cs @@ -77,10 +77,10 @@ public bool Remove(TValue item) /// public new void RemoveAt(int index) { - this._innerList.RemoveAt(index); this[index].PropertyChanged -= this.OnValueChanged; base.RemoveAt(index); - for (int i = index; i < this._innerList.Count; i++) + this._innerList.RemoveAt(index); + for (int i = index; i < this.Count; i++) { this[i].Index = i; } From 8713cc6c1350c7ba66af00eee8d80ac9946a4bbf Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:03:54 -0300 Subject: [PATCH 03/21] fix: create attribute in account page --- src/AttributeSystem/StatAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } From ede4b5c37218d6de28736d39699105a1fa39f71a Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:40:37 -0300 Subject: [PATCH 04/21] feature: duplicate item on ItemStorageField --- .../Components/Form/ItemStorageField.razor | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/Web/Shared/Components/Form/ItemStorageField.razor b/src/Web/Shared/Components/Form/ItemStorageField.razor index 9f7f87b60..c493d47e3 100644 --- a/src/Web/Shared/Components/Form/ItemStorageField.razor +++ b/src/Web/Shared/Components/Form/ItemStorageField.razor @@ -70,10 +70,12 @@ @if (this._selectedItem is null) { + } else { + } @@ -192,4 +194,25 @@ this._selectedItem = item; } } + + private void OnDuplicateItemClickAsync() + { + if (this._selectedItem is null || this.Value is not { } itemStorage) + { + return; + } + + var duplicate = this.PersistenceContext.CreateNew(); + duplicate.AssignValues(this._selectedItem); + + // Place the duplicate in the next available slot after the source item + var occupiedSlots = itemStorage.Items.Select(i => i.ItemSlot).ToHashSet(); + var nextSlot = Enumerable.Range(this._selectedItem.ItemSlot + 1, byte.MaxValue - this._selectedItem.ItemSlot) + .Select(i => (byte)i) + .FirstOrDefault(s => !occupiedSlots.Contains(s)); + duplicate.ItemSlot = nextSlot; + + itemStorage.Items.Add(duplicate); + this._selectedItem = duplicate; + } } From bdc15b22742a0c59df98d8e7e26e9a0c59275523 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:59:48 -0300 Subject: [PATCH 05/21] feature: implement offlevel --- src/GameLogic/DroppedItem.cs | 2 +- src/GameLogic/DroppedMoney.cs | 4 +- src/GameLogic/GameContext.cs | 3 + src/GameLogic/GameMap.cs | 13 + src/GameLogic/IGameContext.cs | 5 + src/GameLogic/MuHelper/MuHelper.cs | 4 +- .../MuHelper/MuHelperFeaturePlugIn.cs | 6 +- .../MuHelper/MuHelperPlayerConfiguration.cs | 319 ++++++++++ ...tion.cs => MuHelperServerConfiguration.cs} | 2 +- .../NullViewPlugInContainer.cs | 24 + .../OfflineLeveling/NullViewProxy.cs | 60 ++ .../OfflineLevelingIntelligence.cs | 581 ++++++++++++++++++ .../OfflineLeveling/OfflineLevelingManager.cs | 113 ++++ .../OfflineLeveling/OfflineLevelingPlayer.cs | 109 ++++ .../OfflineLevelingStopOnLoginPlugIn.cs | 48 ++ src/GameLogic/Player.cs | 13 +- .../OfflineLevelingChatCommandPlugIn.cs | 82 +++ .../Properties/PlayerMessage.Designer.cs | 56 +- src/GameLogic/Properties/PlayerMessage.resx | 18 + .../Properties/PlugInResources.Designer.cs | 30 + src/GameLogic/Properties/PlugInResources.resx | 12 + .../Updates/AddOfflineLevelingPlugIn.cs | 75 +++ .../Initialization/Updates/UpdateVersion.cs | 5 + src/Startup/Dockerfile | 41 +- 24 files changed, 1602 insertions(+), 23 deletions(-) create mode 100644 src/GameLogic/MuHelper/MuHelperPlayerConfiguration.cs rename src/GameLogic/MuHelper/{MuHelperConfiguration.cs => MuHelperServerConfiguration.cs} (98%) create mode 100644 src/GameLogic/OfflineLeveling/NullViewPlugInContainer.cs create mode 100644 src/GameLogic/OfflineLeveling/NullViewProxy.cs create mode 100644 src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs create mode 100644 src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs create mode 100644 src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs create mode 100644 src/GameLogic/OfflineLeveling/OfflineLevelingStopOnLoginPlugIn.cs create mode 100644 src/GameLogic/PlugIns/ChatCommands/Arguments/OfflineLevelingChatCommandPlugIn.cs create mode 100644 src/Persistence/Initialization/Updates/AddOfflineLevelingPlugIn.cs diff --git a/src/GameLogic/DroppedItem.cs b/src/GameLogic/DroppedItem.cs index d805d7f3f..ddc56d1a8 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; 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..42dc8610b 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; } 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/MuHelper/MuHelper.cs b/src/GameLogic/MuHelper/MuHelper.cs index 0e1232db8..7826b8918 100644 --- a/src/GameLogic/MuHelper/MuHelper.cs +++ b/src/GameLogic/MuHelper/MuHelper.cs @@ -30,7 +30,7 @@ public class MuHelper : AsyncDisposable /// /// The current configuration. /// - private readonly MuHelperConfiguration _configuration; + private readonly MuHelperServerConfiguration _configuration; private CancellationTokenSource? _stopCts; private Task? _runTask; @@ -43,7 +43,7 @@ public class MuHelper : AsyncDisposable public MuHelper(Player player) { this._player = player; - this._configuration = this._player.GameContext.FeaturePlugIns.GetPlugIn()?.Configuration ?? new MuHelperConfiguration(); + this._configuration = this._player.GameContext.FeaturePlugIns.GetPlugIn()?.Configuration ?? new MuHelperServerConfiguration(); } /// diff --git a/src/GameLogic/MuHelper/MuHelperFeaturePlugIn.cs b/src/GameLogic/MuHelper/MuHelperFeaturePlugIn.cs index 50ec1ccd4..58cf83675 100644 --- a/src/GameLogic/MuHelper/MuHelperFeaturePlugIn.cs +++ b/src/GameLogic/MuHelper/MuHelperFeaturePlugIn.cs @@ -13,11 +13,11 @@ namespace MUnique.OpenMU.GameLogic.MuHelper; [PlugIn] [Display(Name = nameof(PlugInResources.MuHelperFeaturePlugIn_Name), Description = nameof(PlugInResources.MuHelperFeaturePlugIn_Description), ResourceType = typeof(PlugInResources))] [Guid("E90A72C3-0459-4323-B6D3-171F88D35542")] -public class MuHelperFeaturePlugIn : IFeaturePlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration +public class MuHelperFeaturePlugIn : IFeaturePlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration { /// - public MuHelperConfiguration? Configuration { get; set; } + public MuHelperServerConfiguration? Configuration { get; set; } /// - public object CreateDefaultConfig() => new MuHelperConfiguration(); + public object CreateDefaultConfig() => new MuHelperServerConfiguration(); } \ No newline at end of file diff --git a/src/GameLogic/MuHelper/MuHelperPlayerConfiguration.cs b/src/GameLogic/MuHelper/MuHelperPlayerConfiguration.cs new file mode 100644 index 000000000..a40105282 --- /dev/null +++ b/src/GameLogic/MuHelper/MuHelperPlayerConfiguration.cs @@ -0,0 +1,319 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.MuHelper; + +/// +/// Deserialized representation of the 257-byte PRECEIVE_MUHELPER_DATA blob that +/// the client stores in . +/// +/// Byte layout (all little-endian, #pragma pack(1)): +/// +/// 0 DataStartMarker +/// 1 [unused:3][JewelOrGem:1][SetItem:1][ExcellentItem:1][Zen:1][AddExtraItem:1] +/// 2 [HuntingRange:4][ObtainRange:4] +/// 3-4 DistanceMin (WORD – maxSecondsAway encoded as seconds) +/// 5-6 BasicSkill1 (WORD – always-on attack skill ID, 0 = none) +/// 7-8 ActivationSkill1 +/// 9-10 DelayMinSkill1 (WORD – timer interval in seconds for ActivationSkill1) +/// 11-12 ActivationSkill2 +/// 13-14 DelayMinSkill2 +/// 15-16 CastingBuffMin (WORD – buff recast interval in seconds) +/// 17-18 BuffSkill0NumberID +/// 19-20 BuffSkill1NumberID +/// 21-22 BuffSkill2NumberID +/// 23 [HPStatusAutoPotion:4][HPStatusAutoHeal:4] (value * 10 = threshold %) +/// 24 [HPStatusOfPartyMembers:4][HPStatusDrainLife:4] +/// 25 [AutoPotion:1][AutoHeal:1][DrainLife:1][LongDistanceAttack:1] +/// [OriginalPosition:1][Combo:1][Party:1][PreferenceOfPartyHeal:1] +/// 26 [BuffDurationForAllPartyMembers:1][UseDarkSpirits:1][BuffDuration:1] +/// [Skill1Delay:1][Skill1Con:1][Skill1PreCon:1][Skill1SubCon:2] +/// 27 [Skill2Delay:1][Skill2Con:1][Skill2PreCon:1][Skill2SubCon:2] +/// [RepairItem:1][PickAllNearItems:1][PickSelectedItems:1] +/// 28 PetAttack (BYTE: 0=cease, 1=auto, 2=together) +/// 29-64 _UnusedPadding[36] +/// 65-244 ExtraItems[12][15] (null-terminated ASCII item name filters) +/// 245-256 (trailing padding, ignored) +/// +/// +public sealed class MuHelperPlayerConfiguration +{ + /// Gets the always-active basic attack skill ID (0 = no skill, use normal attack). + public int BasicSkillId { get; init; } + + /// Gets the first conditional skill ID. + public int ActivationSkill1Id { get; init; } + + /// Gets the second conditional skill ID. + public int ActivationSkill2Id { get; init; } + + /// Gets the timer interval (seconds) for ActivationSkill1 when Skill1Delay is set. + public int DelayMinSkill1 { get; init; } + + /// Gets the timer interval (seconds) for ActivationSkill2 when Skill2Delay is set. + public int DelayMinSkill2 { get; init; } + + /// Gets a value indicating whether to use timer for the skill 1. + public bool Skill1UseTimer { get; init; } + + /// Gets a value indicating whether to use condition for the skill 1. + public bool Skill1UseCondition { get; init; } + + /// Gets a value indicating whether to use the precondition for Skill1 (false = nearby, true = attacking). + public bool Skill1ConditionAttacking { get; init; } + + /// + /// Gets the mob count threshold for Skill1 condition: 0=2+, 1=3+, 2=4+, 3=5+. + /// + public int Skill1SubCondition { get; init; } + + /// Gets a value indicating whether to use timer for the skill 2. + public bool Skill2UseTimer { get; init; } + + /// Gets a value indicating whether to use condition for the skill 2. + public bool Skill2UseCondition { get; init; } + + /// Gets a value indicating whether to use the precondition for Skill2 (false = nearby, true = attacking). + public bool Skill2ConditionAttacking { get; init; } + + /// Gets the mob count threshold for Skill2 condition. + public int Skill2SubCondition { get; init; } + + /// Gets a value indicating whether to use combo mode. + public bool UseCombo { get; init; } + + /// Gets the hunting range nibble (0-15); multiply to get tile distance. + public int HuntingRange { get; init; } + + /// Gets the max seconds away from original position before regrouping. + public int MaxSecondsAway { get; init; } + + /// Gets a value indicating whether to counter-attack enemies that attack from long range. + public bool LongRangeCounterAttack { get; init; } + + /// Gets a value indicating whether to return to original spawn position when away too long. + public bool ReturnToOriginalPosition { get; init; } + + /// Gets the Buff 0 skill id. + public int BuffSkill0Id { get; init; } + + /// Gets the Buff 1 skill id. + public int BuffSkill1Id { get; init; } + + /// Gets the Buff 2 skill id. + public int BuffSkill2Id { get; init; } + + /// Gets a value indicating whether apply buffs based on duration (i.e. when the buff expires). + public bool BuffOnDuration { get; init; } + + /// Gets a value indicating whether apply buff duration logic to party members too. + public bool BuffDurationForParty { get; init; } + + /// Gets the buff cast interval in seconds (0 = disabled). + public int BuffCastIntervalSeconds { get; init; } + + /// Gets a value indicating whether to use auto-heal. + public bool AutoHeal { get; init; } + + /// Gets the self-heal threshold (% HP, e.g. 30 means heal when below 30%). + public int HealThresholdPercent { get; init; } + + /// Gets a value indicating whether to use drain life. + public bool UseDrainLife { get; init; } + + /// Gets a value indicating whether to use healing potion. + public bool UseHealPotion { get; init; } + + /// Gets the potion use threshold (% HP). + public int PotionThresholdPercent { get; init; } + + /// Gets a value indicating whether to support party. + public bool SupportParty { get; init; } + + /// Gets a value indicating whether to auto heal party. + public bool AutoHealParty { get; init; } + + /// Gets the party member HP threshold (%) below which healing is applied. + public int HealPartyThresholdPercent { get; init; } + + /// Gets a value indicating whether to use dark raven. + public bool UseDarkRaven { get; init; } + + /// Gets the dark raven mode 0 = cease, 1 = auto-attack, 2 = attack with owner. + public int DarkRavenMode { get; init; } + + /// Gets the obtain range. + public int ObtainRange { get; init; } + + /// Gets a value indicating whether pickup all items. + public bool PickAllItems { get; init; } + + /// Gets a value indicating whether pickup selected items. + public bool PickSelectItems { get; init; } + + /// Gets a value indicating whether pickup jewels. + public bool PickJewel { get; init; } + + /// Gets a value indicating whether pickup zen. + public bool PickZen { get; init; } + + /// Gets a value indicating whether pickup ancient items. + public bool PickAncient { get; init; } + + /// Gets a value indicating whether pickup excellent items. + public bool PickExcellent { get; init; } + + /// Gets a value indicating whether pickup extra items. + public bool PickExtraItems { get; init; } + + /// Gets the extra item names. Up to 12 item name substrings; pick any dropped item whose name contains one of these. + public IReadOnlyList ExtraItemNames { get; init; } = []; + + /// Gets a value indicating whether to repair items. + public bool RepairItem { get; init; } + + /// + /// Deserializes a from the raw blob stored in + /// . + /// Returns when the blob is null, empty, or too short. + /// + /// The raw Mu Helper configuration. + public static MuHelperPlayerConfiguration? TryDeserialize(byte[]? blob) + { + if (blob is null || blob.Length < 69) + { + return null; + } + + byte b1 = blob[1]; + bool jewel = (b1 & (1 << 3)) != 0; + bool setItem = (b1 & (1 << 4)) != 0; + bool excellent = (b1 & (1 << 5)) != 0; + bool zen = (b1 & (1 << 6)) != 0; + bool extraItem = (b1 & (1 << 7)) != 0; + + byte b2 = blob[2]; + int huntingRange = b2 & 0x0F; + int obtainRange = (b2 >> 4) & 0x0F; + + int distanceMin = BlobReadWord(blob, 3); + + int basicSkill1 = BlobReadWord(blob, 5); + int activationSkill1 = BlobReadWord(blob, 7); + int delayMinSkill1 = BlobReadWord(blob, 9); + int activationSkill2 = BlobReadWord(blob, 11); + int delayMinSkill2 = BlobReadWord(blob, 13); + int castingBuffMin = BlobReadWord(blob, 15); + int buffSkill0 = BlobReadWord(blob, 17); + int buffSkill1 = BlobReadWord(blob, 19); + int buffSkill2 = BlobReadWord(blob, 21); + + byte b23 = blob[23]; + int hpPotion = (b23 & 0x0F) * 10; + int hpHeal = ((b23 >> 4) & 0x0F) * 10; + + byte b24 = blob[24]; + int hpParty = (b24 & 0x0F) * 10; + // HPStatusDrainLife uses the same threshold as HPStatusAutoHeal per the serialize code + byte b25 = blob[25]; + bool autoPotion = (b25 & (1 << 0)) != 0; + bool autoHeal = (b25 & (1 << 1)) != 0; + bool drainLife = (b25 & (1 << 2)) != 0; + bool longRangeCounterAttack = (b25 & (1 << 3)) != 0; + bool returnToOriginal = (b25 & (1 << 4)) != 0; + bool combo = (b25 & (1 << 5)) != 0; + bool party = (b25 & (1 << 6)) != 0; + bool prefPartyHeal = (b25 & (1 << 7)) != 0; + + byte b26 = blob[26]; + bool buffDurationParty = (b26 & (1 << 0)) != 0; + bool useDarkSpirits = (b26 & (1 << 1)) != 0; + bool buffDuration = (b26 & (1 << 2)) != 0; + bool skill1Delay = (b26 & (1 << 3)) != 0; + bool skill1Con = (b26 & (1 << 4)) != 0; + bool skill1PreCon = (b26 & (1 << 5)) != 0; // 0=nearby, 1=attacking + int skill1SubCon = (b26 >> 6) & 0x03; + + byte b27 = blob[27]; + bool skill2Delay = (b27 & (1 << 0)) != 0; + bool skill2Con = (b27 & (1 << 1)) != 0; + bool skill2PreCon = (b27 & (1 << 2)) != 0; + int skill2SubCon = (b27 >> 3) & 0x03; + bool repairItem = (b27 & (1 << 5)) != 0; + bool pickAll = (b27 & (1 << 6)) != 0; + bool pickSelect = (b27 & (1 << 7)) != 0; + + int petAttack = blob[28]; + + var extraNames = new List(); + if (blob.Length >= 245) + { + for (int i = 0; i < 12; i++) + { + int start = 65 + (i * 15); + int len = 0; + while (len < 15 && blob[start + len] != 0) + { + len++; + } + + if (len > 0) + { + extraNames.Add(Encoding.ASCII.GetString(blob, start, len)); + } + } + } + + return new MuHelperPlayerConfiguration + { + BasicSkillId = basicSkill1, + ActivationSkill1Id = activationSkill1, + ActivationSkill2Id = activationSkill2, + DelayMinSkill1 = delayMinSkill1, + DelayMinSkill2 = delayMinSkill2, + Skill1UseTimer = skill1Delay, + Skill1UseCondition = skill1Con, + Skill1ConditionAttacking = skill1PreCon, + Skill1SubCondition = skill1SubCon, + Skill2UseTimer = skill2Delay, + Skill2UseCondition = skill2Con, + Skill2ConditionAttacking = skill2PreCon, + Skill2SubCondition = skill2SubCon, + UseCombo = combo, + HuntingRange = huntingRange, + MaxSecondsAway = distanceMin, + LongRangeCounterAttack = longRangeCounterAttack, + ReturnToOriginalPosition = returnToOriginal, + BuffSkill0Id = buffSkill0, + BuffSkill1Id = buffSkill1, + BuffSkill2Id = buffSkill2, + BuffOnDuration = buffDuration, + BuffDurationForParty = buffDurationParty, + BuffCastIntervalSeconds = castingBuffMin, + AutoHeal = autoHeal, + HealThresholdPercent = hpHeal, + UseDrainLife = drainLife, + UseHealPotion = autoPotion, + PotionThresholdPercent = hpPotion, + SupportParty = party, + AutoHealParty = prefPartyHeal, + HealPartyThresholdPercent = hpParty, + UseDarkRaven = useDarkSpirits, + DarkRavenMode = petAttack, + ObtainRange = obtainRange, + PickAllItems = pickAll, + PickSelectItems = pickSelect, + PickJewel = jewel, + PickZen = zen, + PickAncient = setItem, + PickExcellent = excellent, + PickExtraItems = extraItem, + ExtraItemNames = extraNames, + RepairItem = repairItem, + }; + } + + private static int BlobReadWord(byte[] blob, int offset) + => blob[offset] | (blob[offset + 1] << 8); // little-endian +} \ No newline at end of file diff --git a/src/GameLogic/MuHelper/MuHelperConfiguration.cs b/src/GameLogic/MuHelper/MuHelperServerConfiguration.cs similarity index 98% rename from src/GameLogic/MuHelper/MuHelperConfiguration.cs rename to src/GameLogic/MuHelper/MuHelperServerConfiguration.cs index 0fc50e5c6..079b1a7b4 100644 --- a/src/GameLogic/MuHelper/MuHelperConfiguration.cs +++ b/src/GameLogic/MuHelper/MuHelperServerConfiguration.cs @@ -7,7 +7,7 @@ namespace MUnique.OpenMU.GameLogic.MuHelper; /// /// Configuration for the . /// -public class MuHelperConfiguration +public class MuHelperServerConfiguration { /// /// Gets or sets the cost of the helper per stage. This value is applied per diff --git a/src/GameLogic/OfflineLeveling/NullViewPlugInContainer.cs b/src/GameLogic/OfflineLeveling/NullViewPlugInContainer.cs new file mode 100644 index 000000000..f2c1e4f7d --- /dev/null +++ b/src/GameLogic/OfflineLeveling/NullViewPlugInContainer.cs @@ -0,0 +1,24 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +using System.Reflection; + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.PlugIns; + +/// +/// A view plugin container that returns proxy objects which do nothing, +/// effectively suppressing all network traffic for the offline leveling ghost player. +/// +internal sealed class NullViewPlugInContainer : ICustomPlugInContainer +{ + /// + public T? GetPlugIn() + where T : class, IViewPlugIn + { + return DispatchProxy.Create(); + } +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/NullViewProxy.cs b/src/GameLogic/OfflineLeveling/NullViewProxy.cs new file mode 100644 index 000000000..29d46a110 --- /dev/null +++ b/src/GameLogic/OfflineLeveling/NullViewProxy.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 System.Reflection; + +/// +/// A dynamic proxy that returns completed tasks for async methods +/// and default values for all other return types. +/// +internal class NullViewProxy : DispatchProxy +{ + private static readonly MethodInfo TaskFromResultMethod = + typeof(Task).GetMethod(nameof(Task.FromResult))!; + + private static readonly MethodInfo ValueTaskFromResultMethod = + typeof(ValueTask).GetMethod(nameof(ValueTask.FromResult))!; + + /// + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod is null) + { + return null; + } + + var returnType = targetMethod.ReturnType; + + if (returnType == typeof(Task)) + { + return Task.CompletedTask; + } + + if (returnType == typeof(ValueTask)) + { + return ValueTask.CompletedTask; + } + + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)) + { + var arg = returnType.GetGenericArguments()[0]; + return ValueTaskFromResultMethod.MakeGenericMethod(arg) + .Invoke(null, [GetDefaultValue(arg)]); + } + + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + var arg = returnType.GetGenericArguments()[0]; + return TaskFromResultMethod.MakeGenericMethod(arg) + .Invoke(null, [GetDefaultValue(arg)]); + } + + return GetDefaultValue(returnType); + } + + private static object? GetDefaultValue(Type type) + => type.IsValueType ? Activator.CreateInstance(type) : 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..1842bde7b --- /dev/null +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs @@ -0,0 +1,581 @@ +// +// 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.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.Items; +using MUnique.OpenMU.GameLogic.PlayerActions.Skills; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic.Views.World; +using MUnique.OpenMU.Interfaces; + +/// +/// 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 const byte FallbackViewRange = 10; + private const byte FallbackAttackRange = 2; + private const byte JewelItemGroup = 14; + + // How many 500 ms ticks equal one "second". + private const int TicksPerSecond = 2; + + private static readonly PickupItemAction PickupAction = new(); + + private readonly OfflineLevelingPlayer _player; + private readonly MuHelperPlayerConfiguration? _config; + + private Timer? _aiTimer; + private bool _disposed; + + private int _tickCounter; + private int _secondsElapsed; + + private IAttackable? _currentTarget; + private int _nearbyMonsterCount; + private int _comboStep; + + private int _buffSkillIndex; + private bool _buffTimerTriggered; + + /// + /// Initializes a new instance of the class. + /// + /// The offline leveling player. + public OfflineLevelingIntelligence(OfflineLevelingPlayer player) + { + this._player = player; + this._config = MuHelperPlayerConfiguration.TryDeserialize(player.SelectedCharacter?.MuHelperConfiguration); + } + + /// Starts the 500 ms AI timer. + public void Start() + { + this._aiTimer ??= new Timer( + _ => this.SafeTick(), + 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:Avoid async void methods", Justification = "Timer callback — exceptions are caught internally.")] + private async void SafeTick() + { + 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._player.IsAlive + || this._player.PlayerState.CurrentState != PlayerState.EnteredWorld) + { + return; + } + + if (++this._tickCounter >= TicksPerSecond) + { + this._tickCounter = 0; + this._secondsElapsed++; + } + + // CMuHelper::Work() order: Buff → RecoverHealth → ObtainItem → Regroup → Attack + if (!await this.BuffSelfAsync().ConfigureAwait(false)) + { + return; + } + + if (!await this.RecoverHealthAsync().ConfigureAwait(false)) + { + return; + } + + await this.PickupItemsAsync().ConfigureAwait(false); + + await this.AttackAsync().ConfigureAwait(false); + } + + // ── Step 1 – Buff ────────────────────────────────────────────────────────── + private async ValueTask BuffSelfAsync() + { + if (this._config is null) + { + return true; + } + + int[] buffIds = [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id]; + if (buffIds.All(id => id == 0)) + { + return true; + } + + if (!this._config.BuffOnDuration + && this._config.BuffCastIntervalSeconds > 0 + && this._secondsElapsed > 0 + && this._secondsElapsed % this._config.BuffCastIntervalSeconds == 0) + { + this._buffTimerTriggered = true; + } + + int buffId = buffIds[this._buffSkillIndex]; + if (buffId == 0) + { + this._buffSkillIndex = (this._buffSkillIndex + 1) % 3; + return true; + } + + var skillEntry = this._player.SkillList?.GetSkill((ushort)buffId); + if (skillEntry?.Skill?.MagicEffectDef is null) + { + this._buffSkillIndex = (this._buffSkillIndex + 1) % 3; + return true; + } + + bool alreadyActive = this._player.MagicEffectList.ActiveEffects.Values + .Any(e => e.Definition == skillEntry.Skill.MagicEffectDef); + + if (!alreadyActive || this._buffTimerTriggered) + { + await this._player.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false); + + this._buffSkillIndex = (this._buffSkillIndex + 1) % 3; + if (this._buffSkillIndex == 0) + { + this._buffTimerTriggered = false; + } + + return false; + } + + this._buffSkillIndex = (this._buffSkillIndex + 1) % 3; + return true; + } + + // ── Step 2 – Recover health ──────────────────────────────────────────────── + private async ValueTask RecoverHealthAsync() + { + if (this._config is null || this._player.Attributes is null) + { + return true; + } + + double hp = this._player.Attributes[Stats.CurrentHealth]; + double maxHp = this._player.Attributes[Stats.MaximumHealth]; + if (maxHp <= 0) + { + return true; + } + + int hpPercent = (int)(hp * 100.0 / maxHp); + + if (this._config.AutoHeal && hpPercent <= this._config.HealThresholdPercent) + { + var healSkill = this.FindSkillByType(SkillType.Regeneration); + if (healSkill is not null) + { + await this._player.ApplyRegenerationAsync(this._player, healSkill).ConfigureAwait(false); + return false; + } + } + + if (this._config.UseDrainLife && hpPercent <= this._config.HealThresholdPercent) + { + var drainSkill = this.FindDrainLifeSkill(); + if (drainSkill is not null) + { + this.RefreshTarget(); + if (this._currentTarget is not null) + { + await this.AttackTargetAsync(this._currentTarget, drainSkill).ConfigureAwait(false); + return false; + } + } + } + + return true; + } + + // ── Step 3 – Item pickup ─────────────────────────────────────────────────── + private async ValueTask PickupItemsAsync() + { + if (this._config is null || this._player.CurrentMap is not { } map) + { + return; + } + + // Nothing configured to pick up. + if (!this._config.PickAllItems + && !this._config.PickJewel + && !this._config.PickZen + && !this._config.PickAncient + && !this._config.PickExcellent + && !(this._config.PickExtraItems && this._config.ExtraItemNames.Count > 0)) + { + return; + } + + byte range = (byte)Math.Max(this._config.ObtainRange * 2, 3); + var drops = map.GetDropsInRange(this._player.Position, range); + + foreach (var drop in drops) + { + switch (drop) + { + case DroppedMoney when this._config.PickZen: + await PickupAction.PickupItemAsync(this._player, drop.Id).ConfigureAwait(false); + break; + + case DroppedItem droppedItem when this.ShouldPickUp(droppedItem.Item): + await PickupAction.PickupItemAsync(this._player, drop.Id).ConfigureAwait(false); + break; + } + } + } + + private bool ShouldPickUp(Item item) + { + if (this._config is null) + { + return false; + } + + if (this._config.PickAllItems) + { + return true; + } + + 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 && this._config.ExtraItemNames.Count > 0) + { + var itemName = item.Definition?.Name.ValueInNeutralLanguage; + if (itemName is not null + && this._config.ExtraItemNames.Any(n => + itemName.Contains(n, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + + return false; + } + + // ── Step 4 – Attack ──────────────────────────────────────────────────────── + private async ValueTask AttackAsync() + { + this.RefreshTarget(); + + if (this._currentTarget is null) + { + this._comboStep = 0; + return; + } + + byte range = this.GetEffectiveAttackRange(); + + if (!this._currentTarget.IsInRange(this._player.Position, range)) + { + return; + } + + if (this._config?.UseCombo == true) + { + await this.ExecuteComboAttackAsync().ConfigureAwait(false); + } + else + { + var skill = this.SelectAttackSkill(); + await this.AttackTargetAsync(this._currentTarget, skill).ConfigureAwait(false); + } + } + + /// + /// Attacks the target and broadcasts the skill/attack animation to nearby observers. + /// Without this broadcast, other players cannot see the ghost's animations. + /// + private async ValueTask AttackTargetAsync(IAttackable target, SkillEntry? skillEntry) + { + this._player.Rotation = this._player.GetDirectionTo(target); + + if (skillEntry?.Skill is { } skill) + { + if (skill.SkillType is SkillType.AreaSkillAutomaticHits or SkillType.AreaSkillExplicitTarget + or SkillType.AreaSkillExplicitHits) + { + // Broadcast the area skill animation to all nearby observers. + 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); + + // Hit only killable Monster targets in range — never players, guards, or NPCs. + var monstersInRange = this._player.CurrentMap? + .GetAttackablesInRange(target.Position, skill.Range) + .OfType() + .Where(m => m.IsAlive && !m.IsAtSafezone() + && m.Definition.ObjectKind == NpcObjectKind.Monster) + ?? []; + foreach (var monster in monstersInRange) + { + await monster.AttackByAsync(this._player, skillEntry, false).ConfigureAwait(false); + } + } + else + { + var strategy = + this._player.GameContext.PlugInManager.GetStrategy((short)skill.Number) + ?? new TargetedSkillDefaultPlugin(); + await strategy.PerformSkillAsync(this._player, target, (ushort)skill.Number).ConfigureAwait(false); + } + } + else + { + // Generic fallback attack if no skill is assigned or evaluated. + 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 void RefreshTarget() + { + if (this._currentTarget is { } t + && (!t.IsAlive || t.IsAtSafezone() || t.IsTeleporting + || !t.IsInRange(this._player.Position, (byte)(this.GetHuntingRange() + 4)))) + { + this._currentTarget = null; + } + + if (this._currentTarget is null) + { + this._currentTarget = this.FindNearestMonster(); + } + + this._nearbyMonsterCount = this.CountMonstersNearby(); + } + + private IAttackable? FindNearestMonster() + { + if (this._player.CurrentMap is not { } map) + { + return null; + } + + byte range = this.GetHuntingRange(); + return map.GetAttackablesInRange(this._player.Position, range) + .OfType() + .Where(m => m.IsAlive && !m.IsAtSafezone() + && m.Definition.ObjectKind == NpcObjectKind.Monster) + .MinBy(m => m.GetDistanceTo(this._player)); + } + + private int CountMonstersNearby() + { + if (this._player.CurrentMap is not { } map) + { + return 0; + } + + return map.GetAttackablesInRange(this._player.Position, this.GetHuntingRange()) + .OfType() + .Count(m => m.IsAlive && !m.IsAtSafezone() + && m.Definition.ObjectKind == NpcObjectKind.Monster); + } + + 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.Skill1ConditionAttacking, + 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.Skill2ConditionAttacking, + 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, bool conditionIsAttacking, + int subCond) + { + if (skillId <= 0) + { + return null; + } + + if (useTimer && timerInterval > 0 + && this._secondsElapsed > 0 + && this._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._config is null || this._currentTarget is null) + { + return; + } + + int[] ids = [this._config.BasicSkillId, this._config.ActivationSkill1Id, this._config.ActivationSkill2Id]; + SkillEntry? skill = ids.Any(id => id == 0) + ? null + : this._player.SkillList?.GetSkill((ushort)ids[this._comboStep]); + + await this.AttackTargetAsync(this._currentTarget, skill).ConfigureAwait(false); + this._comboStep = (this._comboStep + 1) % 3; + } + + private byte GetHuntingRange() + { + if (this._config is null || this._config.HuntingRange == 0) + { + return FallbackViewRange; + } + + return (byte)Math.Min(this._config.HuntingRange * 2, FallbackViewRange); + } + + private byte GetEffectiveAttackRange() + { + if (this._config?.BasicSkillId > 0) + { + var skill = this._player.SkillList?.GetSkill((ushort)this._config.BasicSkillId); + if (skill?.Skill?.Range is { } r && r > 0) + { + return (byte)r; + } + } + + 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) + .Select(s => (byte)s.Skill!.Range) + .DefaultIfEmpty(FallbackAttackRange) + .Max() ?? FallbackAttackRange; + } + + private SkillEntry? GetAnyOffensiveSkill() + => this._player.SkillList?.Skills.FirstOrDefault(s => + s.Skill is not null + && s.Skill.SkillType != SkillType.PassiveBoost + && s.Skill.SkillType != SkillType.Buff + && s.Skill.SkillType != SkillType.Regeneration); + + private SkillEntry? FindSkillByType(SkillType type) + => this._player.SkillList?.Skills.FirstOrDefault(s => s.Skill?.SkillType == type); + + private SkillEntry? FindDrainLifeSkill() + => this._player.SkillList?.Skills.FirstOrDefault(s => + s.Skill is not null + && s.Skill.Name.ValueInNeutralLanguage.Contains("Drain Life", StringComparison.OrdinalIgnoreCase)); +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs new file mode 100644 index 000000000..89d496b0d --- /dev/null +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs @@ -0,0 +1,113 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using System.Collections.Concurrent; + +/// +/// Tracks active instances across the game context. +/// One shared instance is stored on the so both the +/// chat command and the re-login plug-in can reach it. +/// +public sealed class OfflineLevelingManager +{ + private readonly ConcurrentDictionary _activePlayers = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// Captures the real player's state, disconnects the real client, then spawns a ghost + /// that continues leveling in the background. + /// + /// 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) + { + // Capture all needed references before the real player disconnects, + // since disconnect clears SelectedCharacter and Account from the Player instance. + var account = realPlayer.Account; + var character = realPlayer.SelectedCharacter; + + if (account is null || character is null) + { + return false; + } + + // Use a placeholder sentinel to atomically claim the slot before any async work. + // This prevents a second call for the same loginName from racing through while + // the real player is being disconnected and the ghost is being initialized. + var sentinel = new OfflineLevelingPlayer(realPlayer.GameContext); + if (!this._activePlayers.TryAdd(loginName, sentinel)) + { + // Another session is already active or being started for this account. + await sentinel.DisposeAsync().ConfigureAwait(false); + return false; + } + + try + { + // Log off the login server so the player can reconnect once the ghost is running. + if (realPlayer.GameContext is IGameServerContext gsCtx) + { + try + { + await gsCtx.LoginServer.LogOffAsync(loginName, gsCtx.Id).ConfigureAwait(false); + } + catch (Exception ex) + { + realPlayer.Logger.LogWarning(ex, "Could not log off from login server during offlevel."); + } + } + + // Suppress the PlayerDisconnected event so GameServer.OnPlayerDisconnectedAsync + // does not try to log off again or double-save. + realPlayer.SuppressDisconnectedEvent(); + + // DisconnectAsync: removes real player from map, fires PlayerLeftWorld + // (freeing the name slot in PlayersByCharacterName), and saves progress. + await realPlayer.DisconnectAsync().ConfigureAwait(false); + + // SuppressDisconnectedEvent() prevents OnPlayerDisconnectedAsync from firing, + // so realPlayer.DisposeAsync() is never called by the game server — which means + // realPlayer.PersistenceContext is never disposed and its EF DbContext is still + // alive and tracking the account/character entities. + // We must dispose it explicitly NOW so the entities become fully detached, + // allowing the ghost's fresh PersistenceContext to Attach and track them. + realPlayer.PersistenceContext.Dispose(); + + // Now the name slot is free — initialize the ghost in-place. + 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 player 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); +} \ 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..ed7690b66 --- /dev/null +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs @@ -0,0 +1,109 @@ +// +// 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.Views; +using MUnique.OpenMU.PlugIns; + +/// +/// A ghost player that stays in the world after the real client disconnects, +/// continuing to level up using the character's saved MU Helper configuration. +/// All view plugin calls are silently discarded — no network traffic is produced. +/// +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 login name of the account this offline player belongs to, + /// used to match it when the real player logs back in. + /// + public string? AccountLoginName => this.Account?.LoginName; + + /// + /// Gets the character name used to match on re-login. + /// + public string? CharacterName => this.SelectedCharacter?.Name; + + /// + /// Initializes the offline leveling ghost from pre-captured account and character + /// references. Must be called AFTER the real player has fully disconnected so that + /// the name slot in is free. + /// + /// The account the character belongs to. + /// The character to continue leveling. + /// true when the ghost was successfully started. + public async ValueTask InitializeAsync(Account account, Character character) + { + try + { + this.Account = account; + + // Attach the account (and all reachable entities including the character) + // to the ghost's own persistence context so that SaveChangesAsync persists + // XP, level-ups, and any other in-memory changes accumulated during offline leveling. + this.PersistenceContext.Attach(account); + + // Must follow the full valid transition path: Initial → LoginScreen → Authenticated → CharacterSelection. + // Skipping LoginScreen leaves the state at Initial, so TickAsync's EnteredWorld guard + // blocks every tick and the ghost never attacks. + 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); + + // AddPlayerAsync must come BEFORE SetSelectedCharacterAsync because it subscribes + // the PlayerEnteredWorld event, which SetSelectedCharacterAsync fires. + // Without this order, the ghost never gets added to PlayersByCharacterName. + await this.GameContext.AddPlayerAsync(this).ConfigureAwait(false); + + // SetSelectedCharacterAsync → OnPlayerEnteredWorldAsync → ClientReadyAfterMapChangeAsync + // which handles: setting CurrentMap, advancing state to EnteredWorld, and map.AddAsync. + // We must NOT call those steps ourselves — doing so would add the ghost to the map twice. + await this.SetSelectedCharacterAsync(character).ConfigureAwait(false); + + this._intelligence = new OfflineLevelingIntelligence(this); + this._intelligence.Start(); + + this.Logger.LogInformation( + "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 leveling player."); + return false; + } + } + + /// + /// Stops the AI loop and removes the ghost from the world. + /// Called when the real player logs back in. + /// + public async ValueTask StopAsync() + { + this._intelligence?.Dispose(); + this._intelligence = null; + + await this.DisconnectAsync().ConfigureAwait(false); + } + + /// + protected override ICustomPlugInContainer CreateViewPlugInContainer() + => new NullViewPlugInContainer(); +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingStopOnLoginPlugIn.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingStopOnLoginPlugIn.cs new file mode 100644 index 000000000..fb6344b70 --- /dev/null +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingStopOnLoginPlugIn.cs @@ -0,0 +1,48 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.OfflineLeveling; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Listens for the transition and stops any active +/// offline leveling session for that account so the real player can take over normally. +/// +[PlugIn] +[Display( + Name = nameof(PlugInResources.OfflineLevelingStopOnLoginPlugIn_Name), + Description = nameof(PlugInResources.OfflineLevelingStopOnLoginPlugIn_Description), + ResourceType = typeof(PlugInResources))] +[Guid("3E7A4D2C-81B5-4F6A-9C3D-0A2E5B8F1D9C")] +public sealed class OfflineLevelingStopOnLoginPlugIn : IPlayerStateChangedPlugIn +{ + /// + public async ValueTask PlayerStateChangedAsync(Player player, State previousState, State currentState) + { + // Ignore it to avoid immediately stopping the session we just started. + if (currentState != PlayerState.Authenticated || player is OfflineLevelingPlayer) + { + return; + } + + var loginName = player.Account?.LoginName; + if (loginName is null) + { + return; + } + + var manager = player.GameContext.OfflineLevelingManager; + if (!manager.IsActive(loginName)) + { + return; + } + + player.Logger.LogInformation("Account {LoginName} authenticated, stopping offline leveling.", loginName); + + await manager.StopAsync(loginName).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index 4cda5d326..594199341 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -1400,6 +1400,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. /// @@ -2769,4 +2780,4 @@ public GMMagicEffectDefinition() this.PowerUpDefinitions = new List(0); } } -} +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/ChatCommands/Arguments/OfflineLevelingChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/Arguments/OfflineLevelingChatCommandPlugIn.cs new file mode 100644 index 000000000..14506f101 --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/Arguments/OfflineLevelingChatCommandPlugIn.cs @@ -0,0 +1,82 @@ +// +// 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.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; + } + + 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; + } + + // Show the message before disconnecting so the player sees it. + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.OfflineLevelingStarted)).ConfigureAwait(false); + + // StartAsync handles everything: login-server logoff, suppress event, + // disconnect real player, then spawn the ghost. + 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..e4d5def47 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -1580,5 +1580,59 @@ 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); + } + } } -} +} \ No newline at end of file diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx index 304ee6743..b294c2df9 100644 --- a/src/GameLogic/Properties/PlayerMessage.resx +++ b/src/GameLogic/Properties/PlayerMessage.resx @@ -624,4 +624,22 @@ 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. + \ No newline at end of file diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 4ae51954c..acefdf41e 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -3094,5 +3094,35 @@ 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); + } + } + + public static string OfflineLevelingStopOnLoginPlugIn_Name { + get { + return ResourceManager.GetString("OfflineLevelingStopOnLoginPlugIn_Name", resourceCulture); + } + } + + 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 cd9958fe4..0de31adc9 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1128,4 +1128,16 @@ [{mapName}] Invasion! + + Offline Leveling Command + + + Allows players to start offline leveling with /offlevel. The character stays in the world after the client disconnects and stops when the player logs back in. + + + Offline Leveling: Stop on Login + + + Automatically stops an active offline leveling session when the account owner logs back in, allowing the real player to take over their character. + diff --git a/src/Persistence/Initialization/Updates/AddOfflineLevelingPlugIn.cs b/src/Persistence/Initialization/Updates/AddOfflineLevelingPlugIn.cs new file mode 100644 index 000000000..9781a6286 --- /dev/null +++ b/src/Persistence/Initialization/Updates/AddOfflineLevelingPlugIn.cs @@ -0,0 +1,75 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Updates; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.OfflineLeveling; +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; +using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix; +using MUnique.OpenMU.PlugIns; + +/// +/// Registers the and +/// in the database so that they are +/// active on existing installations that were initialized before these plugins existed. +/// +[PlugIn] +[Display(Name = PlugInName, Description = PlugInDescription)] +[Guid("C2A5E8F3-4D1B-4E9A-7F6C-3B0D2A9E4C17")] +public class AddOfflineLevelingPlugIn : UpdatePlugInBase +{ + /// + /// The plug in name. + /// + internal const string PlugInName = "Add Offline Leveling"; + + /// + /// The plug in description. + /// + internal const string PlugInDescription = "Registers the offline leveling chat command and stop-on-login plugin configurations."; + + /// + public override string Name => PlugInName; + + /// + public override string Description => PlugInDescription; + + /// + public override UpdateVersion Version => UpdateVersion.AddOfflineLeveling; + + /// + public override string DataInitializationKey => DataInitialization.Id; + + /// + public override bool IsMandatory => true; + + /// + public override DateTime CreatedAt => new(2025, 3, 11, 12, 0, 0, DateTimeKind.Utc); + + /// + protected override ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration) + { + this.AddPlugInIfMissing(context, gameConfiguration); + this.AddPlugInIfMissing(context, gameConfiguration); + return ValueTask.CompletedTask; + } + + private void AddPlugInIfMissing(IContext context, GameConfiguration gameConfiguration) + { + var typeId = typeof(TPlugIn).GUID; + + if (gameConfiguration.PlugInConfigurations.Any(c => c.TypeId == typeId)) + { + return; + } + + var config = context.CreateNew(); + config.SetGuid(typeId); + config.TypeId = typeId; + config.IsActive = true; + gameConfiguration.PlugInConfigurations.Add(config); + } +} diff --git a/src/Persistence/Initialization/Updates/UpdateVersion.cs b/src/Persistence/Initialization/Updates/UpdateVersion.cs index 1e771cc39..7e70a8f1f 100644 --- a/src/Persistence/Initialization/Updates/UpdateVersion.cs +++ b/src/Persistence/Initialization/Updates/UpdateVersion.cs @@ -344,4 +344,9 @@ public enum UpdateVersion /// The version of the . /// AddProjectileCountToTripleShot = 67, + + /// + /// The version of the . + /// + AddOfflineLeveling = 68, } \ No newline at end of file diff --git a/src/Startup/Dockerfile b/src/Startup/Dockerfile index 9d92d4336..5f8003493 100644 --- a/src/Startup/Dockerfile +++ b/src/Startup/Dockerfile @@ -1,5 +1,9 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base WORKDIR /app + +RUN apk add --no-cache icu-libs +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false + EXPOSE 8080 EXPOSE 55901 EXPOSE 55902 @@ -12,25 +16,38 @@ EXPOSE 55980 FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build WORKDIR /src -COPY ["Directory.Packages.props", "."] -COPY ["Directory.Build.props", "."] -COPY ["Startup/MUnique.OpenMU.Startup.csproj", "Startup/"] + +COPY Directory.Packages.props . +COPY Directory.Build.props . + +COPY Startup/*.csproj Startup/ +COPY Persistence/**/*.csproj Persistence/ + RUN dotnet restore "Startup/MUnique.OpenMU.Startup.csproj" + COPY . . -WORKDIR "/src/Startup" -RUN dotnet build "MUnique.OpenMU.Startup.csproj" -c Release -o /app/build -p:ci=true + +WORKDIR /src/Startup + +RUN dotnet build "MUnique.OpenMU.Startup.csproj" \ + -c Release \ + -o /app/build \ + -p:ci=true FROM build AS publish -RUN dotnet publish "MUnique.OpenMU.Startup.csproj" -c Release -o /app/publish -p:ci=true +RUN dotnet publish "MUnique.OpenMU.Startup.csproj" \ + -c Release \ + -o /app/publish \ + -p:ci=true FROM base AS final WORKDIR /app + COPY --from=publish /app/publish . -RUN mkdir /app/logs -RUN chmod 777 /app/logs -RUN apk add --no-cache icu-libs +RUN mkdir -p /app/logs && chmod 777 /app/logs -ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false -USER $APP_UID -ENTRYPOINT ["dotnet", "MUnique.OpenMU.Startup.dll", "-autostart"] +ARG APP_UID=1000 +USER ${APP_UID} + +ENTRYPOINT ["dotnet", "MUnique.OpenMU.Startup.dll", "-autostart"] \ No newline at end of file From 72de4dd5a5418d6769e2a1e129923ff65cdfe905 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:03:34 -0300 Subject: [PATCH 06/21] feature: implement offlevel xp --- .../OfflineLeveling/OfflineLevelingPlayer.cs | 43 +++++++------------ 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs index ed7690b66..e549728b8 100644 --- a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs @@ -9,9 +9,7 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; using MUnique.OpenMU.PlugIns; /// -/// A ghost player that stays in the world after the real client disconnects, -/// continuing to level up using the character's saved MU Helper configuration. -/// All view plugin calls are silently discarded — no network traffic is produced. +/// A ghost player that continues leveling after the real client disconnects. /// public sealed class OfflineLevelingPlayer : Player { @@ -27,52 +25,42 @@ public OfflineLevelingPlayer(IGameContext gameContext) } /// - /// Gets the login name of the account this offline player belongs to, - /// used to match it when the real player logs back in. + /// Gets the login name of the account. /// public string? AccountLoginName => this.Account?.LoginName; /// - /// Gets the character name used to match on re-login. + /// Gets the character name. /// public string? CharacterName => this.SelectedCharacter?.Name; /// - /// Initializes the offline leveling ghost from pre-captured account and character - /// references. Must be called AFTER the real player has fully disconnected so that - /// the name slot in is free. + /// Initializes the ghost player from captured references. /// - /// The account the character belongs to. - /// The character to continue leveling. - /// true when the ghost was successfully started. + /// The account. + /// The character. + /// true if successfully started. public async ValueTask InitializeAsync(Account account, Character character) { try { this.Account = account; - - // Attach the account (and all reachable entities including the character) - // to the ghost's own persistence context so that SaveChangesAsync persists - // XP, level-ups, and any other in-memory changes accumulated during offline leveling. this.PersistenceContext.Attach(account); - // Must follow the full valid transition path: Initial → LoginScreen → Authenticated → CharacterSelection. - // Skipping LoginScreen leaves the state at Initial, so TickAsync's EnteredWorld guard - // blocks every tick and the ghost never attacks. + // 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); - // AddPlayerAsync must come BEFORE SetSelectedCharacterAsync because it subscribes - // the PlayerEnteredWorld event, which SetSelectedCharacterAsync fires. - // Without this order, the ghost never gets added to PlayersByCharacterName. + // Add to context and set character. await this.GameContext.AddPlayerAsync(this).ConfigureAwait(false); - - // SetSelectedCharacterAsync → OnPlayerEnteredWorldAsync → ClientReadyAfterMapChangeAsync - // which handles: setting CurrentMap, advancing state to EnteredWorld, and map.AddAsync. - // We must NOT call those steps ourselves — doing so would add the ghost to the map twice. await this.SetSelectedCharacterAsync(character).ConfigureAwait(false); + if (this.SelectedCharacter is { } selectedCharacter) + { + this.PersistenceContext.Attach(selectedCharacter); + } + this._intelligence = new OfflineLevelingIntelligence(this); this._intelligence.Start(); @@ -92,8 +80,7 @@ public async ValueTask InitializeAsync(Account account, Character characte } /// - /// Stops the AI loop and removes the ghost from the world. - /// Called when the real player logs back in. + /// Stops the ghost player and removes it from the world. /// public async ValueTask StopAsync() { From d01d6e3cfa61e3aa528aa2dec5ff7405d9a3a719 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:49:42 -0300 Subject: [PATCH 07/21] feature: offlevel combo and walking --- .../OfflineLevelingIntelligence.cs | 378 +++++++++++++++--- 1 file changed, 312 insertions(+), 66 deletions(-) diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs index 1842bde7b..af8230cac 100644 --- a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs @@ -8,6 +8,8 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Pathfinding; +using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.NPC; @@ -33,7 +35,7 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; /// public sealed class OfflineLevelingIntelligence : IDisposable { - private const byte FallbackViewRange = 10; + private const byte FallbackViewRange = 5; private const byte FallbackAttackRange = 2; private const byte JewelItemGroup = 14; @@ -48,16 +50,24 @@ public sealed class OfflineLevelingIntelligence : IDisposable private Timer? _aiTimer; private bool _disposed; + // Tick State private int _tickCounter; private int _secondsElapsed; + // Combat State private IAttackable? _currentTarget; private int _nearbyMonsterCount; - private int _comboStep; + private int _currentComboStep; + private int _skillCooldownTicks; + // Buff State private int _buffSkillIndex; private bool _buffTimerTriggered; + // Movement State + private Point _originalPosition; + private int _secondsAwayFromOrigin; + /// /// Initializes a new instance of the class. /// @@ -66,6 +76,7 @@ public OfflineLevelingIntelligence(OfflineLevelingPlayer player) { this._player = player; this._config = MuHelperPlayerConfiguration.TryDeserialize(player.SelectedCharacter?.MuHelperConfiguration); + this._originalPosition = player.Position; } /// Starts the 500 ms AI timer. @@ -125,8 +136,14 @@ private async ValueTask TickAsync() this._secondsElapsed++; } + if (this._skillCooldownTicks > 0) + { + this._skillCooldownTicks--; + return; + } + // CMuHelper::Work() order: Buff → RecoverHealth → ObtainItem → Regroup → Attack - if (!await this.BuffSelfAsync().ConfigureAwait(false)) + if (!await this.PerformBuffsAsync().ConfigureAwait(false)) { return; } @@ -138,11 +155,24 @@ private async ValueTask TickAsync() await this.PickupItemsAsync().ConfigureAwait(false); - await this.AttackAsync().ConfigureAwait(false); + if (this._player.IsWalking) + { + return; + } + + if (!await this.RegroupAsync().ConfigureAwait(false)) + { + return; + } + + await this.AttackTargetsAsync().ConfigureAwait(false); } - // ── Step 1 – Buff ────────────────────────────────────────────────────────── - private async ValueTask BuffSelfAsync() + /// + /// Checks and applies self-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. + private async ValueTask PerformBuffsAsync() { if (this._config is null) { @@ -197,7 +227,10 @@ private async ValueTask BuffSelfAsync() return true; } - // ── Step 2 – Recover health ──────────────────────────────────────────────── + /// + /// Manages HP recovery via Regeneration or Drain Life skills. + /// + /// True, if the loop can continue; False, if a recovery skill was used. private async ValueTask RecoverHealthAsync() { if (this._config is null || this._player.Attributes is null) @@ -232,7 +265,7 @@ private async ValueTask RecoverHealthAsync() this.RefreshTarget(); if (this._currentTarget is not null) { - await this.AttackTargetAsync(this._currentTarget, drainSkill).ConfigureAwait(false); + await this.PerformAttackAsync(this._currentTarget, drainSkill, false).ConfigureAwait(false); return false; } } @@ -241,7 +274,9 @@ private async ValueTask RecoverHealthAsync() return true; } - // ── Step 3 – Item pickup ─────────────────────────────────────────────────── + /// + /// Scans for and picks up items within configurable range. + /// private async ValueTask PickupItemsAsync() { if (this._config is null || this._player.CurrentMap is not { } map) @@ -260,7 +295,7 @@ private async ValueTask PickupItemsAsync() return; } - byte range = (byte)Math.Max(this._config.ObtainRange * 2, 3); + byte range = (byte)Math.Max(this._config.ObtainRange, 3); var drops = map.GetDropsInRange(this._player.Position, range); foreach (var drop in drops) @@ -320,21 +355,59 @@ private bool ShouldPickUp(Item item) return false; } - // ── Step 4 – Attack ──────────────────────────────────────────────────────── - private async ValueTask AttackAsync() + /// + /// 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. + private async ValueTask RegroupAsync() + { + if (this._config is null || !this._config.ReturnToOriginalPosition) + { + return true; + } + + var distance = this._player.GetDistanceTo(this._originalPosition); + if (distance > 1) + { + if (this._tickCounter == 0) + { + this._secondsAwayFromOrigin++; + } + } + else + { + this._secondsAwayFromOrigin = 0; + return true; + } + + if (this._secondsAwayFromOrigin >= this._config.MaxSecondsAway || distance > this.GetHuntingRange()) + { + await this.WalkToAsync(this._originalPosition).ConfigureAwait(false); + this._secondsAwayFromOrigin = 0; + return false; + } + + return true; + } + + /// + /// Main combat logic: selects a target and performs single or combo attacks. + /// + private async ValueTask AttackTargetsAsync() { this.RefreshTarget(); if (this._currentTarget is null) { - this._comboStep = 0; + this._currentComboStep = 0; return; } byte range = this.GetEffectiveAttackRange(); - if (!this._currentTarget.IsInRange(this._player.Position, range)) + if (!this.IsTargetInAttackRange(this._currentTarget, range)) { + await this.MoveCloserToTargetAsync(this._currentTarget, range).ConfigureAwait(false); return; } @@ -345,65 +418,102 @@ private async ValueTask AttackAsync() else { var skill = this.SelectAttackSkill(); - await this.AttackTargetAsync(this._currentTarget, skill).ConfigureAwait(false); + await this.PerformAttackAsync(this._currentTarget, skill, false).ConfigureAwait(false); + } + } + + private bool IsTargetInAttackRange(IAttackable target, byte range) + { + return target.IsInRange(this._player.Position, range); + } + + private async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range) + { + // Move closer to the target if it's within hunting range relative to origin. + if (target.IsInRange(this._originalPosition, this.GetHuntingRange())) + { + var walkTarget = this._player.CurrentMap!.Terrain.GetRandomCoordinate(target.Position, range); + await this.WalkToAsync(walkTarget).ConfigureAwait(false); } } /// - /// Attacks the target and broadcasts the skill/attack animation to nearby observers. - /// Without this broadcast, other players cannot see the ghost's animations. + /// Executes an attack against a target, handling skill types and observer animations. /// - private async ValueTask AttackTargetAsync(IAttackable target, SkillEntry? skillEntry) + private async ValueTask PerformAttackAsync(IAttackable target, SkillEntry? skillEntry, bool isCombo) { this._player.Rotation = this._player.GetDirectionTo(target); - if (skillEntry?.Skill is { } skill) + if (skillEntry?.Skill is not { } skill) { - if (skill.SkillType is SkillType.AreaSkillAutomaticHits or SkillType.AreaSkillExplicitTarget - or SkillType.AreaSkillExplicitHits) - { - // Broadcast the area skill animation to all nearby observers. - 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); - - // Hit only killable Monster targets in range — never players, guards, or NPCs. - var monstersInRange = this._player.CurrentMap? - .GetAttackablesInRange(target.Position, skill.Range) - .OfType() - .Where(m => m.IsAlive && !m.IsAtSafezone() - && m.Definition.ObjectKind == NpcObjectKind.Monster) - ?? []; - foreach (var monster in monstersInRange) - { - await monster.AttackByAsync(this._player, skillEntry, false).ConfigureAwait(false); - } - } - else - { - var strategy = - this._player.GameContext.PlugInManager.GetStrategy((short)skill.Number) - ?? new TargetedSkillDefaultPlugin(); - await strategy.PerformSkillAsync(this._player, target, (ushort)skill.Number).ConfigureAwait(false); - } + await this.PerformPhysicalAttackAsync(target).ConfigureAwait(false); + return; + } + + if (skill.SkillType is SkillType.AreaSkillAutomaticHits or SkillType.AreaSkillExplicitTarget or SkillType.AreaSkillExplicitHits) + { + await this.PerformAreaSkillAttackAsync(target, skillEntry, isCombo).ConfigureAwait(false); } else { - // Generic fallback attack if no skill is assigned or evaluated. - await target.AttackByAsync(this._player, null, false).ConfigureAwait(false); + await this.PerformTargetedSkillAttackAsync(target, skill).ConfigureAwait(false); + } + } + + private async ValueTask PerformPhysicalAttackAsync(IAttackable target) + { + // Generic fallback attack if no skill is assigned or evaluated. + await target.AttackByAsync(this._player, null, false).ConfigureAwait(false); - await this._player.ForEachWorldObserverAsync( - p => p.ShowAnimationAsync(this._player, 120, target, this._player.Rotation), + await this._player.ForEachWorldObserverAsync( + p => p.ShowAnimationAsync(this._player, 120, target, this._player.Rotation), + includeThis: true).ConfigureAwait(false); + } + + private async ValueTask PerformAreaSkillAttackAsync(IAttackable target, SkillEntry skillEntry, bool isCombo) + { + var skill = skillEntry.Skill!; + + if (isCombo) + { + // Broadcast the combo finisher animation (the "boom") to all observers. + await this._player.ForEachWorldObserverAsync( + p => p.ShowComboAnimationAsync(this._player, target), includeThis: true).ConfigureAwait(false); } + + // Broadcast the area skill animation to all nearby observers. + 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); + + // Hit only killable Monster targets in range, never players, guards, or NPCs. + var monstersInRange = this._player.CurrentMap? + .GetAttackablesInRange(target.Position, skill.Range) + .OfType() + .Where(m => m.IsAlive && !m.IsAtSafezone() + && m.Definition.ObjectKind == NpcObjectKind.Monster) + ?? []; + foreach (var monster in monstersInRange) + { + await monster.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false); + } + } + + private async ValueTask PerformTargetedSkillAttackAsync(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 void RefreshTarget() { if (this._currentTarget is { } t && (!t.IsAlive || t.IsAtSafezone() || t.IsTeleporting - || !t.IsInRange(this._player.Position, (byte)(this.GetHuntingRange() + 4)))) + || !t.IsInRange(this._originalPosition, this.GetHuntingRange()))) { this._currentTarget = null; } @@ -424,7 +534,7 @@ private void RefreshTarget() } byte range = this.GetHuntingRange(); - return map.GetAttackablesInRange(this._player.Position, range) + return map.GetAttackablesInRange(this._originalPosition, range) .OfType() .Where(m => m.IsAlive && !m.IsAtSafezone() && m.Definition.ObjectKind == NpcObjectKind.Monster) @@ -438,7 +548,7 @@ private int CountMonstersNearby() return 0; } - return map.GetAttackablesInRange(this._player.Position, this.GetHuntingRange()) + return map.GetAttackablesInRange(this._originalPosition, this.GetHuntingRange()) .OfType() .Count(m => m.IsAlive && !m.IsAtSafezone() && m.Definition.ObjectKind == NpcObjectKind.Monster); @@ -479,6 +589,9 @@ private int CountMonstersNearby() return this.GetAnyOffensiveSkill(); } + /// + /// Evaluates if a skill should be used based on its configured conditions (Monsters count). + /// private SkillEntry? EvaluateConditionalSkill( int skillId, bool useTimer, int timerInterval, @@ -519,18 +632,101 @@ private int CountMonstersNearby() private async ValueTask ExecuteComboAttackAsync() { - if (this._config is null || this._currentTarget is null) + if (this._currentTarget is null) + { + this._currentComboStep = 0; + return; + } + + var ids = this.GetConfiguredComboSkillIds(); + if (ids.Count == 0) { + await this.PerformAttackAsync(this._currentTarget, this.GetAnyOffensiveSkill(), false).ConfigureAwait(false); return; } - int[] ids = [this._config.BasicSkillId, this._config.ActivationSkill1Id, this._config.ActivationSkill2Id]; - SkillEntry? skill = ids.Any(id => id == 0) - ? null - : this._player.SkillList?.GetSkill((ushort)ids[this._comboStep]); + var skillId = (ushort)ids[this._currentComboStep % ids.Count]; + var skillEntry = this._player.SkillList?.GetSkill(skillId); + + // If the current combo skill is out of range, we reset the rotation to Basic skill. + 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.PerformAttackAsync(this._currentTarget, skillEntry, isActuallyCombo).ConfigureAwait(false); + + if (isActuallyCombo) + { + // Combo finisher cooldown (3 ticks = 1.5s) to allow animation to finish. + this._skillCooldownTicks = 3; + this._currentComboStep = 0; + } + else + { + // Mini-delay (1 tick = 500ms) between skills to help client processing. + this._skillCooldownTicks = 1; + this._currentComboStep++; + } + } + else + { + // Targeted skill handles its own registration in the plugin. + await this.PerformAttackAsync(this._currentTarget, skillEntry, false).ConfigureAwait(false); + + var stateAfter = comboState?.CurrentState; + + // Detection: If it wasn't initial before and is initial now, it either finished or reset. + if (stateBefore != comboState?.InitialState && stateAfter == comboState?.InitialState) + { + // Combo finished or reset. + this._skillCooldownTicks = 3; + this._currentComboStep = 0; + } + else + { + // Mini-delay (1 tick = 500ms) between skills to help client processing. + this._skillCooldownTicks = 1; + 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); + } - await this.AttackTargetAsync(this._currentTarget, skill).ConfigureAwait(false); - this._comboStep = (this._comboStep + 1) % 3; + if (this._config.ActivationSkill2Id > 0) + { + ids.Add(this._config.ActivationSkill2Id); + } + } + + return ids; } private byte GetHuntingRange() @@ -540,17 +736,31 @@ private byte GetHuntingRange() return FallbackViewRange; } - return (byte)Math.Min(this._config.HuntingRange * 2, FallbackViewRange); + return (byte)Math.Min(this._config.HuntingRange, FallbackViewRange); } private byte GetEffectiveAttackRange() { - if (this._config?.BasicSkillId > 0) + if (this._config is null) { - var skill = this._player.SkillList?.GetSkill((ushort)this._config.BasicSkillId); - if (skill?.Skill?.Range is { } r && r > 0) + return FallbackAttackRange; + } + + var skillIds = this._config.UseCombo + ? this.GetConfiguredComboSkillIds() + : (this._config.BasicSkillId > 0 ? [(int)this._config.BasicSkillId] : new List()); + + 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)r; + // In combo mode, we use the minimum range to ensure all skills can hit. + return (byte)ranges.Min(); } } @@ -578,4 +788,40 @@ s.Skill is not null => this._player.SkillList?.Skills.FirstOrDefault(s => s.Skill is not null && s.Skill.Name.ValueInNeutralLanguage.Contains("Drain Life", StringComparison.OrdinalIgnoreCase)); + + private 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; + } + + // Convert path result to walking steps. Walker handles max 16 steps. + 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); + } + } } \ No newline at end of file From 01ed93f67f0e189b5d101e76e725df4175162570 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:41:08 -0300 Subject: [PATCH 08/21] code review: apply gemini code suggestion and fixes --- .../MuHelper/MuHelperPlayerConfiguration.cs | 2 +- .../OfflineLevelingIntelligence.cs | 68 ++++++---- src/Web/AdminPanel/Pages/EditBase.cs | 28 +++- .../Properties/Resources.Designer.cs | 11 +- src/Web/AdminPanel/Properties/Resources.resx | 5 +- src/Web/ItemEditor/MuItemStorage.razor.cs | 22 +++- .../Components/Form/ItemStorageField.razor | 122 +++++++++++++++--- 7 files changed, 204 insertions(+), 54 deletions(-) diff --git a/src/GameLogic/MuHelper/MuHelperPlayerConfiguration.cs b/src/GameLogic/MuHelper/MuHelperPlayerConfiguration.cs index a40105282..8265922de 100644 --- a/src/GameLogic/MuHelper/MuHelperPlayerConfiguration.cs +++ b/src/GameLogic/MuHelper/MuHelperPlayerConfiguration.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs index af8230cac..c3a04905a 100644 --- a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs @@ -37,10 +37,21 @@ public sealed class OfflineLevelingIntelligence : IDisposable { private const byte FallbackViewRange = 5; private const byte FallbackAttackRange = 2; + private const byte MinPickupRange = 3; + private const byte RegroupDistanceThreshold = 1; + private const byte JewelItemGroup = 14; - // How many 500 ms ticks equal one "second". private const int TicksPerSecond = 2; + private const int ComboFinisherDelayTicks = 3; + private const int InterSkillDelayTicks = 1; + + private const int BuffSlotCount = 3; + + private const byte PhysicalAttackAnimationId = 120; + private const short DrainLifeBaseSkillId = 214; + private const short DrainLifeStrengthenerSkillId = 458; + private const short DrainLifeMasterySkillId = 462; private static readonly PickupItemAction PickupAction = new(); @@ -196,14 +207,14 @@ private async ValueTask PerformBuffsAsync() int buffId = buffIds[this._buffSkillIndex]; if (buffId == 0) { - this._buffSkillIndex = (this._buffSkillIndex + 1) % 3; + this._buffSkillIndex = (this._buffSkillIndex + 1) % BuffSlotCount; return true; } var skillEntry = this._player.SkillList?.GetSkill((ushort)buffId); if (skillEntry?.Skill?.MagicEffectDef is null) { - this._buffSkillIndex = (this._buffSkillIndex + 1) % 3; + this._buffSkillIndex = (this._buffSkillIndex + 1) % BuffSlotCount; return true; } @@ -214,7 +225,7 @@ private async ValueTask PerformBuffsAsync() { await this._player.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false); - this._buffSkillIndex = (this._buffSkillIndex + 1) % 3; + this._buffSkillIndex = (this._buffSkillIndex + 1) % BuffSlotCount; if (this._buffSkillIndex == 0) { this._buffTimerTriggered = false; @@ -223,7 +234,7 @@ private async ValueTask PerformBuffsAsync() return false; } - this._buffSkillIndex = (this._buffSkillIndex + 1) % 3; + this._buffSkillIndex = (this._buffSkillIndex + 1) % BuffSlotCount; return true; } @@ -295,7 +306,7 @@ private async ValueTask PickupItemsAsync() return; } - byte range = (byte)Math.Max(this._config.ObtainRange, 3); + byte range = (byte)Math.Max(this._config.ObtainRange, MinPickupRange); var drops = map.GetDropsInRange(this._player.Position, range); foreach (var drop in drops) @@ -367,7 +378,7 @@ private async ValueTask RegroupAsync() } var distance = this._player.GetDistanceTo(this._originalPosition); - if (distance > 1) + if (distance > RegroupDistanceThreshold) { if (this._tickCounter == 0) { @@ -466,7 +477,7 @@ private async ValueTask PerformPhysicalAttackAsync(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), + p => p.ShowAnimationAsync(this._player, PhysicalAttackAnimationId, target, this._player.Rotation), includeThis: true).ConfigureAwait(false); } @@ -483,6 +494,7 @@ await this._player.ForEachWorldObserverAsync( } // Broadcast the area skill animation to all nearby observers. + // Rotation is converted from 360 degrees to a single byte (0-255) as per protocol. 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), @@ -614,10 +626,10 @@ private int CountMonstersNearby() { int threshold = subCond switch { - 0 => 2, - 1 => 3, - 2 => 4, - 3 => 5, + 0 => 2, // At least 2 monsters + 1 => 3, // At least 3 monsters + 2 => 4, // At least 4 monsters + 3 => 5, // At least 5 monsters _ => int.MaxValue, }; @@ -672,13 +684,13 @@ private async ValueTask ExecuteComboAttackAsync() if (isActuallyCombo) { // Combo finisher cooldown (3 ticks = 1.5s) to allow animation to finish. - this._skillCooldownTicks = 3; + this._skillCooldownTicks = ComboFinisherDelayTicks; this._currentComboStep = 0; } else { // Mini-delay (1 tick = 500ms) between skills to help client processing. - this._skillCooldownTicks = 1; + this._skillCooldownTicks = InterSkillDelayTicks; this._currentComboStep++; } } @@ -693,13 +705,13 @@ private async ValueTask ExecuteComboAttackAsync() if (stateBefore != comboState?.InitialState && stateAfter == comboState?.InitialState) { // Combo finished or reset. - this._skillCooldownTicks = 3; + this._skillCooldownTicks = ComboFinisherDelayTicks; this._currentComboStep = 0; } else { // Mini-delay (1 tick = 500ms) between skills to help client processing. - this._skillCooldownTicks = 1; + this._skillCooldownTicks = InterSkillDelayTicks; this._currentComboStep++; } } @@ -748,7 +760,7 @@ private byte GetEffectiveAttackRange() var skillIds = this._config.UseCombo ? this.GetConfiguredComboSkillIds() - : (this._config.BasicSkillId > 0 ? [(int)this._config.BasicSkillId] : new List()); + : (this._config.BasicSkillId > 0 ? [this._config.BasicSkillId] : []); if (skillIds.Count > 0) { @@ -764,30 +776,30 @@ private byte GetEffectiveAttackRange() } } + return this.GetOffensiveSkills() + .Select(s => (byte)s.Skill!.Range) + .DefaultIfEmpty(FallbackAttackRange) + .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) - .Select(s => (byte)s.Skill!.Range) - .DefaultIfEmpty(FallbackAttackRange) - .Max() ?? FallbackAttackRange; + ?? []; } - private SkillEntry? GetAnyOffensiveSkill() - => this._player.SkillList?.Skills.FirstOrDefault(s => - s.Skill is not null - && s.Skill.SkillType != SkillType.PassiveBoost - && s.Skill.SkillType != SkillType.Buff - && s.Skill.SkillType != SkillType.Regeneration); + private SkillEntry? GetAnyOffensiveSkill() => this.GetOffensiveSkills().FirstOrDefault(); private SkillEntry? FindSkillByType(SkillType type) => this._player.SkillList?.Skills.FirstOrDefault(s => s.Skill?.SkillType == type); private SkillEntry? FindDrainLifeSkill() => this._player.SkillList?.Skills.FirstOrDefault(s => - s.Skill is not null - && s.Skill.Name.ValueInNeutralLanguage.Contains("Drain Life", StringComparison.OrdinalIgnoreCase)); + s.Skill is { Number: DrainLifeBaseSkillId or DrainLifeStrengthenerSkillId or DrainLifeMasterySkillId }); private async ValueTask WalkToAsync(Point target) { diff --git a/src/Web/AdminPanel/Pages/EditBase.cs b/src/Web/AdminPanel/Pages/EditBase.cs index b8730d2bc..e1130dae4 100644 --- a/src/Web/AdminPanel/Pages/EditBase.cs +++ b/src/Web/AdminPanel/Pages/EditBase.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -187,6 +187,8 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) var editorsMarkup = this.GetEditorsMarkup(); builder.AddMarkupContent(10, $"

{Resources.Edit} {this.Type!.GetTypeCaption()}

{downloadMarkup}{editorsMarkup}\r\n"); + this.RenderRefreshButton(builder); + builder.OpenComponent>(11); builder.AddAttribute(12, nameof(CascadingValue.Value), this._persistenceContext); builder.AddAttribute(13, nameof(CascadingValue.IsFixed), this._isOwningContext); @@ -267,6 +269,30 @@ protected async Task SaveChangesAsync() } } + /// + /// Refreshes the data by discarding changes and reloading it from the database. + /// + protected async Task RefreshAsync() + { + await this.EditDataSource.ForceDiscardChangesAsync().ConfigureAwait(true); + this._loadingState = DataLoadingState.LoadingStarted; + var cts = new CancellationTokenSource(); + this._disposeCts = cts; + this._loadTask = Task.Run(() => this.LoadDataAsync(cts.Token), cts.Token); + this.StateHasChanged(); + } + + private void RenderRefreshButton(RenderTreeBuilder builder) + { + builder.OpenElement(100, "p"); + builder.OpenElement(101, "button"); + builder.AddAttribute(102, "class", "btn btn-secondary"); + builder.AddAttribute(103, "onclick", EventCallback.Factory.Create(this, this.RefreshAsync)); + builder.AddContent(104, Resources.Refresh); + builder.CloseElement(); + builder.CloseElement(); + } + /// /// Gets the optional editors markup for the current type. /// diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 16ac0d403..881d8d6be 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 @@ -918,6 +918,15 @@ internal static string Reload { } } + /// + /// Looks up a localized string similar to Refresh. + /// + internal static string Refresh { + get { + return ResourceManager.GetString("Refresh", resourceCulture); + } + } + /// /// Looks up a localized string similar to Reload configuration and restart all Game Servers. /// diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index 0571a7c82..eb288814d 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -1,4 +1,4 @@ - +