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 @@
-
+