| @item.LoginName |
- @item.CharacterName |
@item.ServerId |
@item.StartedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC |
diff --git a/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj b/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj
index 305d9dce6..080635b7a 100644
--- a/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj
+++ b/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj
@@ -79,9 +79,9 @@
-
-
-
+
+
+
diff --git a/src/Web/Shared/Services/LoggedInAccountService.cs b/src/Web/Shared/Services/LoggedInAccountService.cs
index 2c7c3460c..5afe09c83 100644
--- a/src/Web/Shared/Services/LoggedInAccountService.cs
+++ b/src/Web/Shared/Services/LoggedInAccountService.cs
@@ -7,7 +7,7 @@ namespace MUnique.OpenMU.Web.Shared.Services;
using MUnique.OpenMU.Interfaces;
///
-/// Services for the page.
+/// Services for the page.
///
public class LoggedInAccountService : IDataService, ISupportDataChangedNotification
{
@@ -25,7 +25,9 @@ public LoggedInAccountService(ILoginServer loginServer, IServerProvider serverPr
this._serverProvider = serverProvider;
}
- ///
+ ///
+ /// Event raised when the data has changed.
+ ///
public event EventHandler? DataChanged;
///
diff --git a/src/Web/Shared/Services/OfflineLevelingAccount.cs b/src/Web/Shared/Services/OfflineLevelingAccount.cs
index 543a7e8cd..200ad4d82 100644
--- a/src/Web/Shared/Services/OfflineLevelingAccount.cs
+++ b/src/Web/Shared/Services/OfflineLevelingAccount.cs
@@ -7,4 +7,4 @@ namespace MUnique.OpenMU.Web.Shared.Services;
///
/// Keeps the displayed data of an active offline leveling session.
///
-public record OfflineLevelingAccount(string LoginName, string CharacterName, byte ServerId, DateTime StartedAt);
+public record OfflineLevelingAccount(string LoginName, byte ServerId, DateTime StartedAt);
diff --git a/src/Web/Shared/Services/OfflineLevelingAccountService.cs b/src/Web/Shared/Services/OfflineLevelingAccountService.cs
index d80a97cac..931a31b29 100644
--- a/src/Web/Shared/Services/OfflineLevelingAccountService.cs
+++ b/src/Web/Shared/Services/OfflineLevelingAccountService.cs
@@ -23,7 +23,9 @@ public OfflineLevelingAccountService(IServerProvider serverProvider)
this._serverProvider = serverProvider;
}
- ///
+ ///
+ /// Event raised when the data has changed.
+ ///
public event EventHandler? DataChanged;
///
@@ -32,13 +34,10 @@ public OfflineLevelingAccountService(IServerProvider serverProvider)
/// The account whose session should be stopped.
public async Task StopOfflineLevelingAsync(OfflineLevelingAccount account)
{
- var server = this._serverProvider.Servers
- .OfType()
- .FirstOrDefault(s => s.Id == account.ServerId);
-
- if (server is not null)
+ var server = this._serverProvider.Servers.FirstOrDefault(s => s.Id == account.ServerId);
+ if (server is IGameServer gameServer)
{
- await server.DisconnectAccountAsync(account.LoginName).ConfigureAwait(false);
+ await gameServer.DisconnectAccountAsync(account.LoginName).ConfigureAwait(false);
}
this.DataChanged?.Invoke(this, EventArgs.Empty);
@@ -53,7 +52,6 @@ public Task> GetAsync(int offset, int count)
.GetOfflineLevelingPlayers()
.Select(p => new OfflineLevelingAccount(
p.AccountLoginName ?? string.Empty,
- p.CharacterName ?? string.Empty,
(byte)((IManageableServer)s).Id,
p.StartTimestamp)))
.OrderBy(a => a.LoginName)
diff --git a/src/Web/Shared/wwwroot/css/shared.css b/src/Web/Shared/wwwroot/css/shared.css
index 26561274c..930f13b91 100644
--- a/src/Web/Shared/wwwroot/css/shared.css
+++ b/src/Web/Shared/wwwroot/css/shared.css
@@ -1,4 +1,4 @@
-@charset "UTF-8";
+@charset "UTF-8";
/*!
* Bootstrap v4.6.1 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
@@ -3876,13 +3876,15 @@ form > div > div > div:first-child:first-of-type > span:not(:first-child) {
border: 0;
border-radius: 1rem;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
- appearance: none;
}
@media (prefers-reduced-motion: reduce) {
.custom-range::-webkit-slider-thumb {
transition: none;
}
}
+.custom-range::-webkit-slider-thumb {
+ appearance: none;
+}
.custom-range::-webkit-slider-thumb:active {
background-color: rgb(178.5, 215.4, 255);
}
@@ -3902,13 +3904,15 @@ form > div > div > div:first-child:first-of-type > span:not(:first-child) {
border: 0;
border-radius: 1rem;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
- appearance: none;
}
@media (prefers-reduced-motion: reduce) {
.custom-range::-moz-range-thumb {
transition: none;
}
}
+.custom-range::-moz-range-thumb {
+ appearance: none;
+}
.custom-range::-moz-range-thumb:active {
background-color: rgb(178.5, 215.4, 255);
}
@@ -3931,13 +3935,15 @@ form > div > div > div:first-child:first-of-type > span:not(:first-child) {
border: 0;
border-radius: 1rem;
transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
- appearance: none;
}
@media (prefers-reduced-motion: reduce) {
.custom-range::-ms-thumb {
transition: none;
}
}
+.custom-range::-ms-thumb {
+ appearance: none;
+}
.custom-range::-ms-thumb:active {
background-color: rgb(178.5, 215.4, 255);
}
@@ -4344,10 +4350,6 @@ form > div > div > div:first-child:first-of-type > span:not(:first-child) {
display: none;
}
}
-.navbar-expand {
- flex-flow: row nowrap;
- justify-content: flex-start;
-}
.navbar-expand > .container,
.navbar-expand > .container-fluid,
.navbar-expand > .container-sm,
@@ -4357,6 +4359,10 @@ form > div > div > div:first-child:first-of-type > span:not(:first-child) {
padding-right: 0;
padding-left: 0;
}
+.navbar-expand {
+ flex-flow: row nowrap;
+ justify-content: flex-start;
+}
.navbar-expand .navbar-nav {
flex-direction: row;
}
@@ -5536,13 +5542,15 @@ a.close.disabled {
}
.modal.fade .modal-dialog {
transition: transform 0.3s ease-out;
- transform: translate(0, -50px);
}
@media (prefers-reduced-motion: reduce) {
.modal.fade .modal-dialog {
transition: none;
}
}
+.modal.fade .modal-dialog {
+ transform: translate(0, -50px);
+}
.modal.show .modal-dialog {
transform: none;
}
@@ -10928,19 +10936,6 @@ mark {
transform: rotate(360deg);
}
}
-.map-host {
- position: relative;
- user-select: none;
- z-index: 0;
-}
-
-.map-host > img {
- image-rendering: pixelated;
- z-index: -1;
- top: 0px;
- left: 0px;
-}
-
.map-container {
display: flex;
flex-direction: row;
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs
index 99b8ff571..32a6eede3 100644
--- a/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs
@@ -14,6 +14,7 @@ namespace MUnique.OpenMU.Tests;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.OfflineLeveling;
+using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence.InMemory;
using MUnique.OpenMU.PlugIns;
@@ -53,7 +54,7 @@ public async ValueTask RepairHandler_RepairsItemWhenSufficientZen()
await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
player.TryAddMoney(1_000_000);
- var config = new MuHelperPlayerConfiguration { RepairItem = true };
+ var config = new MuHelperSettings { RepairItem = true };
var handler = new RepairHandler(player, config);
await handler.PerformRepairsAsync().ConfigureAwait(false);
@@ -71,7 +72,7 @@ public async ValueTask RepairHandler_DoesNothingWhenDisabled()
await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
player.TryAddMoney(1_000_000);
- var config = new MuHelperPlayerConfiguration { RepairItem = false };
+ var config = new MuHelperSettings { RepairItem = false };
var handler = new RepairHandler(player, config);
await handler.PerformRepairsAsync().ConfigureAwait(false);
@@ -88,7 +89,7 @@ public async ValueTask RepairHandler_DoesNotRepairWhenInsufficientZen()
var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 10);
await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
- var config = new MuHelperPlayerConfiguration { RepairItem = true };
+ var config = new MuHelperSettings { RepairItem = true };
var handler = new RepairHandler(player, config);
await handler.PerformRepairsAsync().ConfigureAwait(false);
@@ -107,7 +108,7 @@ public async ValueTask RepairHandler_SkipsFullyDurableItems()
var initialMoney = 1_000_000;
player.TryAddMoney(initialMoney);
- var config = new MuHelperPlayerConfiguration { RepairItem = true };
+ var config = new MuHelperSettings { RepairItem = true };
var handler = new RepairHandler(player, config);
await handler.PerformRepairsAsync().ConfigureAwait(false);
@@ -125,7 +126,7 @@ public async ValueTask RepairHandler_SkipsFullyDurableItems()
public async ValueTask ItemPickupHandler_DoesNothingWhenAllDisabled()
{
var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var config = new MuHelperPlayerConfiguration
+ var config = new MuHelperSettings
{
PickAllItems = false,
PickJewel = false,
@@ -157,27 +158,25 @@ public async ValueTask ItemPickupHandler_DoesNothingWhenConfigNull()
// -------------------------------------------------------------------------
///
- /// Tests that does nothing
+ /// Tests that does nothing
/// when config is null.
///
[Test]
- public async ValueTask CombatHandler_HealthRecovery_DoesNothingWhenConfigNull()
+ public async ValueTask HealingHandler_DoesNothingWhenConfigNull()
{
var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var origin = player.Position;
- var movement = new MovementHandler(player, null, origin);
- var handler = new CombatHandler(player, null, movement, origin);
+ var handler = new HealingHandler(player, null);
Assert.DoesNotThrowAsync(async () =>
await handler.PerformHealthRecoveryAsync().ConfigureAwait(false));
}
///
- /// Tests that does not consume
+ /// Tests that does not consume
/// a potion when the player's HP is above the threshold.
///
[Test]
- public async ValueTask CombatHandler_HealthRecovery_DoesNotUsePotionAboveThreshold()
+ public async ValueTask HealingHandler_DoesNotUsePotionAboveThreshold()
{
var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
var maxHp = player.Attributes![Stats.MaximumHealth];
@@ -186,15 +185,13 @@ public async ValueTask CombatHandler_HealthRecovery_DoesNotUsePotionAboveThresho
var potion = this.CreateHealthPotion();
await player.Inventory!.AddItemAsync((byte)(InventoryConstants.FirstEquippableItemSlotIndex + 12), potion).ConfigureAwait(false);
- var config = new MuHelperPlayerConfiguration
+ var config = new MuHelperSettings
{
UseHealPotion = true,
PotionThresholdPercent = 50,
};
- var origin = player.Position;
- var movement = new MovementHandler(player, config, origin);
- var handler = new CombatHandler(player, config, movement, origin);
+ var handler = new HealingHandler(player, config);
await handler.PerformHealthRecoveryAsync().ConfigureAwait(false);
Assert.That(player.Inventory?.GetItem(potion.ItemSlot), Is.Not.Null);
@@ -212,7 +209,7 @@ public async ValueTask CombatHandler_HealthRecovery_DoesNotUsePotionAboveThresho
public async ValueTask BuffHandler_ReturnsTrue_WhenNoBuffsConfigured()
{
var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var config = new MuHelperPlayerConfiguration
+ var config = new MuHelperSettings
{
BuffSkill0Id = 0,
BuffSkill1Id = 0,
From 353308d7f5ad03d372571de4e04f1563068d7913 Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Tue, 17 Mar 2026 00:20:10 -0300
Subject: [PATCH 03/32] fix: incorrect packet size causing test failure
---
docs/Packets/C1-11-ObjectHitExtended_by-server.md | 6 ++++--
docs/Packets/C1-11-ObjectHit_by-server.md | 6 ++++--
.../C1-D2-05-CashShopStorageListRequest_by-client.md | 2 +-
...1-D2-0B-CashShopStorageItemConsumeRequest_by-client.md | 2 +-
src/GameLogic/Player.cs | 8 ++++----
.../Packets/ClientToServer/ClientToServerPackets.cs | 5 +++--
.../Packets/ClientToServer/ClientToServerPackets.xml | 2 +-
.../Packets/ClientToServer/ClientToServerPacketsRef.cs | 4 ++--
.../ClientToServerPacketTests.cs | 4 ++--
9 files changed, 22 insertions(+), 17 deletions(-)
diff --git a/docs/Packets/C1-11-ObjectHitExtended_by-server.md b/docs/Packets/C1-11-ObjectHitExtended_by-server.md
index fd9e2109e..8d78388e6 100644
--- a/docs/Packets/C1-11-ObjectHitExtended_by-server.md
+++ b/docs/Packets/C1-11-ObjectHitExtended_by-server.md
@@ -16,6 +16,8 @@ The damage is shown at the object which received the hit.
| 1 | 1 | Byte | 16 | Packet header - length of the packet |
| 2 | 1 | Byte | 0x11 | Packet header - packet type identifier |
| 3 << 0 | 4 bit | DamageKind | | Kind |
+| 3 << 4 | 1 bit | Boolean | | IsRageFighterStreakHit |
+| 3 << 5 | 1 bit | Boolean | | IsRageFighterStreakFinalHit |
| 3 << 6 | 1 bit | Boolean | | IsDoubleDamage |
| 3 << 7 | 1 bit | Boolean | | IsTripleDamage |
| 4 | 2 | ShortLittleEndian | | ObjectId |
@@ -34,7 +36,7 @@ Defines the kind of the damage.
| 1 | IgnoreDefenseCyan | Cyan color, usually used by ignore defense damage. |
| 2 | ExcellentLightGreen | Light green color, usually used by excellent damage. |
| 3 | CriticalBlue | Blue color, usually used by critical damage. |
-| 4 | LightPink | Light pink color. |
+| 4 | ReflectedLightPink | Light pink color, usually used by reflected damage. |
| 5 | PoisonDarkGreen | Dark green color, usually used by poison damage. |
-| 6 | ReflectedDarkPink | Dark pink color, usually used by reflected damage. |
+| 6 | DarkPink | Dark pink color. |
| 7 | White | White color. |
\ No newline at end of file
diff --git a/docs/Packets/C1-11-ObjectHit_by-server.md b/docs/Packets/C1-11-ObjectHit_by-server.md
index de9120dfc..7dc7c6dfd 100644
--- a/docs/Packets/C1-11-ObjectHit_by-server.md
+++ b/docs/Packets/C1-11-ObjectHit_by-server.md
@@ -19,6 +19,8 @@ The damage is shown at the object which received the hit.
| 3 | 2 | ShortBigEndian | | ObjectId |
| 5 | 2 | ShortBigEndian | | HealthDamage |
| 7 << 0 | 4 bit | DamageKind | | Kind |
+| 7 << 4 | 1 bit | Boolean | | IsRageFighterStreakHit |
+| 7 << 5 | 1 bit | Boolean | | IsRageFighterStreakFinalHit |
| 7 << 6 | 1 bit | Boolean | | IsDoubleDamage |
| 7 << 7 | 1 bit | Boolean | | IsTripleDamage |
| 8 | 2 | ShortBigEndian | | ShieldDamage |
@@ -33,7 +35,7 @@ Defines the kind of the damage.
| 1 | IgnoreDefenseCyan | Cyan color, usually used by ignore defense damage. |
| 2 | ExcellentLightGreen | Light green color, usually used by excellent damage. |
| 3 | CriticalBlue | Blue color, usually used by critical damage. |
-| 4 | LightPink | Light pink color. |
+| 4 | ReflectedLightPink | Light pink color, usually used by reflected damage. |
| 5 | PoisonDarkGreen | Dark green color, usually used by poison damage. |
-| 6 | ReflectedDarkPink | Dark pink color, usually used by reflected damage. |
+| 6 | DarkPink | Dark pink color. |
| 7 | White | White color. |
\ No newline at end of file
diff --git a/docs/Packets/C1-D2-05-CashShopStorageListRequest_by-client.md b/docs/Packets/C1-D2-05-CashShopStorageListRequest_by-client.md
index 4a842687f..0606520bd 100644
--- a/docs/Packets/C1-D2-05-CashShopStorageListRequest_by-client.md
+++ b/docs/Packets/C1-D2-05-CashShopStorageListRequest_by-client.md
@@ -13,7 +13,7 @@ In case of opening, the server returns if the cash shop is available. If the pla
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
-| 1 | 1 | Byte | 8 | Packet header - length of the packet |
+| 1 | 1 | Byte | 10 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xD2 | Packet header - packet type identifier |
| 3 | 1 | Byte | 0x05 | Packet header - sub packet type identifier |
| 4 | 4 | IntegerLittleEndian | | PageIndex |
diff --git a/docs/Packets/C1-D2-0B-CashShopStorageItemConsumeRequest_by-client.md b/docs/Packets/C1-D2-0B-CashShopStorageItemConsumeRequest_by-client.md
index b0f25d456..d75de9f7b 100644
--- a/docs/Packets/C1-D2-0B-CashShopStorageItemConsumeRequest_by-client.md
+++ b/docs/Packets/C1-D2-0B-CashShopStorageItemConsumeRequest_by-client.md
@@ -13,7 +13,7 @@ The item is applied or added to the inventory.
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
-| 1 | 1 | Byte | 5 | Packet header - length of the packet |
+| 1 | 1 | Byte | 15 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xD2 | Packet header - packet type identifier |
| 3 | 1 | Byte | 0x0B | Packet header - sub packet type identifier |
| 4 | 4 | IntegerLittleEndian | | BaseItemCode |
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index ea039ab24..a26c08b80 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -348,7 +348,7 @@ private set
public ComboStateMachine? ComboState => this.Attributes?[Stats.IsSkillComboAvailable] > 0 ? this._comboStateLazy?.Value : null;
///
- /// Gets summon.
+ /// Gets the player summon.
///
public (Monster, INpcIntelligence)? Summon { get; private set; }
@@ -1697,7 +1697,7 @@ IElement AppedMasterSkillPowerUp(SkillEntry masterSkillEntry, PowerUpDefinition
/// Creates a summoned monster for the player.
///
/// The definition.
- /// Can't add a summon for a player which isn't spawned yet.
+ /// Can't add the player summon for a player which isn't spawned yet.
public async ValueTask CreateSummonedMonsterAsync(MonsterDefinition definition)
{
if (this.CurrentMap is not { } gameMap)
@@ -1729,7 +1729,7 @@ public async ValueTask CreateSummonedMonsterAsync(MonsterDefinition definition)
}
///
- /// Notifies the player object that summon died.
+ /// Notifies the player object that the summoned monster died.
///
public void SummonDied()
{
@@ -1737,7 +1737,7 @@ public void SummonDied()
}
///
- /// Removes summon.
+ /// Removes the player summon.
///
public async ValueTask RemoveSummonAsync()
{
diff --git a/src/Network/Packets/ClientToServer/ClientToServerPackets.cs b/src/Network/Packets/ClientToServer/ClientToServerPackets.cs
index a3151fc95..05b6dfbb0 100644
--- a/src/Network/Packets/ClientToServer/ClientToServerPackets.cs
+++ b/src/Network/Packets/ClientToServer/ClientToServerPackets.cs
@@ -6482,7 +6482,7 @@ private CashShopStorageListRequest(Memory data, bool initialize)
///
/// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed.
///
- public static int Length => 9;
+ public static int Length => 10;
///
/// Gets the header of this packet.
@@ -17690,4 +17690,5 @@ public enum GuildRequestType
/// The leave type.
///
Leave = 2,
- }
\ No newline at end of file
+ }
+
diff --git a/src/Network/Packets/ClientToServer/ClientToServerPackets.xml b/src/Network/Packets/ClientToServer/ClientToServerPackets.xml
index 9ae13208b..40f687671 100644
--- a/src/Network/Packets/ClientToServer/ClientToServerPackets.xml
+++ b/src/Network/Packets/ClientToServer/ClientToServerPackets.xml
@@ -1514,7 +1514,7 @@
D2
05
CashShopStorageListRequest
- 9
+ 10
ClientToServer
The player opened the cash shop dialog or used paging of the storage.
In case of opening, the server returns if the cash shop is available. If the player is in the safezone, it's not.
diff --git a/src/Network/Packets/ClientToServer/ClientToServerPacketsRef.cs b/src/Network/Packets/ClientToServer/ClientToServerPacketsRef.cs
index c704901b7..fcb39ba92 100644
--- a/src/Network/Packets/ClientToServer/ClientToServerPacketsRef.cs
+++ b/src/Network/Packets/ClientToServer/ClientToServerPacketsRef.cs
@@ -6466,7 +6466,7 @@ private CashShopStorageListRequestRef(Span data, bool initialize)
///
/// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed.
///
- public static int Length => 8;
+ public static int Length => 10;
///
/// Gets the header of this packet.
@@ -6665,7 +6665,7 @@ private CashShopStorageItemConsumeRequestRef(Span data, bool initialize)
///
/// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed.
///
- public static int Length => 5;
+ public static int Length => 15;
///
/// Gets the header of this packet.
diff --git a/tests/MUnique.OpenMU.Network.Packets.Tests/ClientToServerPacketTests.cs b/tests/MUnique.OpenMU.Network.Packets.Tests/ClientToServerPacketTests.cs
index 0f826df55..f112b1ebe 100644
--- a/tests/MUnique.OpenMU.Network.Packets.Tests/ClientToServerPacketTests.cs
+++ b/tests/MUnique.OpenMU.Network.Packets.Tests/ClientToServerPacketTests.cs
@@ -1489,7 +1489,7 @@ public void CashShopItemGiftRequest_PacketSizeValidation()
public void CashShopStorageListRequest_PacketSizeValidation()
{
// Fixed-length packet validation
- const int expectedLength = 8;
+ const int expectedLength = 10;
var actualLength = CashShopStorageListRequestRef.Length;
Assert.That(actualLength, Is.EqualTo(expectedLength),
@@ -1537,7 +1537,7 @@ public void CashShopDeleteStorageItemRequest_PacketSizeValidation()
public void CashShopStorageItemConsumeRequest_PacketSizeValidation()
{
// Fixed-length packet validation
- const int expectedLength = 5;
+ const int expectedLength = 15;
var actualLength = CashShopStorageItemConsumeRequestRef.Length;
Assert.That(actualLength, Is.EqualTo(expectedLength),
From c6a678aba554735f7293dbbb1a2bfa61c5962ae9 Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Tue, 17 Mar 2026 19:42:23 -0300
Subject: [PATCH 04/32] offlevel: implemented missing combat behaviour
---
src/GameLogic/NPC/BasicMonsterIntelligence.cs | 103 +++----
src/GameLogic/NPC/Monster.cs | 15 +
src/GameLogic/OfflineLeveling/BuffHandler.cs | 22 +-
.../OfflineLeveling/CombatHandler.cs | 263 +++++++++++-------
.../OfflineLevelingIntelligence.cs | 12 +-
5 files changed, 253 insertions(+), 162 deletions(-)
diff --git a/src/GameLogic/NPC/BasicMonsterIntelligence.cs b/src/GameLogic/NPC/BasicMonsterIntelligence.cs
index ec5001894..ecf0ff6d2 100644
--- a/src/GameLogic/NPC/BasicMonsterIntelligence.cs
+++ b/src/GameLogic/NPC/BasicMonsterIntelligence.cs
@@ -28,9 +28,6 @@ public class BasicMonsterIntelligence : INpcIntelligence, IDisposable
///
/// Gets or sets a value indicating whether this instance can walk on safezone.
///
- ///
- /// true if this instance can walk on safezone; otherwise, false.
- ///
public bool CanWalkOnSafezone { get; protected set; }
///
@@ -52,7 +49,7 @@ public Monster Monster
///
/// Gets the current target.
///
- protected IAttackable? CurrentTarget { get; private set; }
+ public IAttackable? CurrentTarget { get; private set; }
///
public void Start()
@@ -91,6 +88,9 @@ public virtual bool CanWalkOn(Point target)
return this.Monster.CurrentMap.Terrain.AIgrid[target.X, target.Y] == 1;
}
+ ///
+ public bool IsTargetingPlayer(Player player) => this.CurrentTarget == player;
+
///
/// Called when the intelligence starts.
///
@@ -129,30 +129,27 @@ protected virtual void Dispose(bool managed)
var possibleTargets = tempObservers.OfType()
.Where(a => a.IsActive() && !a.IsAtSafezone() && a is not Player { IsInvisible: true })
.ToList();
+
+ // Also consider summoned monsters belonging to players in range.
var summons = possibleTargets.OfType()
.Select(p => p.Summon?.Item1)
.Where(s => s is not null)
.Cast()
.WhereActive()
.ToList();
- possibleTargets.AddRange(summons);
- var closestTarget = possibleTargets.MinBy(a => a.GetDistanceTo(this.Npc));
- return closestTarget;
+ possibleTargets.AddRange(summons);
- // todo: check the walk distance
+ return possibleTargets.MinBy(a => a.GetDistanceTo(this.Npc));
}
///
- /// Determines whether this instance can attack.
+ /// Determines whether this instance can attack this tick.
///
- ///
- /// true if this instance can attack; otherwise, false.
- ///
protected virtual ValueTask CanAttackAsync() => ValueTask.FromResult(true);
///
- /// Handles the tick without having a target.
+ /// Handles the tick when no target is available, moves the monster randomly.
///
protected virtual async ValueTask TickWithoutTargetAsync()
{
@@ -161,7 +158,6 @@ protected virtual async ValueTask TickWithoutTargetAsync()
return;
}
- // we move around randomly, so the monster does not look dead when watched from distance.
if (await this.IsObservedByAttackerAsync().ConfigureAwait(false))
{
await this.Monster.RandomMoveAsync().ConfigureAwait(false);
@@ -169,11 +165,8 @@ protected virtual async ValueTask TickWithoutTargetAsync()
}
///
- /// Determines whether the handled monster is observed by an attacker.
+ /// Determines whether the handled monster is observed by any attacker.
///
- ///
- /// true if the handled monster is observed by an attacker; otherwise, false.
- ///
protected async ValueTask IsObservedByAttackerAsync()
{
using var readerLock = await this.Monster.ObserverLock.ReaderLockAsync();
@@ -197,7 +190,7 @@ private async void SafeTick()
}
catch (OperationCanceledException)
{
- // can be ignored.
+ // expected during shutdown.
}
catch (Exception ex)
{
@@ -228,36 +221,19 @@ private async ValueTask TickAsync()
return;
}
- var target = this.CurrentTarget;
- if (target != null)
- {
- // Old Target out of Range?
- if (!target.IsAlive
- || target is Player { IsInvisible: true }
- || target.IsTeleporting
- || target.IsAtSafezone()
- || !target.IsInRange(this.Monster.Position, this.Npc.Definition.ViewRange)
- || (target is IWorldObserver && !await this.IsTargetInObserversAsync(target).ConfigureAwait(false)))
- {
- target = this.CurrentTarget = await this.SearchNextTargetAsync().ConfigureAwait(false);
- }
- }
- else
- {
- target = this.CurrentTarget = await this.SearchNextTargetAsync().ConfigureAwait(false);
- }
+ this.CurrentTarget = await this.ResolveTargetAsync().ConfigureAwait(false);
- // no target?
- if (target is null)
+ if (this.CurrentTarget is null)
{
await this.TickWithoutTargetAsync().ConfigureAwait(false);
return;
}
- // Target in Attack Range?
- if (target.IsInRange(this.Monster.Position, this.Monster.Definition.AttackRange) && !this.Monster.IsAtSafezone())
+ // Target in attack range — attack.
+ if (this.CurrentTarget.IsInRange(this.Monster.Position, this.Monster.Definition.AttackRange)
+ && !this.Monster.IsAtSafezone())
{
- await this.Monster.AttackAsync(target).ConfigureAwait(false); // yes, attack
+ await this.Monster.AttackAsync(this.CurrentTarget).ConfigureAwait(false);
return;
}
@@ -266,18 +242,51 @@ private async ValueTask TickAsync()
return;
}
- // Target in View Range?
- if (target.IsInRange(this.Monster.Position, this.Monster.Definition.ViewRange + 1))
+ // Target visible but outside attack range, walk toward it.
+ if (this.CurrentTarget.IsInRange(this.Monster.Position, this.Monster.Definition.ViewRange + 1))
{
- // no, walk to the target
- var walkTarget = this.Monster.CurrentMap!.Terrain.GetRandomCoordinate(target.Position, this.Monster.Definition.AttackRange);
+ var walkTarget = this.Monster.CurrentMap!.Terrain.GetRandomCoordinate(this.CurrentTarget.Position, this.Monster.Definition.AttackRange);
if (await this.Monster.WalkToAsync(walkTarget).ConfigureAwait(false))
{
return;
}
}
- // we move around randomly, so the monster does not look dead when watched from distance.
+ // Nothing else to do, wander randomly.
await this.Monster.RandomMoveAsync().ConfigureAwait(false);
}
+
+ ///
+ /// Returns the current target if still valid, otherwise searches for a new one.
+ ///
+ private async ValueTask ResolveTargetAsync()
+ {
+ if (this.CurrentTarget is not null && this.IsCurrentTargetValid())
+ {
+ // Double-check the target is still within the observer list (needed for
+ // players who have moved out of view range server-side).
+ if (!await this.IsTargetInObserversAsync(this.CurrentTarget).ConfigureAwait(false)
+ && this.CurrentTarget is IWorldObserver)
+ {
+ return await this.SearchNextTargetAsync().ConfigureAwait(false);
+ }
+
+ return this.CurrentTarget;
+ }
+
+ return await this.SearchNextTargetAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Returns true if the current target is still a valid attack candidate.
+ ///
+ private bool IsCurrentTargetValid()
+ {
+ return this.CurrentTarget is not null
+ && this.CurrentTarget.IsAlive
+ && this.CurrentTarget is not Player { IsInvisible: true }
+ && !this.CurrentTarget.IsTeleporting
+ && !this.CurrentTarget.IsAtSafezone()
+ && this.CurrentTarget.IsInRange(this.Monster.Position, this.Npc.Definition.ViewRange);
+ }
}
\ No newline at end of file
diff --git a/src/GameLogic/NPC/Monster.cs b/src/GameLogic/NPC/Monster.cs
index 29f33c9d2..d2ab8bc0d 100644
--- a/src/GameLogic/NPC/Monster.cs
+++ b/src/GameLogic/NPC/Monster.cs
@@ -96,6 +96,21 @@ public Monster(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map,
///
protected override bool CanSpawnInSafezone => base.CanSpawnInSafezone || this.SummonedBy is not null;
+ ///
+ /// Checks if this monster is currently attacking the specified player.
+ ///
+ /// The player to check.
+ /// True if this monster is attacking the player; otherwise, false.
+ public bool IsAttackingPlayer(Player player)
+ {
+ if (this._intelligence is BasicMonsterIntelligence basicIntelligence)
+ {
+ return basicIntelligence.CurrentTarget == player;
+ }
+
+ return false;
+ }
+
///
/// Attacks the specified target.
///
diff --git a/src/GameLogic/OfflineLeveling/BuffHandler.cs b/src/GameLogic/OfflineLeveling/BuffHandler.cs
index f41090001..e6db35aaa 100644
--- a/src/GameLogic/OfflineLeveling/BuffHandler.cs
+++ b/src/GameLogic/OfflineLeveling/BuffHandler.cs
@@ -89,25 +89,29 @@ public async ValueTask PerformBuffsAsync()
return true;
}
- private static bool IsSkillQualifiedForTarget(SkillEntry skillEntry)
+ ///
+ /// Gets the configured buff skill IDs from the settings.
+ ///
+ /// A list containing BuffSkill0Id, BuffSkill1Id, and BuffSkill2Id. Returns an empty list if configuration is null.
+ public List GetConfiguredBuffIds()
{
- if (skillEntry.Skill is not { } skill)
+ if (this._config is null)
{
- return false;
+ return [];
}
- return skill.Target != SkillTarget.ImplicitPlayer
- && skill.TargetRestriction != SkillTargetRestriction.Self;
+ return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id];
}
- private List GetConfiguredBuffIds()
+ private static bool IsSkillQualifiedForTarget(SkillEntry skillEntry)
{
- if (this._config is null)
+ if (skillEntry.Skill is not { } skill)
{
- return [];
+ return false;
}
- return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id];
+ return skill.Target != SkillTarget.ImplicitPlayer
+ && skill.TargetRestriction != SkillTargetRestriction.Self;
}
private void UpdatePeriodicBuffTimer()
diff --git a/src/GameLogic/OfflineLeveling/CombatHandler.cs b/src/GameLogic/OfflineLeveling/CombatHandler.cs
index 0fc610c32..57ae9e3a3 100644
--- a/src/GameLogic/OfflineLeveling/CombatHandler.cs
+++ b/src/GameLogic/OfflineLeveling/CombatHandler.cs
@@ -2,21 +2,16 @@
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
-namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
-
-using MUnique.OpenMU.DataModel.Configuration;
-using MUnique.OpenMU.DataModel.Configuration.Items;
-using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.NPC;
-using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions;
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views.World;
-using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Pathfinding;
+namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
+
///
/// Handles combat logic including target selection, attacks, combo attacks, and skill usage.
///
@@ -33,7 +28,9 @@ public sealed class CombatHandler
private readonly OfflineLevelingPlayer _player;
private readonly IMuHelperSettings? _config;
private readonly MovementHandler _movementHandler;
+ private readonly BuffHandler _buffHandler;
private readonly Point _originPosition;
+ private readonly ConditionalSkillSlot[] _conditionalSkillSlots;
private IAttackable? _currentTarget;
private int _nearbyMonsterCount;
@@ -46,13 +43,20 @@ public sealed class CombatHandler
/// The offline leveling player.
/// The MU helper settings.
/// The movement handler.
+ /// The buff handler.
/// The original position to hunt around.
- public CombatHandler(OfflineLevelingPlayer player, IMuHelperSettings? config, MovementHandler movementHandler, Point originPosition)
+ public CombatHandler(OfflineLevelingPlayer player, IMuHelperSettings? config, MovementHandler movementHandler, BuffHandler buffHandler, Point originPosition)
{
this._player = player;
this._config = config;
this._movementHandler = movementHandler;
+ this._buffHandler = buffHandler;
this._originPosition = originPosition;
+ this._conditionalSkillSlots = config is null ? [] :
+ [
+ new ConditionalSkillSlot(config.ActivationSkill1Id, config.Skill1UseTimer, config.DelayMinSkill1, config.Skill1UseCondition, config.Skill1ConditionAttacking, config.Skill1SubCondition),
+ new ConditionalSkillSlot(config.ActivationSkill2Id, config.Skill2UseTimer, config.DelayMinSkill2, config.Skill2UseCondition, config.Skill2ConditionAttacking, config.Skill2SubCondition),
+ ];
}
///
@@ -91,6 +95,36 @@ public void DecrementCooldown()
}
}
+ ///
+ /// Performs combat attacks on targets.
+ ///
+ /// A value task representing the asynchronous operation.
+ public async ValueTask PerformAttackAsync()
+ {
+ this.RefreshTarget();
+
+ if (this._currentTarget is null)
+ {
+ return;
+ }
+
+ byte attackRange = this.GetEffectiveAttackRange();
+ if (!this.IsTargetInAttackRange(this._currentTarget, attackRange))
+ {
+ await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false);
+ return;
+ }
+
+ if (this._config?.UseCombo == true)
+ {
+ await this.ExecuteComboAttackAsync().ConfigureAwait(false);
+ }
+ else
+ {
+ await this.ExecuteAttackAsync(this._currentTarget).ConfigureAwait(false);
+ }
+ }
+
///
/// Performs health recovery through Drain Life attacks if configured.
///
@@ -123,44 +157,23 @@ public async ValueTask PerformDrainLifeRecoveryAsync()
}
}
- ///
- /// Performs combat attacks on targets.
- ///
- /// A value task representing the asynchronous operation.
- public async ValueTask PerformAttackAsync()
+ private async ValueTask ExecuteAttackAsync(IAttackable target)
{
- this.RefreshTarget();
-
- if (this._currentTarget is null)
+ var skill = this.SelectAttackSkill();
+ if (skill == null)
{
- return;
- }
-
- byte attackRange = this.GetEffectiveAttackRange();
- if (!this.IsTargetInAttackRange(this._currentTarget, attackRange))
- {
- await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false);
- return;
+ // Do not attack if there are buffs configured to handle buff-only classes.
+ var buffs = this._buffHandler.GetConfiguredBuffIds();
+ if (buffs.Count > 0)
+ {
+ return;
+ }
}
- if (this._config?.UseCombo == true)
- {
- await this.ExecuteComboAttackAsync().ConfigureAwait(false);
- }
- else
- {
- var skill = this.SelectAttackSkill();
- await this.ExecuteAttackAsync(this._currentTarget, skill, false).ConfigureAwait(false);
- }
+ await this.ExecuteAttackAsync(target, skill, false).ConfigureAwait(false);
}
- ///
- /// Executes an attack on the specified target.
- ///
- /// The target.
- /// The skill entry.
- /// If set to true, it's a combo attack.
- public async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skillEntry, bool isCombo)
+ private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skillEntry, bool isCombo)
{
this._player.Rotation = this._player.GetDirectionTo(target);
@@ -187,8 +200,28 @@ private void RefreshTarget()
this._currentTarget = null;
}
- this._currentTarget ??= this.FindNearestMonster();
- this._nearbyMonsterCount = this.CountMonstersNearby();
+ if (this._currentTarget is null)
+ {
+ var monsters = this.GetAttackableMonstersInHuntingRange().ToList();
+ this._currentTarget = monsters.MinBy(m => m.GetDistanceTo(this._player));
+ this._nearbyMonsterCount = monsters.Count;
+ }
+ else
+ {
+ this._nearbyMonsterCount = this.GetAttackableMonstersInHuntingRange().Count();
+ }
+ }
+
+ private IEnumerable GetAttackableMonstersInHuntingRange()
+ {
+ if (this._player.CurrentMap is not { } map)
+ {
+ return [];
+ }
+
+ return map.GetAttackablesInRange(this._originPosition, this.HuntingRange)
+ .OfType()
+ .Where(this.IsMonsterAttackable);
}
private bool IsTargetInAttackRange(IAttackable target, byte range)
@@ -255,87 +288,72 @@ private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skil
await strategy.PerformSkillAsync(this._player, target, (ushort)skill.Number).ConfigureAwait(false);
}
- private IAttackable? FindNearestMonster()
- {
- if (this._player.CurrentMap is not { } map)
- {
- return null;
- }
-
- return map.GetAttackablesInRange(this._originPosition, this.HuntingRange)
- .OfType()
- .Where(this.IsMonsterAttackable)
- .MinBy(m => m.GetDistanceTo(this._player));
- }
-
- private int CountMonstersNearby()
- {
- if (this._player.CurrentMap is not { } map)
- {
- return 0;
- }
-
- return map.GetAttackablesInRange(this._originPosition, this.HuntingRange)
- .OfType()
- .Count(this.IsMonsterAttackable);
- }
-
private SkillEntry? SelectAttackSkill()
{
if (this._config is null)
{
- return this.GetAnyOffensiveSkill();
+ return null;
}
- var s1 = this.EvaluateConditionalSkill(
- this._config.ActivationSkill1Id,
- this._config.Skill1UseTimer,
- this._config.DelayMinSkill1,
- this._config.Skill1UseCondition,
- this._config.Skill1SubCondition);
- if (s1 is not null)
+ // If no skills are configured at all, don't attack.
+ if (this._config.BasicSkillId == 0
+ && this._config.ActivationSkill1Id == 0
+ && this._config.ActivationSkill2Id == 0)
{
- return s1;
+ return null;
}
- var s2 = this.EvaluateConditionalSkill(
- this._config.ActivationSkill2Id,
- this._config.Skill2UseTimer,
- this._config.DelayMinSkill2,
- this._config.Skill2UseCondition,
- this._config.Skill2SubCondition);
- if (s2 is not null)
+ foreach (var slot in this._conditionalSkillSlots)
{
- return s2;
+ var skill = this.EvaluateConditionalSkill(slot);
+ if (skill is not null && this.HasEnoughResources(skill))
+ {
+ return skill;
+ }
}
if (this._config.BasicSkillId > 0)
{
- return this._player.SkillList?.GetSkill((ushort)this._config.BasicSkillId);
+ var basicSkill = this._player.SkillList?.GetSkill((ushort)this._config.BasicSkillId);
+ if (basicSkill is not null && this.HasEnoughResources(basicSkill))
+ {
+ return basicSkill;
+ }
}
- return this.GetAnyOffensiveSkill();
+ return null;
}
- private SkillEntry? EvaluateConditionalSkill(int skillId, bool useTimer, int timerInterval, bool useCond, int subCond)
+ ///
+ /// Evaluates whether the skill in the given slot should fire this tick.
+ ///
+ private SkillEntry? EvaluateConditionalSkill(ConditionalSkillSlot slot)
{
- if (skillId <= 0)
+ if (slot.SkillId <= 0)
{
return null;
}
- if (useTimer && timerInterval > 0)
+ if (slot.UseTimer && !slot.UseCondition)
{
- var secondsElapsed = (int)(DateTime.UtcNow - this._player.StartTimestamp).TotalSeconds;
- if (secondsElapsed > 0 && secondsElapsed % timerInterval == 0)
+ if (slot.TimerIntervalSeconds <= 0)
+ {
+ return null;
+ }
+
+ var secondsSinceLastUse = (DateTime.UtcNow - slot.LastUseTime).TotalSeconds;
+ if (secondsSinceLastUse >= slot.TimerIntervalSeconds)
{
- return this._player.SkillList?.GetSkill((ushort)skillId);
+ slot.LastUseTime = DateTime.UtcNow;
+ return this._player.SkillList?.GetSkill((ushort)slot.SkillId);
}
+
+ return null;
}
- if (useCond)
+ if (slot.UseCondition && !slot.UseTimer)
{
- int threshold = subCond switch
+ int threshold = slot.SubCondition switch
{
0 => 2,
1 => 3,
@@ -344,15 +362,48 @@ private int CountMonstersNearby()
_ => int.MaxValue,
};
- if (this._nearbyMonsterCount >= threshold)
+ int monsterCount = slot.ConditionAttacking
+ ? this.CountMonstersAttackingPlayer()
+ : this._nearbyMonsterCount;
+
+ if (monsterCount >= threshold)
{
- return this._player.SkillList?.GetSkill((ushort)skillId);
+ return this._player.SkillList?.GetSkill((ushort)slot.SkillId);
}
}
return null;
}
+ private int CountMonstersAttackingPlayer()
+ {
+ return this.GetAttackableMonstersInHuntingRange()
+ .Count(m => m.IsAttackingPlayer(this._player));
+ }
+
+ ///
+ /// Checks that the player has enough mana AND ability (AG) to cast the skill.
+ /// Both resources are consumed by skills via .
+ ///
+ private bool HasEnoughResources(SkillEntry? skillEntry)
+ {
+ if (skillEntry?.Skill is not { } skill || this._player.Attributes is null)
+ {
+ return true;
+ }
+
+ foreach (var requirement in skill.ConsumeRequirements)
+ {
+ int required = this._player.GetRequiredValue(requirement, false);
+ if (this._player.Attributes[requirement.Attribute] < required)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
private async ValueTask ExecuteComboAttackAsync()
{
if (this._currentTarget is null)
@@ -362,9 +413,9 @@ private async ValueTask ExecuteComboAttackAsync()
}
var ids = this.GetConfiguredComboSkillIds();
- if (ids.Count == 0)
+ if (ids.Count < 3)
{
- await this.ExecuteAttackAsync(this._currentTarget, this.GetAnyOffensiveSkill(), false).ConfigureAwait(false);
+ await this.ExecuteAttackAsync(this._currentTarget).ConfigureAwait(false);
return;
}
@@ -494,4 +545,18 @@ private IEnumerable GetOffensiveSkills()
return this._player.SkillList?.Skills.FirstOrDefault(s =>
s.Skill is { Number: DrainLifeBaseSkillId or DrainLifeStrengthenerSkillId or DrainLifeMasterySkillId });
}
-}
+
+ ///
+ /// Holds the configuration for one conditional skill slot, along with its mutable last-use timestamp.
+ ///
+ private sealed class ConditionalSkillSlot(int skillId, bool useTimer, int timerIntervalSeconds, bool useCondition, bool conditionAttacking, int subCondition)
+ {
+ public int SkillId { get; } = skillId;
+ public bool UseTimer { get; } = useTimer;
+ public int TimerIntervalSeconds { get; } = timerIntervalSeconds;
+ public bool UseCondition { get; } = useCondition;
+ public bool ConditionAttacking { get; } = conditionAttacking;
+ public int SubCondition { get; } = subCondition;
+ public DateTime LastUseTime { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
index 494b71e7a..fb88ea53d 100644
--- a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
+++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
@@ -2,12 +2,10 @@
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
-namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
-
+using System.Diagnostics.CodeAnalysis;
using System.Threading;
-using MUnique.OpenMU.GameLogic.Attributes;
-using MUnique.OpenMU.GameLogic.MuHelper;
-using MUnique.OpenMU.GameLogic.Views.MuHelper;
+
+namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
///
/// Server-side AI that drives an ghost after the real
@@ -50,7 +48,7 @@ public OfflineLevelingIntelligence(OfflineLevelingPlayer player)
this._healingHandler = new HealingHandler(player, config);
this._itemPickupHandler = new ItemPickupHandler(player, config);
this._movementHandler = new MovementHandler(player, config, originalPosition);
- this._combatHandler = new CombatHandler(player, config, this._movementHandler, originalPosition);
+ this._combatHandler = new CombatHandler(player, config, this._movementHandler, this._buffHandler, originalPosition);
this._repairHandler = new RepairHandler(player, config);
this._zenHandler = new ZenConsumptionHandler(player);
@@ -87,7 +85,7 @@ public void Dispose()
this._aiTimer = null;
}
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100", Justification = "Timer callback — exceptions are caught internally.")]
+ [SuppressMessage("Usage", "VSTHRD100", Justification = "Timer callback — exceptions are caught internally.")]
private async Task SafeTickAsync()
{
try
From 454cb08c272ad4c5a8c81578b936194c695d85ac Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Wed, 18 Mar 2026 01:34:24 -0300
Subject: [PATCH 05/32] code review: minor adjustments
---
.../OfflineLeveling/RepairHandler.cs | 5 ++++
src/GameLogic/Player.cs | 26 ++++++++++++-------
.../Skills/AreaSkillAttackAction.cs | 4 ++-
.../Skills/TargetedSkillDefaultPlugin.cs | 10 +++----
4 files changed, 30 insertions(+), 15 deletions(-)
diff --git a/src/GameLogic/OfflineLeveling/RepairHandler.cs b/src/GameLogic/OfflineLeveling/RepairHandler.cs
index 7acf0bea5..7091dcabd 100644
--- a/src/GameLogic/OfflineLeveling/RepairHandler.cs
+++ b/src/GameLogic/OfflineLeveling/RepairHandler.cs
@@ -47,6 +47,11 @@ public async ValueTask PerformRepairsAsync()
continue;
}
+ if (this._player.Inventory?.GetItem(i) is null)
+ {
+ continue;
+ }
+
await this._repairAction.RepairItemAsync(this._player, i).ConfigureAwait(false);
}
}
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index a26c08b80..713b38f6b 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -2758,6 +2758,22 @@ private async ValueTask CloseTradeIfNeededAsync()
}
}
+ ///
+ /// A used to apply the GM mark
+ /// to a with status.
+ ///
+ private protected sealed class GMMagicEffectDefinition : MagicEffectDefinition
+ {
+ ///
+ /// Initializes a new instance of the class
+ /// with an empty power-up definitions list.
+ ///
+ public GMMagicEffectDefinition()
+ {
+ this.PowerUpDefinitions = new List(0);
+ }
+ }
+
private sealed class TemporaryItemStorage : ItemStorage
{
public TemporaryItemStorage()
@@ -2808,12 +2824,4 @@ public void RaiseAppearanceChanged()
this.AppearanceChanged?.Invoke(this, EventArgs.Empty);
}
}
-
- private protected sealed class GMMagicEffectDefinition : MagicEffectDefinition
- {
- public GMMagicEffectDefinition()
- {
- this.PowerUpDefinitions = new List(0);
- }
- }
}
\ No newline at end of file
diff --git a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
index 1585f5f8f..8e2a0daf3 100644
--- a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
+++ b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
@@ -51,7 +51,6 @@ public async ValueTask AttackAsync(Player player, ushort extraTargetId, ushort s
|| (skill.SkillType is SkillType.AreaSkillExplicitHits && hitImplicitlyForExplicitSkill))
{
// todo: delayed automatic hits, like evil spirit, flame, triple shot... when hitImplicitlyForExplicitSkill = true.
-
await this.PerformAutomaticHitsAsync(player, extraTargetId, targetAreaCenter, skillEntry!, skill, rotation).ConfigureAwait(false);
}
@@ -78,6 +77,9 @@ private static IEnumerable GetTargets(Player player, Point targetAr
var isExtraTargetDefined = extraTargetId != UndefinedTarget;
var extraTarget = isExtraTargetDefined ? player.GetObject(extraTargetId) as IAttackable : null;
+ player.Logger.LogDebug(
+ "GetTargets: skill={Skill}, extraTargetId={ExtraTargetId}, extraTarget={ExtraTarget}", skill.Name, extraTargetId, extraTarget?.ToString() ?? "null");
+
if (skill.SkillType == SkillType.AreaSkillExplicitTarget)
{
if (extraTarget?.CheckSkillTargetRestrictions(player, skill) is true
diff --git a/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs b/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs
index a725b3b98..c3f62ca6f 100644
--- a/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs
+++ b/src/GameLogic/PlayerActions/Skills/TargetedSkillDefaultPlugin.cs
@@ -55,13 +55,13 @@ public override async ValueTask PerformSkillAsync(Player player, IAttackable tar
if (attributes[Stats.IsStunned] > 0)
{
- player.Logger.LogWarning($"Probably Hacker - player {player} is attacking in stunned state");
+ player.Logger.LogWarning("Probably Hacker - player {Player} is attacking in stunned state", player);
return;
}
if (attributes[Stats.IsAsleep] > 0)
{
- player.Logger.LogWarning($"Probably Hacker - player {player} is attacking in asleep state");
+ player.Logger.LogWarning("Probably Hacker - player {Player} is attacking in asleep state", player);
return;
}
@@ -279,7 +279,7 @@ private async ValueTask ApplySkillAsync(Player player, IAttackable targete
var canDoBuff = !player.IsAtSafezone() || player.CurrentMiniGame is { };
if (!canDoBuff)
{
- player.Logger.LogWarning($"Can't apply magic effect when being in the safezone. skill: {skill.Name} ({skill.Number}), skillType: {skill.SkillType}.");
+ player.Logger.LogWarning("Can't apply magic effect when being in the safe-zone. skill: {SkillName} ({SkillNumber}), skillType: {SkillType}.", skill.Name, skill.Number, skill.SkillType);
break;
}
@@ -295,12 +295,12 @@ private async ValueTask ApplySkillAsync(Player player, IAttackable targete
}
else
{
- player.Logger.LogWarning($"Skill.MagicEffectDef isn't null, but it's not a buff or regeneration skill. skill: {skill.Name} ({skill.Number}), skillType: {skill.SkillType}.");
+ player.Logger.LogWarning("Skill.MagicEffectDef isn't null, but it's not a buff or regeneration skill. skill: {SkillName} ({SkillNumber}), skillType: {SkillType}.", skill.Name, skill.Number, skill.SkillType);
}
}
else
{
- player.Logger.LogWarning($"Skill.MagicEffectDef is null, skill: {skill.Name} ({skill.Number}), skillType: {skill.SkillType}.");
+ player.Logger.LogWarning("Skill.MagicEffectDef is null, skill: {SkillName} ({SkillNumber}), skillType: {SkillType}.", skill.Name, skill.Number, skill.SkillType);
}
}
From 96829fd17fb035e74d726124d5f2d8710126ac8c Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Wed, 18 Mar 2026 14:29:38 -0300
Subject: [PATCH 06/32] feature: offline leveling pet support and some minor
adjustments
---
src/GameLogic/OfflineLeveling/BuffHandler.cs | 113 +++++++++++-------
.../OfflineLeveling/CombatHandler.cs | 26 +---
.../OfflineLeveling/HealingHandler.cs | 2 +-
.../OfflineLevelingIntelligence.cs | 24 ++--
src/GameLogic/OfflineLeveling/PetHandler.cs | 95 +++++++++++++++
5 files changed, 180 insertions(+), 80 deletions(-)
create mode 100644 src/GameLogic/OfflineLeveling/PetHandler.cs
diff --git a/src/GameLogic/OfflineLeveling/BuffHandler.cs b/src/GameLogic/OfflineLeveling/BuffHandler.cs
index e6db35aaa..80341b3f5 100644
--- a/src/GameLogic/OfflineLeveling/BuffHandler.cs
+++ b/src/GameLogic/OfflineLeveling/BuffHandler.cs
@@ -6,8 +6,9 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.MuHelper;
+using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
+using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views.World;
-using MUnique.OpenMU.Interfaces;
///
/// Handles buff application and management for the offline leveling player.
@@ -70,19 +71,16 @@ public async ValueTask PerformBuffsAsync()
continue;
}
- // Try to buff self
- if (await this.TryBuffTargetAsync(this._player, skillEntry, false).ConfigureAwait(false))
+ if (await this.PerformSelfBuffAsync(skillEntry).ConfigureAwait(false))
{
return false;
}
- // Try to buff party members
- if (await this.TryBuffPartyMembersAsync(skillEntry).ConfigureAwait(false))
+ if (await this.PerformPartyBuffAsync(skillEntry).ConfigureAwait(false))
{
return false;
}
- // Move to next slot if no one needed this buff
this.MoveNextSlot();
}
@@ -92,7 +90,7 @@ public async ValueTask PerformBuffsAsync()
///
/// Gets the configured buff skill IDs from the settings.
///
- /// A list containing BuffSkill0Id, BuffSkill1Id, and BuffSkill2Id. Returns an empty list if configuration is null.
+ /// A list containing BuffSkill0Id, BuffSkill1Id, and BuffSkill2Id.
public List GetConfiguredBuffIds()
{
if (this._config is null)
@@ -130,34 +128,6 @@ private void UpdatePeriodicBuffTimer()
}
}
- private async ValueTask TryBuffPartyMembersAsync(SkillEntry skillEntry)
- {
- if (this._config is not { SupportParty: true } || this._player.Party is not { } party)
- {
- return false;
- }
-
- foreach (var member in party.PartyList.OfType())
- {
- if (member == (IAttackable)this._player)
- {
- continue;
- }
-
- if (!IsSkillQualifiedForTarget(skillEntry))
- {
- continue;
- }
-
- if (await this.TryBuffTargetAsync(member, skillEntry, true).ConfigureAwait(false))
- {
- return true;
- }
- }
-
- return false;
- }
-
private void MoveNextSlot()
{
this._buffSkillIndex = (this._buffSkillIndex + 1) % BuffSlotCount;
@@ -167,37 +137,88 @@ private void MoveNextSlot()
}
}
- private async ValueTask TryBuffTargetAsync(IAttackable target, SkillEntry skillEntry, bool isPartyMember)
+ private bool ShouldApplyBuff(IAttackable target, SkillEntry skillEntry, bool isPartyMember)
{
if (skillEntry.Skill?.MagicEffectDef is not { } effectDef)
{
return false;
}
- if (!target.IsActive() || !this._player.IsInRange(target, 8))
+ if (!target.IsActive() || !this._player.IsInRange(target, this._config!.HuntingRange))
{
return false;
}
- bool alreadyActive = target.MagicEffectList.ActiveEffects.Values
+ var alreadyActive = target.MagicEffectList.ActiveEffects.Values
.Any(e => e.Definition == effectDef);
- bool shouldApply;
if (isPartyMember)
{
- shouldApply = this._config!.BuffDurationForParty ? !alreadyActive : this._buffTimerTriggered;
+ return this._config!.BuffDurationForParty ? !alreadyActive : this._buffTimerTriggered;
+ }
+
+ return !alreadyActive || this._buffTimerTriggered;
+ }
+
+ private async ValueTask PerformSelfBuffAsync(SkillEntry skillEntry)
+ {
+ if (!this.ShouldApplyBuff(this._player, skillEntry, false))
+ {
+ return false;
+ }
+
+ if (skillEntry.Skill?.Target == SkillTarget.ImplicitParty)
+ {
+ return await this.PerformImplicitPartyBuffAsync(skillEntry).ConfigureAwait(false);
}
- else
+
+ await ((IObservable)this._player).ForEachWorldObserverAsync(
+ p => p.ShowSkillAnimationAsync(this._player, this._player, skillEntry.Skill!, true),
+ includeThis: true).ConfigureAwait(false);
+ await this._player.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false);
+ this.MoveNextSlot();
+ return true;
+ }
+
+ private async ValueTask PerformImplicitPartyBuffAsync(SkillEntry skillEntry)
+ {
+ var strategy = this._player.GameContext.PlugInManager
+ .GetStrategy((short)skillEntry.Skill!.Number)
+ ?? new TargetedSkillDefaultPlugin();
+
+ await strategy.PerformSkillAsync(this._player, this._player, (ushort)skillEntry.Skill.Number).ConfigureAwait(false);
+ this.MoveNextSlot();
+ return true;
+ }
+
+ private async ValueTask PerformPartyBuffAsync(SkillEntry skillEntry)
+ {
+ if (this._config is not { SupportParty: true } || this._player.Party is not { } party)
{
- shouldApply = !alreadyActive || this._buffTimerTriggered;
+ return false;
}
- if (shouldApply)
+ foreach (var member in party.PartyList.OfType())
{
- await this._player.ForEachWorldObserverAsync(
- p => p.ShowSkillAnimationAsync(this._player, target, skillEntry.Skill!, true),
+ if (member == this._player)
+ {
+ continue;
+ }
+
+ if (!IsSkillQualifiedForTarget(skillEntry))
+ {
+ continue;
+ }
+
+ if (!this.ShouldApplyBuff(member, skillEntry, true))
+ {
+ continue;
+ }
+
+ await (member as IObservable ?? this._player).ForEachWorldObserverAsync(
+ p => p.ShowSkillAnimationAsync(this._player, member, skillEntry.Skill!, true),
includeThis: true).ConfigureAwait(false);
- await target.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false);
+ await member.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false);
this.MoveNextSlot();
return true;
}
diff --git a/src/GameLogic/OfflineLeveling/CombatHandler.cs b/src/GameLogic/OfflineLeveling/CombatHandler.cs
index 57ae9e3a3..9ad863ab5 100644
--- a/src/GameLogic/OfflineLeveling/CombatHandler.cs
+++ b/src/GameLogic/OfflineLeveling/CombatHandler.cs
@@ -2,6 +2,8 @@
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
+namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
+
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.MuHelper;
using MUnique.OpenMU.GameLogic.NPC;
@@ -10,8 +12,6 @@
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
-namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
-
///
/// Handles combat logic including target selection, attacks, combo attacks, and skill usage.
///
@@ -164,7 +164,7 @@ private async ValueTask ExecuteAttackAsync(IAttackable target)
{
// Do not attack if there are buffs configured to handle buff-only classes.
var buffs = this._buffHandler.GetConfiguredBuffIds();
- if (buffs.Count > 0)
+ if (buffs.Any(id => id > 0))
{
return;
}
@@ -519,25 +519,7 @@ private byte GetEffectiveAttackRange()
}
}
- return this.GetOffensiveSkills()
- .Select(s => (byte)s.Skill!.Range)
- .DefaultIfEmpty(DefaultRange)
- .Max();
- }
-
- private IEnumerable GetOffensiveSkills()
- {
- return this._player.SkillList?.Skills
- .Where(s => s.Skill is not null
- && s.Skill.SkillType != SkillType.PassiveBoost
- && s.Skill.SkillType != SkillType.Buff
- && s.Skill.SkillType != SkillType.Regeneration)
- ?? [];
- }
-
- private SkillEntry? GetAnyOffensiveSkill()
- {
- return this.GetOffensiveSkills().FirstOrDefault();
+ return DefaultRange;
}
private SkillEntry? FindDrainLifeSkill()
diff --git a/src/GameLogic/OfflineLeveling/HealingHandler.cs b/src/GameLogic/OfflineLeveling/HealingHandler.cs
index f70bba73e..eeb15947b 100644
--- a/src/GameLogic/OfflineLeveling/HealingHandler.cs
+++ b/src/GameLogic/OfflineLeveling/HealingHandler.cs
@@ -99,7 +99,7 @@ private async ValueTask PerformPartyHealingAsync()
continue;
}
- if (!member.IsActive() || !this._player.IsInRange(member, 8))
+ if (!member.IsActive() || !this._player.IsInRange(member, this._config!.HuntingRange))
{
continue;
}
diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
index fb88ea53d..39e370612 100644
--- a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
+++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
@@ -2,21 +2,21 @@
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
-using System.Diagnostics.CodeAnalysis;
-using System.Threading;
-
namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
+using System.Threading;
+
///
-/// Server-side AI that drives an ghost after the real
+/// Server-side AI that drives an 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 %
+/// - Buff application (up to 3 configured buff skills)
+/// - 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
+/// - Pet control
///
///
public sealed class OfflineLevelingIntelligence : IDisposable
@@ -30,6 +30,7 @@ public sealed class OfflineLevelingIntelligence : IDisposable
private readonly RepairHandler _repairHandler;
private readonly ZenConsumptionHandler _zenHandler;
private readonly HealingHandler _healingHandler;
+ private readonly PetHandler _petHandler;
private Timer? _aiTimer;
private bool _disposed;
@@ -51,6 +52,7 @@ public OfflineLevelingIntelligence(OfflineLevelingPlayer player)
this._combatHandler = new CombatHandler(player, config, this._movementHandler, this._buffHandler, originalPosition);
this._repairHandler = new RepairHandler(player, config);
this._zenHandler = new ZenConsumptionHandler(player);
+ this._petHandler = new PetHandler(player, config);
if (config is null)
{
@@ -65,6 +67,8 @@ public OfflineLevelingIntelligence(OfflineLevelingPlayer player)
/// Starts the 500 ms AI timer.
public void Start()
{
+ _ = this._petHandler.InitializeAsync();
+
this._aiTimer ??= new Timer(
state => _ = this.SafeTickAsync(),
null,
@@ -85,7 +89,7 @@ public void Dispose()
this._aiTimer = null;
}
- [SuppressMessage("Usage", "VSTHRD100", Justification = "Timer callback — exceptions are caught internally.")]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100", Justification = "Timer callback — exceptions are caught internally.")]
private async Task SafeTickAsync()
{
try
@@ -110,8 +114,8 @@ private async ValueTask TickAsync()
}
await this._zenHandler.DeductZenAsync().ConfigureAwait(false);
-
await this._repairHandler.PerformRepairsAsync().ConfigureAwait(false);
+ await this._petHandler.CheckPetDurabilityAsync().ConfigureAwait(false);
if (this.IsOnSkillCooldown())
{
@@ -125,9 +129,7 @@ private async ValueTask TickAsync()
}
await this._healingHandler.PerformHealthRecoveryAsync().ConfigureAwait(false);
-
await this._combatHandler.PerformDrainLifeRecoveryAsync().ConfigureAwait(false);
-
await this._itemPickupHandler.PickupItemsAsync().ConfigureAwait(false);
if (this._player.IsWalking)
@@ -158,4 +160,4 @@ private bool IsOnSkillCooldown()
return false;
}
-}
+}
\ No newline at end of file
diff --git a/src/GameLogic/OfflineLeveling/PetHandler.cs b/src/GameLogic/OfflineLeveling/PetHandler.cs
new file mode 100644
index 000000000..5a4be6b9d
--- /dev/null
+++ b/src/GameLogic/OfflineLeveling/PetHandler.cs
@@ -0,0 +1,95 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
+
+using MUnique.OpenMU.GameLogic.MuHelper;
+using MUnique.OpenMU.GameLogic.Pet;
+using MUnique.OpenMU.GameLogic.PlayerActions.Items;
+
+///
+/// Handles pet behavior initialization and management for the offline leveling player.
+///
+internal sealed class PetHandler
+{
+ private readonly OfflineLevelingPlayer _player;
+ private readonly IMuHelperSettings? _config;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The offline leveling player.
+ /// The MU Helper configuration.
+ public PetHandler(OfflineLevelingPlayer player, IMuHelperSettings? config)
+ {
+ this._player = player;
+ this._config = config;
+ }
+
+ ///
+ /// Initializes the dark raven behavior if configured.
+ /// The raven runs its own internal attack loop independently of the player's tick.
+ ///
+ public async ValueTask InitializeAsync()
+ {
+ try
+ {
+ await this.InitializeDarkRavenAsync().ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // expected during shutdown
+ }
+ catch (Exception ex)
+ {
+ this._player.Logger.LogError(ex, "Error initializing pet for character {CharacterName}.", this._player.Name);
+ }
+ }
+
+ ///
+ /// Checks the pet durability and sets the pet to idle if it has run out.
+ ///
+ public async ValueTask CheckPetDurabilityAsync()
+ {
+ if (this._player.PetCommandManager is null)
+ {
+ return;
+ }
+
+ try
+ {
+ if (this._player.Inventory?.GetItem(InventoryConstants.PetSlot) is { Durability: 0 })
+ {
+ await this._player.PetCommandManager.SetBehaviourAsync(PetBehaviour.Idle, null).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // expected during shutdown
+ }
+ catch (Exception ex)
+ {
+ this._player.Logger.LogError(ex, "Error checking pet durability for {AccountLoginName}.", this._player.AccountLoginName);
+ }
+ }
+
+ private async ValueTask InitializeDarkRavenAsync()
+ {
+ if (this._config is not { UseDarkRaven: true } || this._player.PetCommandManager is not { } petCommandManager)
+ {
+ return;
+ }
+
+ var behaviour = this._config.DarkRavenMode switch
+ {
+ 1 => PetBehaviour.AttackRandom,
+ 2 => PetBehaviour.AttackWithOwner,
+ _ => PetBehaviour.Idle,
+ };
+
+ await petCommandManager.SetBehaviourAsync(behaviour, null).ConfigureAwait(false);
+ }
+
+
+}
\ No newline at end of file
From c98e22a9a2681b953dda91d0ec3ffad90a70e617 Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Thu, 19 Mar 2026 12:36:34 -0300
Subject: [PATCH 07/32] fix: potential race condition on offline leveling login
---
src/GameLogic/PlayerActions/LoginAction.cs | 4 ++--
src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/GameLogic/PlayerActions/LoginAction.cs b/src/GameLogic/PlayerActions/LoginAction.cs
index 2610e7fd3..3b09a9ee1 100644
--- a/src/GameLogic/PlayerActions/LoginAction.cs
+++ b/src/GameLogic/PlayerActions/LoginAction.cs
@@ -96,9 +96,9 @@ private async ValueTask ValidateAccountStateAsync(Player player, AccountSt
return (false, null);
}
- if (player.GameContext.OfflineLevelingManager.IsActive(username))
+ if (player.GameContext.OfflineLevelingManager.TryGetPlayer(username, out var offlinePlayer))
{
- var isTemplateOffline = player.GameContext.OfflineLevelingManager.TryGetPlayer(username, out var offlinePlayer) && offlinePlayer!.IsTemplatePlayer;
+ var isTemplateOffline = offlinePlayer!.IsTemplatePlayer;
if (!isTemplateOffline && !await gameServerContext.LoginServer.TryLoginAsync(username, gameServerContext.Id).ConfigureAwait(false))
{
context.Allowed = false;
diff --git a/src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs b/src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs
index fbd5e7afb..9713edc17 100644
--- a/src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs
+++ b/src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs
@@ -32,7 +32,7 @@ public async ValueTask PlayerStateChangedAsync(Player player, State previousStat
if (player.Party is not null)
{
- player.Logger.LogDebug("Player {0} rejoined their previous party.", player.Name);
+ player.Logger.LogDebug("Player {PlayerName} rejoined their previous party.", player.Name);
await player.InvokeViewPlugInAsync(p => p.UpdatePartyHealthAsync()).ConfigureAwait(false);
}
}
From b91d7caf0185d969c44efdf5f4c6a63be172ad8d Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Fri, 20 Mar 2026 21:52:39 -0300
Subject: [PATCH 08/32] fix: stop pet when player dies
---
.../OfflineLevelingIntelligence.cs | 48 ++++++++++++-------
.../OfflineLeveling/OfflineLevelingPlayer.cs | 8 +++-
src/GameLogic/OfflineLeveling/PetHandler.cs | 18 +++++++
3 files changed, 55 insertions(+), 19 deletions(-)
diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
index 1257bae79..95e25b1fc 100644
--- a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
+++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
@@ -19,7 +19,7 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
/// - Pet control
///
///
-public sealed class OfflineLevelingIntelligence : IDisposable
+public sealed class OfflineLevelingIntelligence : AsyncDisposable
{
private readonly OfflineLevelingPlayer _player;
@@ -31,9 +31,9 @@ public sealed class OfflineLevelingIntelligence : IDisposable
private readonly ZenConsumptionHandler _zenHandler;
private readonly HealingHandler _healingHandler;
private readonly PetHandler _petHandler;
+ private readonly CancellationTokenSource _cts = new();
private Timer? _aiTimer;
- private bool _disposed;
private bool _isDead;
///
@@ -63,48 +63,57 @@ public OfflineLevelingIntelligence(OfflineLevelingPlayer player)
{
this._player.Logger.LogDebug("Offline leveling configuration for {CharacterName}: MuHelperSettings={Settings}.", this._player.Name, config);
}
-
+
this._player.Died += this.OnPlayerDied;
}
- /// Starts the 500 ms AI timer.
+ /// Starts the 500 ms AI timer and a separate pet AI.
public void Start()
{
_ = this._petHandler.InitializeAsync();
-
+
this._aiTimer ??= new Timer(
- state => _ = this.SafeTickAsync(),
+ _ => _ = this.SafeTickAsync(this._cts.Token),
null,
TimeSpan.FromSeconds(1),
TimeSpan.FromMilliseconds(500));
}
///
- public void Dispose()
+ protected override async ValueTask DisposeAsyncCore()
+ {
+ await this._cts.CancelAsync().ConfigureAwait(false);
+ await this._petHandler.StopAsync().ConfigureAwait(false);
+ await base.DisposeAsyncCore().ConfigureAwait(false);
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
{
- if (this._disposed)
+ if (disposing)
{
- return;
+ this._player.Died -= this.OnPlayerDied;
+ this._aiTimer?.Dispose();
+ this._aiTimer = null;
+ this._cts.Dispose();
}
-
- this._player.Died -= this.OnPlayerDied;
- this._disposed = true;
- this._aiTimer?.Dispose();
- this._aiTimer = null;
+
+ base.Dispose(disposing);
}
private void OnPlayerDied(object? sender, DeathInformation e)
{
this._player.Logger.LogDebug("Offline leveling player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName);
this._isDead = true;
+ this._cts.Cancel();
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100", Justification = "Timer callback — exceptions are caught internally.")]
- private async Task SafeTickAsync()
+ private async Task SafeTickAsync(CancellationToken cancellationToken)
{
try
{
- await this.TickAsync().ConfigureAwait(false);
+ await this.TickAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -116,13 +125,18 @@ private async Task SafeTickAsync()
}
}
- private async ValueTask TickAsync()
+ private async ValueTask TickAsync(CancellationToken cancellationToken)
{
if (await this.HandleDeathAsync().ConfigureAwait(false))
{
return;
}
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
if (this._player.PlayerState.CurrentState != PlayerState.EnteredWorld)
{
return;
diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs
index 34fb7e4d9..f71d5cc62 100644
--- a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs
+++ b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs
@@ -103,8 +103,12 @@ private void StartIntelligence()
///
public async ValueTask StopAsync()
{
- this._intelligence?.Dispose();
- this._intelligence = null;
+ if (this._intelligence is { } intelligence)
+ {
+ await intelligence.DisposeAsync().ConfigureAwait(false);
+ this._intelligence = null;
+ }
+
try
{
await this.SaveProgressAsync().ConfigureAwait(false);
diff --git a/src/GameLogic/OfflineLeveling/PetHandler.cs b/src/GameLogic/OfflineLeveling/PetHandler.cs
index 5a4be6b9d..d0fed6cea 100644
--- a/src/GameLogic/OfflineLeveling/PetHandler.cs
+++ b/src/GameLogic/OfflineLeveling/PetHandler.cs
@@ -91,5 +91,23 @@ private async ValueTask InitializeDarkRavenAsync()
await petCommandManager.SetBehaviourAsync(behaviour, null).ConfigureAwait(false);
}
+ ///
+ /// Stops the pet behavior.
+ ///
+ public async ValueTask StopAsync()
+ {
+ if (this._player.PetCommandManager is { } petCommandManager)
+ {
+ try
+ {
+ await petCommandManager.SetBehaviourAsync(PetBehaviour.Idle, null).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ this._player.Logger.LogError(ex, "Error stopping pet for {AccountLoginName}.", this._player.AccountLoginName);
+ }
+ }
+ }
+
}
\ No newline at end of file
From bb0504ddb693dd78808548352eff8995822d86ed Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Sat, 21 Mar 2026 00:08:03 -0300
Subject: [PATCH 09/32] code review: fix file formatting
---
.../MUnique.OpenMU.Web.AdminPanel.csproj | 137 +++++++-----------
.../Shared/MUnique.OpenMU.Web.Shared.csproj | 8 +
2 files changed, 64 insertions(+), 81 deletions(-)
diff --git a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj
index 69d82b0b5..811da76da 100644
--- a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj
+++ b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj
@@ -1,89 +1,64 @@
-
- net10.0
- enable
- nullable;CS4014;VSTHRD110;VSTHRD100
- false
- false
-
- _content/$(MSBuildProjectName)
-
+
+ net10.0
+ enable
+ nullable;CS4014;VSTHRD110;VSTHRD100
+ false
+ false
+
+ _content/$(MSBuildProjectName)
+
+
+
+ ..\..\..\bin\Debug\
+ ..\..\..\bin\Debug\MUnique.OpenMU.Web.AdminPanel.xml
+
+
+ ..\..\..\bin\Release\
+ ..\..\..\bin\Release\MUnique.OpenMU.Web.AdminPanel.xml
+
-
- ..\..\..\bin\Debug\
- ..\..\..\bin\Debug\MUnique.OpenMU.Web.AdminPanel.xml
-
-
- ..\..\..\bin\Release\
- ..\..\..\bin\Release\MUnique.OpenMU.Web.AdminPanel.xml
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
- ResXFileCodeGenerator
- Resources.Designer.cs
-
+
+ <_StaticWebAsset Include="@(StaticWebAsset)" />
+
+
-
-
- True
- True
- Resources.resx
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- <_StaticWebAsset Include="@(StaticWebAsset)"/>
-
-
-
-
+
diff --git a/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj b/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj
index 080635b7a..e0d844bab 100644
--- a/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj
+++ b/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj
@@ -89,4 +89,12 @@
+
+
+
+
+
+
+
+
From 1168dcb39564707319aa2e105dc34a1707eb0de9 Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Sat, 21 Mar 2026 14:44:39 -0300
Subject: [PATCH 10/32] feature: offline leveling test methods
---
.../Configuration/MonsterDefinition.cs | 13 +-
src/GameLogic/GameContext.cs | 4 +-
src/GameLogic/IGameContext.cs | 2 +-
src/GameLogic/MapInitializer.cs | 4 +-
src/GameLogic/NPC/Monster.cs | 8 +-
.../OfflineLeveling/OfflineLevelingPlayer.cs | 2 +-
src/GameLogic/OfflineLeveling/PetHandler.cs | 15 +-
src/GameLogic/PathFinderPoolingPolicy.cs | 12 +-
src/Pathfinding/IPathFinder.cs | 9 +-
src/Pathfinding/PathFinder.cs | 2 +-
.../PreCalculation/PreCalculatedPathFinder.cs | 8 +-
.../PlugInManagerTest.cs | 14 +-
.../PlugInProxyTypeGeneratorTest.cs | 6 +-
.../MUnique.OpenMU.Tests/CharacterMoveTest.cs | 2 +-
.../MUnique.OpenMU.Tests/DropGeneratorTest.cs | 4 +-
.../GameContextTestHelper.cs | 50 +++
tests/MUnique.OpenMU.Tests/GuildActionTest.cs | 4 +-
.../ItemConsumptionTest.cs | 2 +-
.../MUnique.OpenMU.Tests/MasterSystemTest.cs | 2 +-
.../Offlevel/BuffHandlerTests.cs | 77 ++++
.../Offlevel/CombatHandlerTests.cs | 160 +++++++++
.../Offlevel/HealingHandlerTests.cs | 103 ++++++
.../Offlevel/ItemPickupHandlerTests.cs | 71 ++++
.../Offlevel/MovementHandlerTests.cs | 61 ++++
.../OfflineLevelingIntelligenceTests.cs | 64 ++++
.../Offlevel/OfflineLevelingManagerTests.cs | 98 +++++
.../Offlevel/OfflineLevelingPlayerTests.cs | 63 ++++
.../Offlevel/OfflineLevelingTest.cs | 334 ------------------
.../Offlevel/PetHandlerTests.cs | 117 ++++++
.../Offlevel/RepairHandlerTests.cs | 142 ++++++++
.../Offlevel/ZenConsumptionHandlerTests.cs | 96 +++++
.../Party/PartyManagerTest.cs | 20 +-
tests/MUnique.OpenMU.Tests/Party/PartyTest.cs | 17 +-
.../{TestHelper.cs => PlayerTestHelper.cs} | 25 +-
.../PowerUpFactoryTest.cs | 12 +-
.../SelfDefensePlugInTest.cs | 2 +-
tests/MUnique.OpenMU.Tests/SkillListTest.cs | 8 +-
tests/MUnique.OpenMU.Tests/TradeTest.cs | 8 +-
38 files changed, 1201 insertions(+), 440 deletions(-)
create mode 100644 tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/BuffHandlerTests.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/HealingHandlerTests.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/ItemPickupHandlerTests.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/MovementHandlerTests.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingIntelligenceTests.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingManagerTests.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingPlayerTests.cs
delete mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/PetHandlerTests.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/RepairHandlerTests.cs
create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/ZenConsumptionHandlerTests.cs
rename tests/MUnique.OpenMU.Tests/{TestHelper.cs => PlayerTestHelper.cs} (93%)
diff --git a/src/DataModel/Configuration/MonsterDefinition.cs b/src/DataModel/Configuration/MonsterDefinition.cs
index d511a62cc..7a068c4a7 100644
--- a/src/DataModel/Configuration/MonsterDefinition.cs
+++ b/src/DataModel/Configuration/MonsterDefinition.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -219,6 +219,17 @@ public enum NpcObjectKind
[Cloneable]
public partial class MonsterDefinition
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MonsterDefinition()
+ {
+ this.ItemCraftings = new List();
+ this.DropItemGroups = new List();
+ this.Attributes = new List();
+ this.Quests = new List();
+ }
+
///
/// Gets or sets the unique number of this monster.
///
diff --git a/src/GameLogic/GameContext.cs b/src/GameLogic/GameContext.cs
index a4538f33d..fdd5d6d86 100644
--- a/src/GameLogic/GameContext.cs
+++ b/src/GameLogic/GameContext.cs
@@ -35,7 +35,7 @@ public class GameContext : AsyncDisposable, IGameContext
private static readonly Counter MiniGameCounter = Meter.CreateCounter("MiniGameCount");
- private static readonly IObjectPool PathFinderPoolInstance = new LimitedObjectPool(new PathFinderPoolingPolicy());
+ private static readonly IObjectPool PathFinderPoolInstance = new LimitedObjectPool(new PathFinderPoolingPolicy());
private readonly Dictionary _mapList = new();
@@ -166,7 +166,7 @@ public GameContext(GameConfiguration configuration, IPersistenceContextProvider
///
/// Gets the path finder pool.
///
- public IObjectPool PathFinderPool => PathFinderPoolInstance;
+ public IObjectPool PathFinderPool => PathFinderPoolInstance;
///
public int PlayerCount => this._playerList.Count;
diff --git a/src/GameLogic/IGameContext.cs b/src/GameLogic/IGameContext.cs
index e340a1fd0..7e2459ba1 100644
--- a/src/GameLogic/IGameContext.cs
+++ b/src/GameLogic/IGameContext.cs
@@ -100,7 +100,7 @@ public interface IGameContext
///
/// Gets the object pool for path finders.
///
- IObjectPool PathFinderPool { get; }
+ IObjectPool PathFinderPool { get; }
///
/// Gets the duel room manager.
diff --git a/src/GameLogic/MapInitializer.cs b/src/GameLogic/MapInitializer.cs
index f3e8a6f16..7026dc682 100644
--- a/src/GameLogic/MapInitializer.cs
+++ b/src/GameLogic/MapInitializer.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -50,7 +50,7 @@ public MapInitializer(GameConfiguration configuration, ILogger l
///
/// The path finder pool.
///
- public IObjectPool? PathFinderPool { get; set; }
+ public IObjectPool? PathFinderPool { get; set; }
///
/// Gets or sets the size of the chunk of created s.
diff --git a/src/GameLogic/NPC/Monster.cs b/src/GameLogic/NPC/Monster.cs
index d2ab8bc0d..2724f6b16 100644
--- a/src/GameLogic/NPC/Monster.cs
+++ b/src/GameLogic/NPC/Monster.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -38,7 +38,7 @@ public sealed class Monster : AttackableNpcBase, IAttackable, IAttacker, ISuppor
///
private readonly AttributeDefinition? _skillPowerUpTarget;
- private readonly IObjectPool _pathFinderPool;
+ private readonly IObjectPool _pathFinderPool;
private bool _isCalculatingPath;
@@ -55,7 +55,7 @@ public sealed class Monster : AttackableNpcBase, IAttackable, IAttacker, ISuppor
/// The plug in manager.
/// The path finder pool.
/// The event state provider.
- public Monster(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map, IDropGenerator dropGenerator, INpcIntelligence npcIntelligence, PlugInManager plugInManager, IObjectPool pathFinderPool, IEventStateProvider? eventStateProvider = null)
+ public Monster(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map, IDropGenerator dropGenerator, INpcIntelligence npcIntelligence, PlugInManager plugInManager, IObjectPool pathFinderPool, IEventStateProvider? eventStateProvider = null)
: base(spawnInfo, stats, map, eventStateProvider, dropGenerator, plugInManager)
{
this._pathFinderPool = pathFinderPool;
@@ -148,7 +148,7 @@ public async ValueTask WalkToAsync(Point target)
IList? calculatedPath;
this._isCalculatingPath = true;
- PathFinder? pathFinder = null;
+ IPathFinder? pathFinder = null;
try
{
pathFinder = await this._pathFinderPool.GetAsync().ConfigureAwait(false);
diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs
index f71d5cc62..ae2661ca4 100644
--- a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs
+++ b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs
@@ -33,7 +33,7 @@ public OfflineLevelingPlayer(IGameContext gameContext)
///
/// Gets the start timestamp of the offline leveling session.
///
- public DateTime StartTimestamp { get; private set; }
+ public DateTime StartTimestamp { get; internal set; }
///
/// Initializes the offline player from captured references.
diff --git a/src/GameLogic/OfflineLeveling/PetHandler.cs b/src/GameLogic/OfflineLeveling/PetHandler.cs
index d0fed6cea..960f47000 100644
--- a/src/GameLogic/OfflineLeveling/PetHandler.cs
+++ b/src/GameLogic/OfflineLeveling/PetHandler.cs
@@ -15,18 +15,23 @@ internal sealed class PetHandler
{
private readonly OfflineLevelingPlayer _player;
private readonly IMuHelperSettings? _config;
+ private readonly IPetCommandManager? _petCommandManager;
///
/// Initializes a new instance of the class.
///
/// The offline leveling player.
/// The MU Helper configuration.
- public PetHandler(OfflineLevelingPlayer player, IMuHelperSettings? config)
+ /// Optional pet command manager for testing.
+ public PetHandler(OfflineLevelingPlayer player, IMuHelperSettings? config, IPetCommandManager? petCommandManager = null)
{
this._player = player;
this._config = config;
+ this._petCommandManager = petCommandManager;
}
+ private IPetCommandManager? PetCommandManager => this._petCommandManager ?? this._player.PetCommandManager;
+
///
/// Initializes the dark raven behavior if configured.
/// The raven runs its own internal attack loop independently of the player's tick.
@@ -52,7 +57,7 @@ public async ValueTask InitializeAsync()
///
public async ValueTask CheckPetDurabilityAsync()
{
- if (this._player.PetCommandManager is null)
+ if (this.PetCommandManager is null)
{
return;
}
@@ -61,7 +66,7 @@ public async ValueTask CheckPetDurabilityAsync()
{
if (this._player.Inventory?.GetItem(InventoryConstants.PetSlot) is { Durability: 0 })
{
- await this._player.PetCommandManager.SetBehaviourAsync(PetBehaviour.Idle, null).ConfigureAwait(false);
+ await this.PetCommandManager.SetBehaviourAsync(PetBehaviour.Idle, null).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
@@ -76,7 +81,7 @@ public async ValueTask CheckPetDurabilityAsync()
private async ValueTask InitializeDarkRavenAsync()
{
- if (this._config is not { UseDarkRaven: true } || this._player.PetCommandManager is not { } petCommandManager)
+ if (this._config is not { UseDarkRaven: true } || this.PetCommandManager is not { } petCommandManager)
{
return;
}
@@ -96,7 +101,7 @@ private async ValueTask InitializeDarkRavenAsync()
///
public async ValueTask StopAsync()
{
- if (this._player.PetCommandManager is { } petCommandManager)
+ if (this.PetCommandManager is { } petCommandManager)
{
try
{
diff --git a/src/GameLogic/PathFinderPoolingPolicy.cs b/src/GameLogic/PathFinderPoolingPolicy.cs
index 62ba960ba..8825579cd 100644
--- a/src/GameLogic/PathFinderPoolingPolicy.cs
+++ b/src/GameLogic/PathFinderPoolingPolicy.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -8,19 +8,19 @@ namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Pathfinding;
///
-/// The which implements the creation
-/// of the with an .
+/// The which implements the creation
+/// of the with an .
///
-public class PathFinderPoolingPolicy : PooledObjectPolicy
+public class PathFinderPoolingPolicy : PooledObjectPolicy
{
///
- public override PathFinder Create()
+ public override IPathFinder Create()
{
return new PathFinder(new ScopedGridNetwork());
}
///
- public override bool Return(PathFinder obj)
+ public override bool Return(IPathFinder obj)
{
return true;
}
diff --git a/src/Pathfinding/IPathFinder.cs b/src/Pathfinding/IPathFinder.cs
index 7d8688204..92f1efc60 100644
--- a/src/Pathfinding/IPathFinder.cs
+++ b/src/Pathfinding/IPathFinder.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -9,8 +9,13 @@ namespace MUnique.OpenMU.Pathfinding;
///
/// Interface for a path finder.
///
-internal interface IPathFinder
+public interface IPathFinder
{
+ ///
+ /// Resets the path finder.
+ ///
+ void ResetPathFinder();
+
///
/// Finds the path between two points.
///
diff --git a/src/Pathfinding/PathFinder.cs b/src/Pathfinding/PathFinder.cs
index 2ff260ced..0565c53e2 100644
--- a/src/Pathfinding/PathFinder.cs
+++ b/src/Pathfinding/PathFinder.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/Pathfinding/PreCalculation/PreCalculatedPathFinder.cs b/src/Pathfinding/PreCalculation/PreCalculatedPathFinder.cs
index ce74cac6f..9479dd1c3 100644
--- a/src/Pathfinding/PreCalculation/PreCalculatedPathFinder.cs
+++ b/src/Pathfinding/PreCalculation/PreCalculatedPathFinder.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -22,6 +22,12 @@ public PreCalculatedPathFinder(IEnumerable pathInfos)
this._nextSteps = pathInfos.ToDictionary(info => info.Combination, info => info.NextStep);
}
+ ///
+ public void ResetPathFinder()
+ {
+ // No state to reset in this implementation.
+ }
+
///
public IList? FindPath(Point start, Point end, byte[,] terrain, bool includeSafezone, CancellationToken cancellationToken = default)
{
diff --git a/tests/MUnique.OpenMU.PlugIns.Tests/PlugInManagerTest.cs b/tests/MUnique.OpenMU.PlugIns.Tests/PlugInManagerTest.cs
index feb8a1012..8f53676ed 100644
--- a/tests/MUnique.OpenMU.PlugIns.Tests/PlugInManagerTest.cs
+++ b/tests/MUnique.OpenMU.PlugIns.Tests/PlugInManagerTest.cs
@@ -39,7 +39,7 @@ public async ValueTask RegisteredPlugInsActiveByDefaultAsync()
var plugIn = new ExamplePlugIn();
manager.RegisterPlugInAtPlugInPoint(plugIn);
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var command = "test";
var args = new MyEventArgs();
@@ -59,7 +59,7 @@ public async ValueTask DeactivatingPlugInsAsync()
manager.RegisterPlugInAtPlugInPoint(plugIn);
manager.DeactivatePlugIn();
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var command = "test";
var args = new MyEventArgs();
@@ -80,7 +80,7 @@ public async ValueTask DeactivatingDeactivatedPlugInAsync()
manager.DeactivatePlugIn();
manager.DeactivatePlugIn();
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var command = "test";
var args = new MyEventArgs();
@@ -101,7 +101,7 @@ public async ValueTask ActivatingActivatedPlugInAsync()
manager.ActivatePlugIn();
manager.ActivatePlugIn();
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var command = "test";
var args = new MyEventArgs();
@@ -124,7 +124,7 @@ public async ValueTask DeactivatingOnePlugInDoesntAffectOthersAsync()
manager.ActivatePlugIn();
manager.DeactivatePlugIn();
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var command = "test";
var args = new MyEventArgs();
@@ -147,7 +147,7 @@ public async ValueTask CreatedAndActiveByConfigurationAsync(bool active)
IsActive = active,
};
var manager = new PlugInManager(new List { configuration }, new NullLoggerFactory(), this.CreateServiceProvider(), null);
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var command = "test";
var args = new MyEventArgs();
@@ -217,7 +217,7 @@ public async ValueTask ActivatingPlugInsAsync()
manager.DeactivatePlugIn();
manager.ActivatePlugIn();
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var command = "test";
var args = new MyEventArgs();
diff --git a/tests/MUnique.OpenMU.PlugIns.Tests/PlugInProxyTypeGeneratorTest.cs b/tests/MUnique.OpenMU.PlugIns.Tests/PlugInProxyTypeGeneratorTest.cs
index 6f2515d5f..af519518c 100644
--- a/tests/MUnique.OpenMU.PlugIns.Tests/PlugInProxyTypeGeneratorTest.cs
+++ b/tests/MUnique.OpenMU.PlugIns.Tests/PlugInProxyTypeGeneratorTest.cs
@@ -62,7 +62,7 @@ public async ValueTask MultiplePlugInsAreExecutedAsync()
var generator = new PlugInProxyTypeGenerator();
var proxy = generator.GenerateProxy(new PlugInManager(null, NullLoggerFactory.Instance, null, null));
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var command = "test";
var args = new MyEventArgs();
var firstMock = new Mock();
@@ -115,7 +115,7 @@ public async ValueTask InactivePlugInsAreNotExecutedAsync()
var generator = new PlugInProxyTypeGenerator();
var proxy = generator.GenerateProxy(new PlugInManager(null, NullLoggerFactory.Instance, null, null));
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var command = "test";
var args = new MyEventArgs();
var firstMock = new Mock();
@@ -142,7 +142,7 @@ public async ValueTask CancelEventArgsAreRespectedAsync()
var generator = new PlugInProxyTypeGenerator();
var proxy = generator.GenerateProxy(new PlugInManager(null, NullLoggerFactory.Instance, null, null));
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var command = "test";
var args = new MyEventArgs();
var firstMock = new Mock();
diff --git a/tests/MUnique.OpenMU.Tests/CharacterMoveTest.cs b/tests/MUnique.OpenMU.Tests/CharacterMoveTest.cs
index 61e3cfb62..da671b80b 100644
--- a/tests/MUnique.OpenMU.Tests/CharacterMoveTest.cs
+++ b/tests/MUnique.OpenMU.Tests/CharacterMoveTest.cs
@@ -61,7 +61,7 @@ public async ValueTask TestWalkStepsAreCorrectAsync()
private async ValueTask DoTheWalkAsync()
{
var packet = new byte[] { 0xC1, 0x08, (byte)PacketType.Walk, 0x93, 0x78, 0x44, 0x33, 0x44 };
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
player.SelectedCharacter!.PositionX = StartPoint.X;
player.SelectedCharacter.PositionY = StartPoint.Y;
var moveHandler = new CharacterWalkHandlerPlugIn();
diff --git a/tests/MUnique.OpenMU.Tests/DropGeneratorTest.cs b/tests/MUnique.OpenMU.Tests/DropGeneratorTest.cs
index 6af6249d8..510465029 100644
--- a/tests/MUnique.OpenMU.Tests/DropGeneratorTest.cs
+++ b/tests/MUnique.OpenMU.Tests/DropGeneratorTest.cs
@@ -24,7 +24,7 @@ public async ValueTask TestDropFailAsync()
{
var config = this.GetGameConfig();
var generator = new DefaultDropGenerator(config, this.GetRandomizer(9999));
- var (items, _) = await generator.GenerateItemDropsAsync(this.GetMonster(1), 0, await TestHelper.CreatePlayerAsync().ConfigureAwait(false));
+ var (items, _) = await generator.GenerateItemDropsAsync(this.GetMonster(1), 0, await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false));
var item = items.FirstOrDefault();
Assert.That(item, Is.Null);
}
@@ -41,7 +41,7 @@ public async ValueTask TestItemDropItemByMonsterAsync()
monster.DropItemGroups.Add(3000, SpecialItemType.RandomItem, true);
var generator = new DefaultDropGenerator(config, this.GetRandomizer2(0, 0.5));
- var (items, _) = await generator.GenerateItemDropsAsync(monster, 1, await TestHelper.CreatePlayerAsync().ConfigureAwait(false));
+ var (items, _) = await generator.GenerateItemDropsAsync(monster, 1, await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false));
var item = items.FirstOrDefault();
Assert.That(item, Is.Not.Null);
diff --git a/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs b/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs
new file mode 100644
index 000000000..f6184808d
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs
@@ -0,0 +1,50 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Microsoft.Extensions.Logging.Abstractions;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.Persistence.InMemory;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Helper functions to create test game contexts.
+///
+public static class GameContextTestHelper
+{
+ ///
+ /// Creates a game context for offline leveling tests.
+ ///
+ /// The game context with MuHelperFeaturePlugIn configured.
+ public static IGameContext CreateGameContext()
+ {
+ var contextProvider = new InMemoryPersistenceContextProvider();
+ var context = contextProvider.CreateNewContext();
+ var gameConfig = context.CreateNew();
+ var mapDef = context.CreateNew();
+ mapDef.Number = 0;
+ mapDef.TerrainData = new byte[ushort.MaxValue + 3];
+ gameConfig.Maps.Add(mapDef);
+ gameConfig.MaximumPartySize = 5;
+ gameConfig.RecoveryInterval = int.MaxValue;
+ gameConfig.MaximumInventoryMoney = int.MaxValue;
+
+ var mapInitializer = new MapInitializer(gameConfig, new NullLogger(), NullDropGenerator.Instance, null);
+ var plugInConfigurations = new List
+ {
+ new ()
+ {
+ TypeId = new Guid("E90A72C3-0459-4323-B6D3-171F88D35542"), // MuHelperFeaturePlugIn
+ IsActive = true,
+ },
+ };
+ var plugInManager = new PlugInManager(plugInConfigurations, new NullLoggerFactory(), null, null);
+ var gameContext = new GameContext(gameConfig, contextProvider, mapInitializer, new NullLoggerFactory(), plugInManager, NullDropGenerator.Instance, new ConfigurationChangeMediator());
+ mapInitializer.PlugInManager = gameContext.PlugInManager;
+ mapInitializer.PathFinderPool = gameContext.PathFinderPool;
+
+ return gameContext;
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/GuildActionTest.cs b/tests/MUnique.OpenMU.Tests/GuildActionTest.cs
index ed667b523..5aaf737d0 100644
--- a/tests/MUnique.OpenMU.Tests/GuildActionTest.cs
+++ b/tests/MUnique.OpenMU.Tests/GuildActionTest.cs
@@ -33,12 +33,12 @@ public override async ValueTask SetupAsync()
await base.SetupAsync().ConfigureAwait(false);
var gameServerContext = this.CreateGameServer();
- this._guildMasterPlayer = await TestHelper.CreatePlayerAsync(gameServerContext).ConfigureAwait(false);
+ this._guildMasterPlayer = await PlayerTestHelper.CreatePlayerAsync(gameServerContext).ConfigureAwait(false);
this._guildMasterPlayer.SelectedCharacter!.Id = this.GuildMaster.Id;
this._guildMasterPlayer.SelectedCharacter.Name = this.GuildMaster.Name;
await this.GuildServer.PlayerEnteredGameAsync(this.GuildMaster.Id, this.GuildMaster.Name, 0).ConfigureAwait(false);
this._guildMasterPlayer.Attributes![Stats.Level] = 100;
- this._player = await TestHelper.CreatePlayerAsync(gameServerContext).ConfigureAwait(false);
+ this._player = await PlayerTestHelper.CreatePlayerAsync(gameServerContext).ConfigureAwait(false);
await this._player.CurrentMap!.AddAsync(this._guildMasterPlayer).ConfigureAwait(false);
this._player.SelectedCharacter!.Name = "Player";
this._player.SelectedCharacter.Id = Guid.NewGuid();
diff --git a/tests/MUnique.OpenMU.Tests/ItemConsumptionTest.cs b/tests/MUnique.OpenMU.Tests/ItemConsumptionTest.cs
index 69b623648..d9282e1d8 100644
--- a/tests/MUnique.OpenMU.Tests/ItemConsumptionTest.cs
+++ b/tests/MUnique.OpenMU.Tests/ItemConsumptionTest.cs
@@ -365,7 +365,7 @@ private Item GetItem()
private async ValueTask GetPlayerAsync()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
player.SelectedCharacter!.Attributes.Add(new StatAttribute(Stats.Level, 100));
player.SelectedCharacter.Attributes.Add(new StatAttribute(Stats.CurrentHealth, 0));
diff --git a/tests/MUnique.OpenMU.Tests/MasterSystemTest.cs b/tests/MUnique.OpenMU.Tests/MasterSystemTest.cs
index a0d2ed244..b19e29c55 100644
--- a/tests/MUnique.OpenMU.Tests/MasterSystemTest.cs
+++ b/tests/MUnique.OpenMU.Tests/MasterSystemTest.cs
@@ -31,7 +31,7 @@ public class MasterSystemTest
[SetUp]
public async Task SetupAsync()
{
- this._player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ this._player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var context = this._player.GameContext;
this._skillRank1 = this.CreateSkill(1, 1, 1, null, this._player.SelectedCharacter!.CharacterClass!);
this._skillRank2 = this.CreateSkill(2, 2, 1, null, this._player.SelectedCharacter!.CharacterClass!);
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/BuffHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/BuffHandlerTests.cs
new file mode 100644
index 000000000..062c07a1f
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/BuffHandlerTests.cs
@@ -0,0 +1,77 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using NUnit.Framework;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.MuHelper;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class BuffHandlerTests
+{
+ private IGameContext _gameContext = null!;
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that returns true immediately
+ /// when no buff skills are configured.
+ ///
+ [Test]
+ public async ValueTask ReturnsTrueWhenNoBuffsConfiguredAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var config = new MuHelperSettings
+ {
+ BuffSkill0Id = 0,
+ BuffSkill1Id = 0,
+ BuffSkill2Id = 0,
+ };
+
+ var handler = new BuffHandler(player, config);
+
+ // Act
+ var result = await handler.PerformBuffsAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(result, Is.True);
+ }
+
+ ///
+ /// Tests that returns true when config is null.
+ ///
+ [Test]
+ public async ValueTask ReturnsTrueWhenConfigNullAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var handler = new BuffHandler(player, null);
+
+ // Act
+ var result = await handler.PerformBuffsAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(result, Is.True);
+ }
+
+ private async ValueTask CreateOfflinePlayerAsync()
+ {
+ return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
+ }
+
+}
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs
new file mode 100644
index 000000000..e81de398c
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs
@@ -0,0 +1,160 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using Moq;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class CombatHandlerTests
+{
+ private IGameContext _gameContext = null!;
+ private Point _origin = new(100, 100);
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that moves closer to the target if out of range.
+ ///
+ [Test]
+ public async ValueTask PerformAttackAsync_MovesCloserToTargetAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ player.Position = this._origin;
+
+ var monster = await this.CreateMonsterAsync(new Point(105, 105)).ConfigureAwait(false);
+ await player.CurrentMap!.AddAsync(monster).ConfigureAwait(false);
+
+ var config = new MuHelperSettings { HuntingRange = 10 };
+ var movementHandler = new MovementHandler(player, config, this._origin);
+ var buffHandler = new BuffHandler(player, config);
+
+ var handler = new CombatHandler(player, config, movementHandler, buffHandler, this._origin);
+
+ // Act
+ await handler.PerformAttackAsync().ConfigureAwait(false);
+
+ // Assert
+ // Should have initiated a walk closer to the monster
+ Assert.That(player.IsWalking, Is.True);
+ }
+
+ ///
+ /// Tests that uses Drain Life when HP is low.
+ ///
+ [Test]
+ public async ValueTask PerformDrainLifeRecoveryAsync_UsesDrainLifeWhenLowHpAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ player.Position = this._origin;
+ player.Attributes![Stats.CurrentHealth] = 10; // Low HP
+ player.Attributes[Stats.MaximumHealth] = 100;
+
+ // Place monster slightly away from player so GetDirectionTo doesn't return Undefined
+ var monsterPosition = new Point((byte)(this._origin.X + 1), (byte)this._origin.Y);
+ var monster = await this.CreateMonsterAsync(monsterPosition).ConfigureAwait(false);
+ await player.CurrentMap!.AddAsync(monster).ConfigureAwait(false);
+
+ var config = new MuHelperSettings
+ {
+ UseDrainLife = true,
+ HealThresholdPercent = 50,
+ HuntingRange = 10
+ };
+
+ // Add Drain Life skill to player
+ var drainSkill = new TestSkill
+ {
+ Number = 214,
+ };
+ await player.SkillList!.AddLearnedSkillAsync(drainSkill).ConfigureAwait(false);
+
+ var movementHandler = new MovementHandler(player, config, this._origin);
+ var buffHandler = new BuffHandler(player, config);
+
+ var handler = new CombatHandler(player, config, movementHandler, buffHandler, this._origin);
+
+ // Act
+ await handler.PerformDrainLifeRecoveryAsync().ConfigureAwait(false);
+
+ // Assert
+ // We verify that rotation was updated, which happens during ExecuteAttackAsync
+ Assert.That(player.Rotation, Is.Not.EqualTo(default(Direction)));
+ }
+
+ private async ValueTask CreateOfflinePlayerAsync()
+ {
+ return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
+ }
+
+ private async ValueTask CreateMonsterAsync(Point position)
+ {
+ var monsterDefinition = new MonsterDefinition
+ {
+ ObjectKind = NpcObjectKind.Monster,
+ };
+ monsterDefinition.Attributes.Add(new MonsterAttribute { AttributeDefinition = Stats.MaximumHealth, Value = 1000 });
+ monsterDefinition.Attributes.Add(new MonsterAttribute { AttributeDefinition = Stats.DefenseBase, Value = 100 });
+
+ var map = await this._gameContext.GetMapAsync(0).ConfigureAwait(false)!;
+ var spawnArea = new MonsterSpawnArea
+ {
+ MonsterDefinition = monsterDefinition,
+ GameMap = map!.Definition,
+ X1 = position.X,
+ Y1 = position.Y,
+ X2 = position.X,
+ Y2 = position.Y,
+ Quantity = 1,
+ };
+
+ var monster = new Monster(
+ spawnArea,
+ monsterDefinition,
+ map,
+ NullDropGenerator.Instance,
+ new Mock().Object,
+ this._gameContext.PlugInManager,
+ this._gameContext.PathFinderPool);
+
+ monster.Initialize();
+ monster.Attributes[Stats.CurrentHealth] = 100;
+
+ return monster;
+ }
+
+ private class TestSkill : Skill
+ {
+ public TestSkill()
+ {
+ this.Requirements = new List();
+ this.ConsumeRequirements = new List();
+ this.Range = 1;
+ this.SkillType = SkillType.DirectHit;
+ this.DamageType = DamageType.Curse;
+ }
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/HealingHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/HealingHandlerTests.cs
new file mode 100644
index 000000000..ac5ca4ec8
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/HealingHandlerTests.cs
@@ -0,0 +1,103 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using Moq;
+using MUnique.OpenMU.DataModel;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.MuHelper;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class HealingHandlerTests
+{
+ private IGameContext _gameContext = null!;
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that does nothing
+ /// when config is null.
+ ///
+ [Test]
+ public async ValueTask DoesNothingWhenConfigNullAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var handler = new HealingHandler(player, null);
+
+ // Act
+ await handler.PerformHealthRecoveryAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Tests that does not consume
+ /// a potion when the player's HP is above the threshold.
+ ///
+ [Test]
+ public async ValueTask DoesNotUsePotionAboveThresholdAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var maxHp = player.Attributes![Stats.MaximumHealth];
+ player.Attributes[Stats.CurrentHealth] = maxHp * 0.9f;
+
+ var potion = this.CreateHealthPotion();
+ await player.Inventory!.AddItemAsync((byte)(InventoryConstants.FirstEquippableItemSlotIndex + 12), potion).ConfigureAwait(false);
+
+ var config = new MuHelperSettings
+ {
+ UseHealPotion = true,
+ PotionThresholdPercent = 50,
+ };
+
+ var handler = new HealingHandler(player, config);
+
+ // Act
+ await handler.PerformHealthRecoveryAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(player.Inventory?.GetItem(potion.ItemSlot), Is.Not.Null);
+ }
+
+ private async ValueTask CreateOfflinePlayerAsync()
+ {
+ return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
+ }
+
+ private MUnique.OpenMU.DataModel.Entities.Item CreateHealthPotion()
+ {
+ var definition = new Mock();
+ definition.SetupAllProperties();
+ definition.Setup(d => d.BasePowerUpAttributes).Returns(new List());
+ definition.Setup(d => d.PossibleItemOptions).Returns(new List());
+ definition.Object.Number = ItemConstants.SmallHealingPotion.Number!.Value;
+ definition.Object.Group = ItemConstants.SmallHealingPotion.Group;
+
+ var item = new Mock();
+ item.SetupAllProperties();
+ item.Setup(i => i.Definition).Returns(definition.Object);
+ item.Setup(i => i.ItemOptions).Returns(new List());
+ item.Setup(i => i.ItemSetGroups).Returns(new List());
+ item.Object.Durability = 1;
+
+ return item.Object;
+ }
+
+}
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/ItemPickupHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/ItemPickupHandlerTests.cs
new file mode 100644
index 000000000..ad67b1cea
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/ItemPickupHandlerTests.cs
@@ -0,0 +1,71 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.MuHelper;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class ItemPickupHandlerTests
+{
+ private IGameContext _gameContext = null!;
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that pickup does nothing when all pickup options are disabled.
+ ///
+ [Test]
+ public async ValueTask DoesNothingWhenAllDisabledAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var config = new MuHelperSettings
+ {
+ PickAllItems = false,
+ PickJewel = false,
+ PickAncient = false,
+ PickZen = false,
+ PickExcellent = false,
+ };
+
+ var handler = new ItemPickupHandler(player, config);
+
+ // Act
+ await handler.PickupItemsAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Tests that pickup does nothing when config is null.
+ ///
+ [Test]
+ public async ValueTask DoesNothingWhenConfigNullAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var handler = new ItemPickupHandler(player, null);
+
+ // Act
+ await handler.PickupItemsAsync().ConfigureAwait(false);
+ }
+
+ private async ValueTask CreateOfflinePlayerAsync()
+ {
+ return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
+ }
+
+}
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/MovementHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/MovementHandlerTests.cs
new file mode 100644
index 000000000..13b9521df
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/MovementHandlerTests.cs
@@ -0,0 +1,61 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.MuHelper;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class MovementHandlerTests
+{
+ private IGameContext _gameContext = null!;
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that does nothing when within range.
+ ///
+ [Test]
+ public async ValueTask RegroupAsync_DoesNothingWhenWithinRangeAsync()
+ {
+ // Arrange
+ var origin = new Point(100, 100);
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ player.Position = new Point(100, 101); // Within RegroupDistanceThreshold (1)
+
+ var config = new MuHelperSettings
+ {
+ ReturnToOriginalPosition = true,
+ HuntingRange = 5,
+ };
+
+ var handler = new MovementHandler(player, config, origin);
+
+ // Act
+ var result = await handler.RegroupAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(result, Is.True);
+ Assert.That(player.IsWalking, Is.False);
+ }
+
+ private async ValueTask CreateOfflinePlayerAsync()
+ {
+ return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingIntelligenceTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingIntelligenceTests.cs
new file mode 100644
index 000000000..bb5cb6631
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingIntelligenceTests.cs
@@ -0,0 +1,64 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class OfflineLevelingIntelligenceTests
+{
+ private IGameContext _gameContext = null!;
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that the intelligence can be created and started.
+ ///
+ [Test]
+ public async ValueTask StartsWithoutExceptionAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+
+ // Act
+ var intelligence = new OfflineLevelingIntelligence(player);
+ intelligence.Start();
+
+ intelligence?.Dispose();
+ }
+
+ ///
+ /// Tests that disposing the intelligence twice does not throw.
+ ///
+ [Test]
+ public async ValueTask DisposeTwiceDoesNotThrowAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var intelligence = new OfflineLevelingIntelligence(player);
+ intelligence.Start();
+
+ // Act & Assert
+ intelligence.Dispose();
+ intelligence.Dispose();
+ }
+
+ private async ValueTask CreateOfflinePlayerAsync()
+ {
+ return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
+ }
+
+}
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingManagerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingManagerTests.cs
new file mode 100644
index 000000000..dcf5f8a09
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingManagerTests.cs
@@ -0,0 +1,98 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using Moq;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.MuHelper;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class OfflineLevelingManagerTests
+{
+ private IGameContext _gameContext = null!;
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that returns true on success.
+ ///
+ [Test]
+ public async ValueTask StartAsync_ReturnsTrueOnSuccessAsync()
+ {
+ // Arrange
+ var manager = new OfflineLevelingManager();
+ var realPlayer = await PlayerTestHelper.CreatePlayerAsync(this._gameContext).ConfigureAwait(false);
+ realPlayer.Account!.LoginName = "testuser";
+ realPlayer.TryAddMoney(1_000_000);
+ realPlayer.Attributes![Stats.Level] = 100;
+
+ // Act
+ var result = await manager.StartAsync(realPlayer, "testuser").ConfigureAwait(false);
+
+ // Assert
+ Assert.That(result, Is.True);
+ Assert.That(manager.IsActive("testuser"), Is.True);
+ }
+
+ ///
+ /// Tests that fails if the player has insufficient Zen.
+ ///
+ [Test]
+ public async ValueTask StartAsync_FailsOnInsufficientZenAsync()
+ {
+ // Arrange
+ var manager = new OfflineLevelingManager();
+ var realPlayer = await PlayerTestHelper.CreatePlayerAsync(this._gameContext).ConfigureAwait(false);
+ realPlayer.Account!.LoginName = "testuser";
+ realPlayer.Money = 0; // No money
+ realPlayer.Attributes![Stats.Level] = 100;
+
+ // Act
+ var result = await manager.StartAsync(realPlayer, "testuser").ConfigureAwait(false);
+
+ // Assert
+ Assert.That(result, Is.False);
+ Assert.That(manager.IsActive("testuser"), Is.False);
+ }
+
+ ///
+ /// Tests that successfully stops a session.
+ ///
+ [Test]
+ public async ValueTask StopAsync_StopsActiveSessionAsync()
+ {
+ // Arrange
+ var manager = new OfflineLevelingManager();
+ var realPlayer = await PlayerTestHelper.CreatePlayerAsync(this._gameContext).ConfigureAwait(false);
+ realPlayer.Account!.LoginName = "testuser";
+ realPlayer.TryAddMoney(1_000_000);
+ realPlayer.Attributes![Stats.Level] = 100;
+
+ await manager.StartAsync(realPlayer, "testuser").ConfigureAwait(false);
+ Assert.That(manager.IsActive("testuser"), Is.True);
+
+ // Act
+ await manager.StopAsync("testuser").ConfigureAwait(false);
+
+ // Assert
+ Assert.That(manager.IsActive("testuser"), Is.False);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingPlayerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingPlayerTests.cs
new file mode 100644
index 000000000..57262d3e3
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingPlayerTests.cs
@@ -0,0 +1,63 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class OfflineLevelingPlayerTests
+{
+ private IGameContext _gameContext = null!;
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that the offline leveling player is created and started successfully.
+ ///
+ [Test]
+ public async ValueTask InitializesSuccessfullyAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(player.PlayerState.CurrentState, Is.EqualTo(PlayerState.EnteredWorld));
+ Assert.That(player.SelectedCharacter, Is.Not.Null);
+ }
+
+ ///
+ /// Tests that cleans up resources.
+ ///
+ [Test]
+ public async ValueTask StopAsync_CleansUpAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+
+ // Act
+ await player.StopAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(player.PlayerState.CurrentState, Is.EqualTo(PlayerState.Finished));
+ }
+
+ private async ValueTask CreateOfflinePlayerAsync()
+ {
+ return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs
deleted file mode 100644
index 32a6eede3..000000000
--- a/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs
+++ /dev/null
@@ -1,334 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root for full license information.
-//
-
-namespace MUnique.OpenMU.Tests;
-
-using Microsoft.Extensions.Logging.Abstractions;
-using Moq;
-using MUnique.OpenMU.DataModel;
-using MUnique.OpenMU.DataModel.Configuration;
-using MUnique.OpenMU.DataModel.Configuration.Items;
-using MUnique.OpenMU.DataModel.Entities;
-using MUnique.OpenMU.GameLogic;
-using MUnique.OpenMU.GameLogic.Attributes;
-using MUnique.OpenMU.GameLogic.MuHelper;
-using MUnique.OpenMU.GameLogic.OfflineLeveling;
-using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
-using MUnique.OpenMU.Pathfinding;
-using MUnique.OpenMU.Persistence.InMemory;
-using MUnique.OpenMU.PlugIns;
-using Item = MUnique.OpenMU.Persistence.BasicModel.Item;
-using ItemDefinition = MUnique.OpenMU.Persistence.BasicModel.ItemDefinition;
-using ItemSlotType = MUnique.OpenMU.Persistence.BasicModel.ItemSlotType;
-
-///
-/// Tests for offline leveling handlers and intelligence.
-///
-[TestFixture]
-public class OfflineLevelingTest
-{
- private IGameContext _gameContext = null!;
-
- ///
- /// Sets up a fresh game context before each test.
- ///
- [SetUp]
- public void SetUp()
- {
- this._gameContext = this.CreateGameContext();
- }
-
- // -------------------------------------------------------------------------
- // RepairHandler
- // -------------------------------------------------------------------------
-
- ///
- /// Tests that auto-repair restores item durability when the player has enough Zen.
- ///
- [Test]
- public async ValueTask RepairHandler_RepairsItemWhenSufficientZen()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 10);
- await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
- player.TryAddMoney(1_000_000);
-
- var config = new MuHelperSettings { RepairItem = true };
- var handler = new RepairHandler(player, config);
- await handler.PerformRepairsAsync().ConfigureAwait(false);
-
- Assert.That(item.Durability, Is.EqualTo(100));
- }
-
- ///
- /// Tests that auto-repair does nothing when disabled in the configuration.
- ///
- [Test]
- public async ValueTask RepairHandler_DoesNothingWhenDisabled()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 10);
- await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
- player.TryAddMoney(1_000_000);
-
- var config = new MuHelperSettings { RepairItem = false };
- var handler = new RepairHandler(player, config);
- await handler.PerformRepairsAsync().ConfigureAwait(false);
-
- Assert.That(item.Durability, Is.EqualTo(10));
- }
-
- ///
- /// Tests that auto-repair does not repair when the player has insufficient Zen.
- ///
- [Test]
- public async ValueTask RepairHandler_DoesNotRepairWhenInsufficientZen()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 10);
- await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
-
- var config = new MuHelperSettings { RepairItem = true };
- var handler = new RepairHandler(player, config);
- await handler.PerformRepairsAsync().ConfigureAwait(false);
-
- Assert.That(item.Durability, Is.EqualTo(10));
- }
-
- ///
- /// Tests that fully durable items are skipped by the repair handler.
- ///
- [Test]
- public async ValueTask RepairHandler_SkipsFullyDurableItems()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 100);
- await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
- var initialMoney = 1_000_000;
- player.TryAddMoney(initialMoney);
-
- var config = new MuHelperSettings { RepairItem = true };
- var handler = new RepairHandler(player, config);
- await handler.PerformRepairsAsync().ConfigureAwait(false);
-
- Assert.That(player.Money, Is.EqualTo(initialMoney));
- }
-
- // -------------------------------------------------------------------------
- // ItemPickupHandler
- // -------------------------------------------------------------------------
-
- ///
- /// Tests that pickup does nothing when all pickup options are disabled.
- ///
- [Test]
- public async ValueTask ItemPickupHandler_DoesNothingWhenAllDisabled()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var config = new MuHelperSettings
- {
- PickAllItems = false,
- PickJewel = false,
- PickAncient = false,
- PickZen = false,
- PickExcellent = false,
- };
-
- var handler = new ItemPickupHandler(player, config);
- Assert.DoesNotThrowAsync(async () =>
- await handler.PickupItemsAsync().ConfigureAwait(false));
- }
-
- ///
- /// Tests that pickup does nothing when config is null.
- ///
- [Test]
- public async ValueTask ItemPickupHandler_DoesNothingWhenConfigNull()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var handler = new ItemPickupHandler(player, null);
-
- Assert.DoesNotThrowAsync(async () =>
- await handler.PickupItemsAsync().ConfigureAwait(false));
- }
-
- // -------------------------------------------------------------------------
- // CombatHandler
- // -------------------------------------------------------------------------
-
- ///
- /// Tests that does nothing
- /// when config is null.
- ///
- [Test]
- public async ValueTask HealingHandler_DoesNothingWhenConfigNull()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var handler = new HealingHandler(player, null);
-
- Assert.DoesNotThrowAsync(async () =>
- await handler.PerformHealthRecoveryAsync().ConfigureAwait(false));
- }
-
- ///
- /// Tests that does not consume
- /// a potion when the player's HP is above the threshold.
- ///
- [Test]
- public async ValueTask HealingHandler_DoesNotUsePotionAboveThreshold()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var maxHp = player.Attributes![Stats.MaximumHealth];
- player.Attributes[Stats.CurrentHealth] = maxHp * 0.9f;
-
- var potion = this.CreateHealthPotion();
- await player.Inventory!.AddItemAsync((byte)(InventoryConstants.FirstEquippableItemSlotIndex + 12), potion).ConfigureAwait(false);
-
- var config = new MuHelperSettings
- {
- UseHealPotion = true,
- PotionThresholdPercent = 50,
- };
-
- var handler = new HealingHandler(player, config);
- await handler.PerformHealthRecoveryAsync().ConfigureAwait(false);
-
- Assert.That(player.Inventory?.GetItem(potion.ItemSlot), Is.Not.Null);
- }
-
- // -------------------------------------------------------------------------
- // BuffHandler
- // -------------------------------------------------------------------------
-
- ///
- /// Tests that returns true immediately
- /// when no buff skills are configured.
- ///
- [Test]
- public async ValueTask BuffHandler_ReturnsTrue_WhenNoBuffsConfigured()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var config = new MuHelperSettings
- {
- BuffSkill0Id = 0,
- BuffSkill1Id = 0,
- BuffSkill2Id = 0,
- };
-
- var handler = new BuffHandler(player, config);
- var result = await handler.PerformBuffsAsync().ConfigureAwait(false);
-
- Assert.That(result, Is.True);
- }
-
- ///
- /// Tests that returns true when config is null.
- ///
- [Test]
- public async ValueTask BuffHandler_ReturnsTrue_WhenConfigNull()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var handler = new BuffHandler(player, null);
- var result = await handler.PerformBuffsAsync().ConfigureAwait(false);
-
- Assert.That(result, Is.True);
- }
-
- // -------------------------------------------------------------------------
- // OfflineLevelingIntelligence
- // -------------------------------------------------------------------------
-
- ///
- /// Tests that the intelligence can be created and started without throwing.
- ///
- [Test]
- public async ValueTask Intelligence_StartsWithoutException()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
-
- OfflineLevelingIntelligence? intelligence = null;
- Assert.DoesNotThrow(() =>
- {
- intelligence = new OfflineLevelingIntelligence(player);
- intelligence.Start();
- });
-
- intelligence?.Dispose();
- }
-
- ///
- /// Tests that disposing the intelligence twice does not throw.
- ///
- [Test]
- public async ValueTask Intelligence_DisposeTwice_DoesNotThrow()
- {
- var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
- var intelligence = new OfflineLevelingIntelligence(player);
- intelligence.Start();
-
- intelligence.Dispose();
- Assert.DoesNotThrow(() => intelligence.Dispose());
- }
-
- // -------------------------------------------------------------------------
- // Helpers
- // -------------------------------------------------------------------------
-
- private async ValueTask CreateOfflinePlayerAsync()
- {
- return await TestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
- }
-
- private Item CreateDamagedItem(byte maxDurability, byte currentDurability)
- {
- return new Item
- {
- Definition = new ItemDefinition
- {
- Durability = maxDurability,
- ItemSlot = new ItemSlotType(),
- Width = 1,
- Height = 1,
- Value = 1000,
- },
- Durability = currentDurability,
- };
- }
-
- private MUnique.OpenMU.DataModel.Entities.Item CreateHealthPotion()
- {
- var definition = new Mock();
- definition.SetupAllProperties();
- definition.Setup(d => d.BasePowerUpAttributes).Returns(new List());
- definition.Setup(d => d.PossibleItemOptions).Returns(new List());
- definition.Object.Number = ItemConstants.SmallHealingPotion.Number!.Value;
- definition.Object.Group = ItemConstants.SmallHealingPotion.Group;
-
- var item = new Mock();
- item.SetupAllProperties();
- item.Setup(i => i.Definition).Returns(definition.Object);
- item.Setup(i => i.ItemOptions).Returns(new List());
- item.Setup(i => i.ItemSetGroups).Returns(new List());
- item.Object.Durability = 1;
-
- return item.Object;
- }
-
- private IGameContext CreateGameContext()
- {
- var contextProvider = new InMemoryPersistenceContextProvider();
- var gameConfig = contextProvider.CreateNewContext().CreateNew();
- gameConfig.Maps.Add(contextProvider.CreateNewContext().CreateNew());
- gameConfig.MaximumPartySize = 5;
- gameConfig.RecoveryInterval = int.MaxValue;
- gameConfig.MaximumInventoryMoney = int.MaxValue;
-
- var mapInitializer = new MapInitializer(gameConfig, new NullLogger(), NullDropGenerator.Instance, null);
- var gameContext = new GameContext(gameConfig, contextProvider, mapInitializer, new NullLoggerFactory(), new PlugInManager(new List(), new NullLoggerFactory(), null, null), NullDropGenerator.Instance, new ConfigurationChangeMediator());
- mapInitializer.PlugInManager = gameContext.PlugInManager;
- mapInitializer.PathFinderPool = gameContext.PathFinderPool;
-
- return gameContext;
- }
-}
\ No newline at end of file
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/PetHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/PetHandlerTests.cs
new file mode 100644
index 000000000..b5282e97d
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/PetHandlerTests.cs
@@ -0,0 +1,117 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using Moq;
+using MUnique.OpenMU.DataModel;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.MuHelper;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
+using MUnique.OpenMU.GameLogic.Pet;
+using MUnique.OpenMU.GameLogic.PlayerActions.Items;
+using Item = MUnique.OpenMU.Persistence.BasicModel.Item;
+using ItemDefinition = MUnique.OpenMU.Persistence.BasicModel.ItemDefinition;
+using ItemSlotType = MUnique.OpenMU.Persistence.BasicModel.ItemSlotType;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class PetHandlerTests
+{
+ private IGameContext _gameContext = null!;
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that sets the correct Dark Raven behavior.
+ ///
+ [Test]
+ [TestCase(1, PetBehaviour.AttackRandom)]
+ [TestCase(2, PetBehaviour.AttackWithOwner)]
+ public async ValueTask InitializeAsync_SetsDarkRavenBehaviorAsync(int mode, PetBehaviour expectedBehaviour)
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var petCommandManagerMock = new Mock();
+
+ var config = new MuHelperSettings
+ {
+ UseDarkRaven = true,
+ DarkRavenMode = (byte)mode,
+ };
+
+ var handler = new PetHandler(player, config, petCommandManagerMock.Object);
+
+ // Act
+ await handler.InitializeAsync().ConfigureAwait(false);
+
+ // Assert
+ petCommandManagerMock.Verify(m => m.SetBehaviourAsync(expectedBehaviour, null), Times.Once);
+ }
+
+ ///
+ /// Tests that sets pet behavior to Idle when durability is 0.
+ ///
+ [Test]
+ public async ValueTask CheckPetDurabilityAsync_SetsIdleWhenDurabilityIsZeroAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var petCommandManagerMock = new Mock();
+
+ var petItem = new Item
+ {
+ Definition = new ItemDefinition
+ {
+ ItemSlot = new ItemSlotType(),
+ Width = 1,
+ Height = 1,
+ },
+ Durability = 0,
+ };
+ await player.Inventory!.AddItemAsync(InventoryConstants.PetSlot, petItem).ConfigureAwait(false);
+
+ var handler = new PetHandler(player, new MuHelperSettings(), petCommandManagerMock.Object);
+
+ // Act
+ await handler.CheckPetDurabilityAsync().ConfigureAwait(false);
+
+ // Assert
+ petCommandManagerMock.Verify(m => m.SetBehaviourAsync(PetBehaviour.Idle, null), Times.Once);
+ }
+
+ ///
+ /// Tests that sets pet behavior to Idle.
+ ///
+ [Test]
+ public async ValueTask StopAsync_SetsIdleAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var petCommandManagerMock = new Mock();
+
+ var handler = new PetHandler(player, new MuHelperSettings(), petCommandManagerMock.Object);
+
+ // Act
+ await handler.StopAsync().ConfigureAwait(false);
+
+ // Assert
+ petCommandManagerMock.Verify(m => m.SetBehaviourAsync(PetBehaviour.Idle, null), Times.Once);
+ }
+
+ private async ValueTask CreateOfflinePlayerAsync()
+ {
+ return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/RepairHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/RepairHandlerTests.cs
new file mode 100644
index 000000000..73d6ce1a0
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/RepairHandlerTests.cs
@@ -0,0 +1,142 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using MUnique.OpenMU.DataModel;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.MuHelper;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
+using Item = MUnique.OpenMU.Persistence.BasicModel.Item;
+using ItemDefinition = MUnique.OpenMU.Persistence.BasicModel.ItemDefinition;
+using ItemSlotType = MUnique.OpenMU.Persistence.BasicModel.ItemSlotType;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class RepairHandlerTests
+{
+ private IGameContext _gameContext = null!;
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that auto-repair restores item durability when the player has enough Zen.
+ ///
+ [Test]
+ public async ValueTask RepairsItemWhenSufficientZenAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 10);
+ await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
+ player.TryAddMoney(1_000_000);
+
+ var config = new MuHelperSettings { RepairItem = true };
+ var handler = new RepairHandler(player, config);
+
+ // Act
+ await handler.PerformRepairsAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(item.Durability, Is.EqualTo(100));
+ }
+
+ ///
+ /// Tests that auto-repair does nothing when disabled in the configuration.
+ ///
+ [Test]
+ public async ValueTask DoesNothingWhenDisabledAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 10);
+ await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
+ player.TryAddMoney(1_000_000);
+
+ var config = new MuHelperSettings { RepairItem = false };
+ var handler = new RepairHandler(player, config);
+
+ // Act
+ await handler.PerformRepairsAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(item.Durability, Is.EqualTo(10));
+ }
+
+ ///
+ /// Tests that auto-repair does not repair when the player has insufficient Zen.
+ ///
+ [Test]
+ public async ValueTask DoesNotRepairWhenInsufficientZenAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 10);
+ await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
+
+ var config = new MuHelperSettings { RepairItem = true };
+ var handler = new RepairHandler(player, config);
+
+ // Act
+ await handler.PerformRepairsAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(item.Durability, Is.EqualTo(10));
+ }
+
+ ///
+ /// Tests that fully durable items are skipped by the repair handler.
+ ///
+ [Test]
+ public async ValueTask SkipsFullyDurableItemsAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 100);
+ await player.Inventory!.AddItemAsync(InventoryConstants.ArmorSlot, item).ConfigureAwait(false);
+ var initialMoney = 1_000_000;
+ player.TryAddMoney(initialMoney);
+
+ var config = new MuHelperSettings { RepairItem = true };
+ var handler = new RepairHandler(player, config);
+
+ // Act
+ await handler.PerformRepairsAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(player.Money, Is.EqualTo(initialMoney));
+ }
+
+ private async ValueTask CreateOfflinePlayerAsync()
+ {
+ return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
+ }
+
+ private Item CreateDamagedItem(byte maxDurability, byte currentDurability)
+ {
+ return new Item
+ {
+ Definition = new ItemDefinition
+ {
+ Durability = maxDurability,
+ ItemSlot = new ItemSlotType(),
+ Width = 1,
+ Height = 1,
+ Value = 1000,
+ },
+ Durability = currentDurability,
+ };
+ }
+
+}
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/ZenConsumptionHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/ZenConsumptionHandlerTests.cs
new file mode 100644
index 000000000..b654dfbd3
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/ZenConsumptionHandlerTests.cs
@@ -0,0 +1,96 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.Offlevel;
+
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.MuHelper;
+using MUnique.OpenMU.GameLogic.OfflineLeveling;
+
+///
+/// Tests for .
+///
+[TestFixture]
+public class ZenConsumptionHandlerTests
+{
+ private IGameContext _gameContext = null!;
+
+ ///
+ /// Sets up a fresh game context before each test.
+ ///
+ [SetUp]
+ public void SetUp()
+ {
+ this._gameContext = GameContextTestHelper.CreateGameContext();
+ }
+
+ ///
+ /// Tests that Zen is deducted when the pay interval has passed.
+ ///
+ [Test]
+ public async ValueTask DeductsZenWhenIntervalPassedAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ player.TryAddMoney(1_000_000);
+ player.Attributes![Stats.Level] = 100;
+
+ var handler = new ZenConsumptionHandler(player);
+ player.StartTimestamp = DateTime.UtcNow.AddMinutes(-2);
+
+ // Act
+ await handler.DeductZenAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(player.Money, Is.LessThan(1_000_000));
+ }
+
+ ///
+ /// Tests that Zen is not deducted when the pay interval has not passed.
+ ///
+ [Test]
+ public async ValueTask DoesNotDeductZenWhenIntervalNotPassedAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ player.TryAddMoney(1_000_000);
+
+ var handler = new ZenConsumptionHandler(player);
+
+ // Act
+ await handler.DeductZenAsync().ConfigureAwait(false);
+
+ // Assert
+ Assert.That(player.Money, Is.EqualTo(1_000_000));
+ }
+
+ ///
+ /// Tests that offline leveling stops when the player has insufficient Zen.
+ ///
+ [Test]
+ public async ValueTask StopsOfflineLevelingWhenInsufficientZenAsync()
+ {
+ // Arrange
+ var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false);
+ player.TryAddMoney(100);
+ player.Attributes![Stats.Level] = 100;
+
+ var handler = new ZenConsumptionHandler(player);
+ player.StartTimestamp = DateTime.UtcNow.AddMinutes(-2);
+
+ // Act
+ await handler.DeductZenAsync().ConfigureAwait(false);
+
+ // Assert - Offline leveling stops when insufficient Zen
+ Assert.That(player.PlayerState.CurrentState, Is.EqualTo(PlayerState.Finished));
+ }
+
+ private async ValueTask CreateOfflinePlayerAsync()
+ {
+ var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
+ player.Attributes![Stats.MasterLevel] = 0;
+ return player;
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs b/tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs
index f9a7724b6..01ea0c949 100644
--- a/tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs
+++ b/tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs
@@ -119,8 +119,7 @@ public async ValueTask OnMemberReconnected_DoesNothingWhenNoCachedParty()
{
var member = await this.CreatePartyMemberAsync().ConfigureAwait(false);
- Assert.DoesNotThrowAsync(async () =>
- await this._partyManager.OnMemberReconnectedAsync(member).ConfigureAwait(false));
+ await this._partyManager.OnMemberReconnectedAsync(member).ConfigureAwait(false);
Assert.That(member.Party, Is.Null);
}
@@ -151,23 +150,8 @@ public async ValueTask KickedMember_IsRemovedFromPartyList()
private async ValueTask CreatePartyMemberAsync()
{
- var result = await TestHelper.CreatePlayerAsync(this.GetGameContext()).ConfigureAwait(false);
+ var result = await PlayerTestHelper.CreatePlayerAsync(GameContextTestHelper.CreateGameContext()).ConfigureAwait(false);
await result.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
return result;
}
-
- private IGameContext GetGameContext()
- {
- var contextProvider = new InMemoryPersistenceContextProvider();
- var gameConfig = contextProvider.CreateNewContext().CreateNew();
- gameConfig.Maps.Add(contextProvider.CreateNewContext().CreateNew());
- gameConfig.MaximumPartySize = MaxPartySize;
-
- var mapInitializer = new MapInitializer(gameConfig, new NullLogger(), NullDropGenerator.Instance, null);
- var gameContext = new GameContext(gameConfig, contextProvider, mapInitializer, new NullLoggerFactory(), new PlugInManager(new List(), new NullLoggerFactory(), null, null), NullDropGenerator.Instance, new ConfigurationChangeMediator());
- mapInitializer.PlugInManager = gameContext.PlugInManager;
- mapInitializer.PathFinderPool = gameContext.PathFinderPool;
-
- return gameContext;
- }
}
\ No newline at end of file
diff --git a/tests/MUnique.OpenMU.Tests/Party/PartyTest.cs b/tests/MUnique.OpenMU.Tests/Party/PartyTest.cs
index 57734d3a0..98cc89c28 100644
--- a/tests/MUnique.OpenMU.Tests/Party/PartyTest.cs
+++ b/tests/MUnique.OpenMU.Tests/Party/PartyTest.cs
@@ -184,7 +184,7 @@ public async ValueTask PartyResponseAcceptExistingPartyAsync()
private async ValueTask CreatePartyMemberAsync()
{
- var result = await TestHelper.CreatePlayerAsync(this.GetGameContext()).ConfigureAwait(false);
+ var result = await PlayerTestHelper.CreatePlayerAsync(GameContextTestHelper.CreateGameContext()).ConfigureAwait(false);
await result.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
return result;
}
@@ -200,19 +200,4 @@ private async ValueTask CreatePartyWithMembersAsync(int numberOfMembers)
return party;
}
-
- private IGameContext GetGameContext()
- {
- var contextProvider = new InMemoryPersistenceContextProvider();
- var gameConfig = contextProvider.CreateNewContext().CreateNew();
- gameConfig.Maps.Add(contextProvider.CreateNewContext().CreateNew());
-
- gameConfig.MaximumPartySize = 5;
- var mapInitializer = new MapInitializer(gameConfig, new NullLogger(), NullDropGenerator.Instance, null);
- var gameContext = new GameContext(gameConfig, contextProvider, mapInitializer, new NullLoggerFactory(), new PlugInManager(new List(), new NullLoggerFactory(), null, null), NullDropGenerator.Instance, new ConfigurationChangeMediator());
- mapInitializer.PlugInManager = gameContext.PlugInManager;
- mapInitializer.PathFinderPool = gameContext.PathFinderPool;
-
- return gameContext;
- }
}
\ No newline at end of file
diff --git a/tests/MUnique.OpenMU.Tests/TestHelper.cs b/tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs
similarity index 93%
rename from tests/MUnique.OpenMU.Tests/TestHelper.cs
rename to tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs
index 392f12ec8..878f11f7f 100644
--- a/tests/MUnique.OpenMU.Tests/TestHelper.cs
+++ b/tests/MUnique.OpenMU.Tests/PlayerTestHelper.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -12,18 +12,18 @@ namespace MUnique.OpenMU.Tests;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
-using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.OfflineLeveling;
+using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Persistence.InMemory;
using MUnique.OpenMU.PlugIns;
///
-/// Some helper functions to create test objects.
+/// Helper functions to create test players.
///
-public static class TestHelper
+public static class PlayerTestHelper
{
///
- /// Gets the player.
+ /// Gets a test player with a new in-memory game context.
///
/// The test player.
public static async ValueTask CreatePlayerAsync()
@@ -56,12 +56,10 @@ public static async ValueTask CreatePlayerAsync()
}
///
- /// Gets a test player.
+ /// Gets a test player with the specified game context.
///
/// The game context.
- ///
- /// The test player.
- ///
+ /// The test player.
public static async ValueTask CreatePlayerAsync(IGameContext gameContext)
{
var characterMock = new Mock();
@@ -76,7 +74,7 @@ public static async ValueTask CreatePlayerAsync(IGameContext gameContext
var character = characterMock.Object;
character.Inventory = inventoryMock.Object;
- character.CurrentMap = (await gameContext.GetMapAsync(0).ConfigureAwait(false))?.Definition;
+ character.CurrentMap = gameContext.Configuration.Maps.FirstOrDefault(m => m.Number == 0);
var characterClassMock = new Mock();
characterClassMock.Setup(c => c.StatAttributes).Returns(
new List
@@ -92,7 +90,6 @@ public static async ValueTask CreatePlayerAsync(IGameContext gameContext
});
characterClassMock.Setup(c => c.AttributeCombinations).Returns(new List
{
- // Params: TargetAttribute, Multiplier, SourceAttribute
new (Stats.TotalStrength, 1, Stats.BaseStrength),
new (Stats.TotalAgility, 1, Stats.BaseAgility),
new (Stats.TotalVitality, 1, Stats.BaseVitality),
@@ -142,10 +139,10 @@ public static async ValueTask CreatePlayerAsync(IGameContext gameContext
}
///
- /// Creates an .
+ /// Creates an offline leveling player at .
///
/// The game context.
- /// The offline leveling player at .
+ /// The offline leveling player.
public static async ValueTask CreateOfflineLevelingPlayerAsync(IGameContext gameContext)
{
var regularPlayer = await CreatePlayerAsync(gameContext).ConfigureAwait(false);
@@ -169,4 +166,4 @@ protected override ICustomPlugInContainer CreateViewPlugInContainer
return new MockViewPlugInContainer();
}
}
-}
\ No newline at end of file
+}
diff --git a/tests/MUnique.OpenMU.Tests/PowerUpFactoryTest.cs b/tests/MUnique.OpenMU.Tests/PowerUpFactoryTest.cs
index cefc8248e..d01c8b5a2 100644
--- a/tests/MUnique.OpenMU.Tests/PowerUpFactoryTest.cs
+++ b/tests/MUnique.OpenMU.Tests/PowerUpFactoryTest.cs
@@ -32,7 +32,7 @@ public class PowerUpFactoryTest
[Test]
public async ValueTask ItemOptionsAsync()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var factory = this.GetPowerUpFactory();
var item = this.GetItemWithOption();
var result = factory.GetPowerUps(item, player.Attributes!);
@@ -45,7 +45,7 @@ public async ValueTask ItemOptionsAsync()
[Test]
public async ValueTask ItemBasePowerUpLevel0Async()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var factory = this.GetPowerUpFactory();
var item = this.GetItemWithBasePowerUp();
var result = factory.GetPowerUps(item, player.Attributes!);
@@ -58,7 +58,7 @@ public async ValueTask ItemBasePowerUpLevel0Async()
[Test]
public async ValueTask ItemBasePowerUpLevel3Async()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var factory = this.GetPowerUpFactory();
var item = this.GetItemWithBasePowerUp();
item.Level = 3;
@@ -72,7 +72,7 @@ public async ValueTask ItemBasePowerUpLevel3Async()
[Test]
public async ValueTask NoPowerUpsWhenItemBrokenAsync()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var factory = this.GetPowerUpFactory();
var item = this.GetItemWithBasePowerUp();
item.Durability = 0;
@@ -86,7 +86,7 @@ public async ValueTask NoPowerUpsWhenItemBrokenAsync()
[Test]
public async ValueTask NoPowerUpsWhenItemUnwearableAsync()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var factory = this.GetPowerUpFactory();
var item = this.GetItemWithBasePowerUp();
item.ItemSlot = UnwearableSlot;
@@ -100,7 +100,7 @@ public async ValueTask NoPowerUpsWhenItemUnwearableAsync()
[Test]
public async ValueTask NoPowerUpsInItemAsync()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var factory = this.GetPowerUpFactory();
var item = this.GetItem();
var result = factory.GetPowerUps(item, player.Attributes!);
diff --git a/tests/MUnique.OpenMU.Tests/SelfDefensePlugInTest.cs b/tests/MUnique.OpenMU.Tests/SelfDefensePlugInTest.cs
index 0f4901a51..5dd0c3241 100644
--- a/tests/MUnique.OpenMU.Tests/SelfDefensePlugInTest.cs
+++ b/tests/MUnique.OpenMU.Tests/SelfDefensePlugInTest.cs
@@ -22,7 +22,7 @@ public class SelfDefensePlugInTest
[Test]
public async ValueTask OwnSummonHitDoesNotStartSelfDefenseAsync()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var summonMock = new Mock();
var summonable = summonMock.As();
diff --git a/tests/MUnique.OpenMU.Tests/SkillListTest.cs b/tests/MUnique.OpenMU.Tests/SkillListTest.cs
index bfecdb96d..e10b1023d 100644
--- a/tests/MUnique.OpenMU.Tests/SkillListTest.cs
+++ b/tests/MUnique.OpenMU.Tests/SkillListTest.cs
@@ -27,7 +27,7 @@ public class SkillListTest
[Test]
public async ValueTask LearnedSkillAsync()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
player.SelectedCharacter!.LearnedSkills.Add(this.CreateSkillEntry(LearnedSkillId));
var skillList = new SkillList(player);
Assert.That(skillList.ContainsSkill(LearnedSkillId), Is.True);
@@ -39,7 +39,7 @@ public async ValueTask LearnedSkillAsync()
[Test]
public async ValueTask ItemSkillAddedAsync()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var skillList = player.SkillList as SkillList;
await player.Inventory!.AddItemAsync(0, this.CreateItemWithSkill(QualifiedItemSkillId, player.SelectedCharacter!.CharacterClass)).ConfigureAwait(false);
await player.Inventory!.AddItemAsync(1, this.CreateItemWithSkill(NonQualifiedItemSkillId)).ConfigureAwait(false);
@@ -54,7 +54,7 @@ public async ValueTask ItemSkillAddedAsync()
[Test]
public async ValueTask ItemSkillRemovedAsync()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var item = this.CreateItemWithSkill(QualifiedItemSkillId, player.SelectedCharacter!.CharacterClass);
item.Durability = 1;
await player.Inventory!.AddItemAsync(0, item).ConfigureAwait(false);
@@ -69,7 +69,7 @@ public async ValueTask ItemSkillRemovedAsync()
[Test]
public async ValueTask NonLearnedSkillAsync()
{
- var player = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
Assert.That(player.SkillList!.ContainsSkill(NonLearnedSkillId), Is.False);
}
diff --git a/tests/MUnique.OpenMU.Tests/TradeTest.cs b/tests/MUnique.OpenMU.Tests/TradeTest.cs
index bc30d692d..f2bc44d33 100644
--- a/tests/MUnique.OpenMU.Tests/TradeTest.cs
+++ b/tests/MUnique.OpenMU.Tests/TradeTest.cs
@@ -115,8 +115,8 @@ public async ValueTask TradeFinishTestAsync()
[Test]
public async ValueTask TradeItemsAsync()
{
- var trader1 = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
- var trader2 = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var trader1 = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var trader2 = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var tradeRequestAction = new TradeRequestAction();
var tradeResponseAction = new TradeAcceptAction();
@@ -144,8 +144,8 @@ public async ValueTask TradeItemsAsync()
[Test]
public async ValueTask TradeFailedItemsNotFitAsync()
{
- var trader1 = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
- var trader2 = await TestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var trader1 = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
+ var trader2 = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var tradeRequestAction = new TradeRequestAction();
var tradeResponseAction = new TradeAcceptAction();
From 5b3ac10fdd782b42330ab6b38922fc87bbe08e9f Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Sat, 21 Mar 2026 21:52:54 -0300
Subject: [PATCH 11/32] fix: revert breaking change
---
src/DataModel/Configuration/MonsterDefinition.cs | 11 -----------
.../Offlevel/CombatHandlerTests.cs | 2 ++
2 files changed, 2 insertions(+), 11 deletions(-)
diff --git a/src/DataModel/Configuration/MonsterDefinition.cs b/src/DataModel/Configuration/MonsterDefinition.cs
index 7a068c4a7..fca37dd56 100644
--- a/src/DataModel/Configuration/MonsterDefinition.cs
+++ b/src/DataModel/Configuration/MonsterDefinition.cs
@@ -219,17 +219,6 @@ public enum NpcObjectKind
[Cloneable]
public partial class MonsterDefinition
{
- ///
- /// Initializes a new instance of the class.
- ///
- public MonsterDefinition()
- {
- this.ItemCraftings = new List();
- this.DropItemGroups = new List();
- this.Attributes = new List();
- this.Quests = new List();
- }
-
///
/// Gets or sets the unique number of this monster.
///
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs
index e81de398c..e66e9bc8d 100644
--- a/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs
@@ -15,6 +15,8 @@ namespace MUnique.OpenMU.Tests.Offlevel;
using MUnique.OpenMU.GameLogic.OfflineLeveling;
using MUnique.OpenMU.GameServer.RemoteView.MuHelper;
using MUnique.OpenMU.Pathfinding;
+using MonsterDefinition = MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition;
+using MonsterAttribute = MUnique.OpenMU.Persistence.BasicModel.MonsterAttribute;
///
/// Tests for .
From 18dc98d26c6a2803957c5c427ad59cb27946674b Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Sun, 22 Mar 2026 18:11:09 -0300
Subject: [PATCH 12/32] code review: apply code suggestions
---
src/GameLogic/OfflineLeveling/BuffHandler.cs | 32 ++++++++++---------
.../OfflineLeveling/CombatHandler.cs | 2 +-
.../OfflineLevelingIntelligence.cs | 14 +++++---
.../OfflineLeveling/OfflineLevelingManager.cs | 12 +++----
.../Services/OfflineLevelingAccountService.cs | 2 +-
.../Offlevel/CombatHandlerTests.cs | 2 +-
.../Party/PartyManagerTest.cs | 2 +-
7 files changed, 36 insertions(+), 30 deletions(-)
diff --git a/src/GameLogic/OfflineLeveling/BuffHandler.cs b/src/GameLogic/OfflineLeveling/BuffHandler.cs
index 80341b3f5..d6240dc4e 100644
--- a/src/GameLogic/OfflineLeveling/BuffHandler.cs
+++ b/src/GameLogic/OfflineLeveling/BuffHandler.cs
@@ -35,6 +35,22 @@ public BuffHandler(OfflineLevelingPlayer player, IMuHelperSettings? config)
this._config = config;
}
+ ///
+ /// Gets the configured buff skill IDs from the settings.
+ ///
+ public IList ConfiguredBuffIds
+ {
+ get
+ {
+ if (this._config is null)
+ {
+ return [];
+ }
+
+ return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id];
+ }
+ }
+
///
/// Checks and applies buffs if configured and needed.
///
@@ -46,7 +62,7 @@ public async ValueTask PerformBuffsAsync()
return true;
}
- var buffIds = this.GetConfiguredBuffIds();
+ var buffIds = this.ConfiguredBuffIds;
if (buffIds.Count == 0)
{
return true;
@@ -87,20 +103,6 @@ public async ValueTask PerformBuffsAsync()
return true;
}
- ///
- /// Gets the configured buff skill IDs from the settings.
- ///
- /// A list containing BuffSkill0Id, BuffSkill1Id, and BuffSkill2Id.
- public List GetConfiguredBuffIds()
- {
- if (this._config is null)
- {
- return [];
- }
-
- return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id];
- }
-
private static bool IsSkillQualifiedForTarget(SkillEntry skillEntry)
{
if (skillEntry.Skill is not { } skill)
diff --git a/src/GameLogic/OfflineLeveling/CombatHandler.cs b/src/GameLogic/OfflineLeveling/CombatHandler.cs
index 9ad863ab5..54bd836d4 100644
--- a/src/GameLogic/OfflineLeveling/CombatHandler.cs
+++ b/src/GameLogic/OfflineLeveling/CombatHandler.cs
@@ -163,7 +163,7 @@ private async ValueTask ExecuteAttackAsync(IAttackable target)
if (skill == null)
{
// Do not attack if there are buffs configured to handle buff-only classes.
- var buffs = this._buffHandler.GetConfiguredBuffIds();
+ var buffs = this._buffHandler.ConfiguredBuffIds;
if (buffs.Any(id => id > 0))
{
return;
diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
index 228b7162b..bc1e447de 100644
--- a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
+++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
@@ -21,7 +21,6 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling;
/// - Skill and movement animations broadcast to nearby observers
/// - Pet control
///
-/// Party support is not implemented.
///
public sealed class OfflineLevelingIntelligence : AsyncDisposable
{
@@ -77,9 +76,9 @@ public OfflineLevelingIntelligence(OfflineLevelingPlayer player)
public void Start()
{
_ = this._petHandler.InitializeAsync();
-
+
this._aiTimer ??= new Timer(
- _ => _ = this.SafeTickAsync(this._cts.Token),
+ _ => this.SafeTick(this._cts.Token),
null,
TimeSpan.FromSeconds(1),
TimeSpan.FromMilliseconds(500));
@@ -103,10 +102,16 @@ protected override void Dispose(bool disposing)
this._aiTimer = null;
this._cts.Dispose();
}
-
+
base.Dispose(disposing);
}
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100", Justification = "Timer callback — exceptions are caught internally.")]
+ private async void SafeTick(CancellationToken cancellationToken)
+ {
+ await this.SafeTickAsync(cancellationToken).ConfigureAwait(false);
+ }
+
private void OnPlayerDied(DeathInformation e)
{
this._player.Logger.LogDebug("Offline leveling player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName);
@@ -114,7 +119,6 @@ private void OnPlayerDied(DeathInformation e)
this._cts.Cancel();
}
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100", Justification = "Timer callback — exceptions are caught internally.")]
private async Task SafeTickAsync(CancellationToken cancellationToken)
{
try
diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs
index ec36daebd..77a74ba42 100644
--- a/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs
+++ b/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs
@@ -15,6 +15,12 @@ public sealed class OfflineLevelingManager
private readonly System.Collections.Concurrent.ConcurrentDictionary _activePlayers =
new(StringComparer.OrdinalIgnoreCase);
+ ///
+ /// Gets a snapshot of all currently active offline leveling players.
+ ///
+ public IReadOnlyCollection OfflineLevelingPlayers
+ => this._activePlayers.Values.ToList();
+
///
/// Starts an offline leveling session by replacing the real player with a ghost.
///
@@ -92,12 +98,6 @@ public async ValueTask StopAsync(string loginName)
public bool TryGetPlayer(string loginName, out OfflineLevelingPlayer? player)
=> this._activePlayers.TryGetValue(loginName, out player);
- ///
- /// Returns a snapshot of all currently active offline leveling players.
- ///
- public IReadOnlyCollection GetOfflineLevelingPlayers()
- => this._activePlayers.Values.ToList();
-
private async ValueTask TransitionToOfflineAsync(Player realPlayer, string loginName)
{
await this.LogOffFromLoginServerAsync(realPlayer, loginName).ConfigureAwait(false);
diff --git a/src/Web/Shared/Services/OfflineLevelingAccountService.cs b/src/Web/Shared/Services/OfflineLevelingAccountService.cs
index 931a31b29..73ef42a65 100644
--- a/src/Web/Shared/Services/OfflineLevelingAccountService.cs
+++ b/src/Web/Shared/Services/OfflineLevelingAccountService.cs
@@ -49,7 +49,7 @@ public Task> GetAsync(int offset, int count)
var result = this._serverProvider.Servers
.OfType()
.SelectMany(s => s.Context.OfflineLevelingManager
- .GetOfflineLevelingPlayers()
+ .OfflineLevelingPlayers
.Select(p => new OfflineLevelingAccount(
p.AccountLoginName ?? string.Empty,
(byte)((IManageableServer)s).Id,
diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs
index e66e9bc8d..f0cabf4f6 100644
--- a/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs
+++ b/tests/MUnique.OpenMU.Tests/Offlevel/CombatHandlerTests.cs
@@ -25,7 +25,7 @@ namespace MUnique.OpenMU.Tests.Offlevel;
public class CombatHandlerTests
{
private IGameContext _gameContext = null!;
- private Point _origin = new(100, 100);
+ private readonly Point _origin = new(100, 100);
///
/// Sets up a fresh game context before each test.
diff --git a/tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs b/tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs
index 01ea0c949..34a59587e 100644
--- a/tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs
+++ b/tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs
@@ -130,7 +130,7 @@ public async ValueTask OnMemberReconnected_DoesNothingWhenNoCachedParty()
[Test]
public async ValueTask KickedMember_IsRemovedFromPartyList()
{
- var (party, member1, member2) = await this.CreatePartyWithTwoMembersAsync().ConfigureAwait(false);
+ var (party, _, member2) = await this.CreatePartyWithTwoMembersAsync().ConfigureAwait(false);
await party.KickPlayerAsync((byte)party.PartyList.IndexOf(member2)).ConfigureAwait(false);
From bb35fbf54de4ea1558ea001e8dc4746e683f15c4 Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Sun, 22 Mar 2026 18:20:41 -0300
Subject: [PATCH 13/32] code review: apply code suggestions
---
.../OfflineLeveling/OfflineLevelingIntelligence.cs | 8 +-------
1 file changed, 1 insertion(+), 7 deletions(-)
diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
index bc1e447de..9363a0971 100644
--- a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
+++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs
@@ -78,7 +78,7 @@ public void Start()
_ = this._petHandler.InitializeAsync();
this._aiTimer ??= new Timer(
- _ => this.SafeTick(this._cts.Token),
+ state => _ = this.SafeTickAsync(this._cts.Token),
null,
TimeSpan.FromSeconds(1),
TimeSpan.FromMilliseconds(500));
@@ -106,12 +106,6 @@ protected override void Dispose(bool disposing)
base.Dispose(disposing);
}
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100", Justification = "Timer callback — exceptions are caught internally.")]
- private async void SafeTick(CancellationToken cancellationToken)
- {
- await this.SafeTickAsync(cancellationToken).ConfigureAwait(false);
- }
-
private void OnPlayerDied(DeathInformation e)
{
this._player.Logger.LogDebug("Offline leveling player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName);
From c870e24b5bd5a9cb80463a2f87c00c596194249c Mon Sep 17 00:00:00 2001
From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com>
Date: Mon, 23 Mar 2026 08:39:19 -0300
Subject: [PATCH 14/32] code review: revert code deleted by mistake
---
.../MUnique.OpenMU.Web.AdminPanel.csproj | 17 +++++++++++++++++
.../GameContextTestHelper.cs | 2 +-
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj
index 811da76da..e621c8187 100644
--- a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj
+++ b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj
@@ -44,6 +44,23 @@
+
+
+ ResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+
+
+ True
+ True
+ Resources.resx
+
+
+
+
+
+
|