From 976b0d81ced869b263756828ca98b98d0201bf68 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:46:23 -0300 Subject: [PATCH 01/32] feature: offline party support --- src/AttributeSystem/StatAttribute.cs | 4 +- src/Directory.Packages.props | 1 + src/GameLogic/GameContext.cs | 18 +- src/GameLogic/IGameContext.cs | 7 +- src/GameLogic/IPartyManager.cs | 44 +++ src/GameLogic/IPartyMember.cs | 12 +- src/GameLogic/MUnique.OpenMU.GameLogic.csproj | 3 +- .../OfflineLevelingIntelligence.cs | 3 +- .../OfflineLeveling/OfflineLevelingManager.cs | 23 +- src/GameLogic/OfflinePartyMember.cs | 87 +++++ src/GameLogic/Party.cs | 65 +++- src/GameLogic/PartyManager.cs | 82 +++++ src/GameLogic/Player.cs | 33 +- .../Party/PartyResponseAction.cs | 4 +- .../PlugIns/PartyAutoRejoinPlugIn.cs | 39 ++ .../Properties/PlugInResources.Designer.cs | 12 + src/GameLogic/Properties/PlugInResources.resx | 6 + src/GameServer/GameServer.cs | 6 + .../RemoteView/Party/PartyHealthViewPlugIn.cs | 3 + src/Interfaces/LocalizedString.cs | 3 +- .../ClientToServer/ClientToServerPackets.cs | 7 +- .../ClientToServer/ClientToServerPackets.xml | 4 +- src/Web/AdminPanel/Pages/LoggedIn.razor | 35 +- .../Properties/Resources.Designer.cs | 27 ++ src/Web/AdminPanel/Properties/Resources.resx | 9 + .../AdminPanel/WebApplicationExtensions.cs | 2 + .../Shared/Services/OfflineLevelingAccount.cs | 10 + .../Services/OfflineLevelingAccountService.cs | 66 ++++ .../Offlevel/OfflineLevelingTest.cs | 337 ++++++++++++++++++ .../Party/PartyManagerTest.cs | 173 +++++++++ .../{ => Party}/PartyTest.cs | 16 +- tests/MUnique.OpenMU.Tests/TestHelper.cs | 18 + 32 files changed, 1109 insertions(+), 50 deletions(-) create mode 100644 src/GameLogic/IPartyManager.cs create mode 100644 src/GameLogic/OfflinePartyMember.cs create mode 100644 src/GameLogic/PartyManager.cs create mode 100644 src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs create mode 100644 src/Web/Shared/Services/OfflineLevelingAccount.cs create mode 100644 src/Web/Shared/Services/OfflineLevelingAccountService.cs create mode 100644 tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs create mode 100644 tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs rename tests/MUnique.OpenMU.Tests/{ => Party}/PartyTest.cs (93%) diff --git a/src/AttributeSystem/StatAttribute.cs b/src/AttributeSystem/StatAttribute.cs index 3f5982035..7385fd73a 100644 --- a/src/AttributeSystem/StatAttribute.cs +++ b/src/AttributeSystem/StatAttribute.cs @@ -40,9 +40,9 @@ public StatAttribute(AttributeDefinition definition, float baseValue) { get { - if (this.Definition?.MaximumValue.HasValue is true) + if (this.Definition?.MaximumValue is float maxValue) { - return Math.Min(this.Definition.MaximumValue.Value, this._statValue); + return Math.Min(maxValue, this._statValue); } return this._statValue; diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 36db961fa..6f93eb4ce 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -23,6 +23,7 @@ + diff --git a/src/GameLogic/GameContext.cs b/src/GameLogic/GameContext.cs index 42dc8610b..a4538f33d 100644 --- a/src/GameLogic/GameContext.cs +++ b/src/GameLogic/GameContext.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -80,6 +80,7 @@ public GameContext(GameConfiguration configuration, IPersistenceContextProvider this.DropGenerator = dropGenerator; this.ConfigurationChangeMediator = changeMediator; this.ItemPowerUpFactory = new ItemPowerUpFactory(loggerFactory.CreateLogger()); + this.PartyManager = new PartyManager(configuration.MaximumPartySize, loggerFactory.CreateLogger()); this._recoverTimer = new Timer(this.RecoverTimerElapsed, null, this.Configuration.RecoveryInterval, this.Configuration.RecoveryInterval); this._tasksTimer = new Timer(this.ExecutePeriodicTasks, null, 1000, 1000); this.FeaturePlugIns = new FeaturePlugInContainer(this.PlugInManager); @@ -148,16 +149,17 @@ public GameContext(GameConfiguration configuration, IPersistenceContextProvider /// /// Gets the players by character name dictionary. /// - public IDictionary PlayersByCharacterName { get; } = new ConcurrentDictionary(); + public ConcurrentDictionary PlayersByCharacterName { get; } = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); /// public DuelRoomManager DuelRoomManager { get; set; } - /// - /// Gets the state of the active self defenses. - /// + /// public ConcurrentDictionary<(Player Attacker, Player Defender), DateTime> SelfDefenseState { get; } = new(); + /// + public IPartyManager PartyManager { get; } + /// public ILoggerFactory LoggerFactory { get; } @@ -341,7 +343,7 @@ public virtual async ValueTask RemovePlayerAsync(Player player) PlayerCounter.Add(-1); if (player.SelectedCharacter != null) { - this.PlayersByCharacterName.Remove(player.SelectedCharacter.Name); + this.PlayersByCharacterName.TryRemove(player.SelectedCharacter.Name, out _); } player.CurrentMap?.RemoveAsync(player); @@ -507,13 +509,13 @@ await this.ForEachPlayerAsync(player => private ValueTask PlayerEnteredWorldAsync(Player player) { - this.PlayersByCharacterName.Add(player.SelectedCharacter!.Name, player); + this.PlayersByCharacterName.TryAdd(player.SelectedCharacter!.Name, player); return ValueTask.CompletedTask; } private ValueTask PlayerLeftWorldAsync(Player player) { - this.PlayersByCharacterName.Remove(player.SelectedCharacter!.Name); + this.PlayersByCharacterName.TryRemove(player.SelectedCharacter!.Name, out _); return ValueTask.CompletedTask; } } \ No newline at end of file diff --git a/src/GameLogic/IGameContext.cs b/src/GameLogic/IGameContext.cs index f42b66c5c..e340a1fd0 100644 --- a/src/GameLogic/IGameContext.cs +++ b/src/GameLogic/IGameContext.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -112,6 +112,11 @@ public interface IGameContext /// ConcurrentDictionary<(Player Attacker, Player Defender), DateTime> SelfDefenseState { get; } + /// + /// Gets the party manager which handles party creation and persistence. + /// + IPartyManager PartyManager { get; } + /// /// Gets the initialized maps which are hosted on this context. /// diff --git a/src/GameLogic/IPartyManager.cs b/src/GameLogic/IPartyManager.cs new file mode 100644 index 000000000..09a9abc8c --- /dev/null +++ b/src/GameLogic/IPartyManager.cs @@ -0,0 +1,44 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +/// +/// Manages party creation, cache persistence, and offline-member lifecycle. +/// +public interface IPartyManager +{ + /// + /// Creates a new party using the configured maximum party size. + /// + /// The newly created party. + Party CreateParty(); + + /// + /// Persists the party for the specified character identifier. + /// + /// The character identifier. + /// The party to persist. + void SaveParty(Guid characterId, Party party); + + /// + /// Removes the persisted party for the specified character identifier. + /// + /// The character identifier. + void RemoveParty(Guid characterId); + + /// + /// Called when a party member disconnects. Replaces the live member with an + /// snapshot so the party stays intact. + /// + /// The member who disconnected. + ValueTask OnMemberDisconnectedAsync(IPartyMember member); + + /// + /// Called when a previously offline party member reconnects. Swaps the + /// snapshot back to the live player. + /// + /// The member who reconnected. + ValueTask OnMemberReconnectedAsync(IPartyMember member); +} \ No newline at end of file diff --git a/src/GameLogic/IPartyMember.cs b/src/GameLogic/IPartyMember.cs index fd7b36dbb..4f7942cbc 100644 --- a/src/GameLogic/IPartyMember.cs +++ b/src/GameLogic/IPartyMember.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -33,4 +33,14 @@ public interface IPartyMember : IWorldObserver, IObservable, IIdentifiable, ILoc /// Gets the name. /// string Name { get; } + + /// + /// Gets the unique character identifier. + /// + Guid CharacterId { get; } + + /// + /// Gets a value indicating whether the member is currently connected to the game. + /// + bool IsConnected { get; } } \ No newline at end of file diff --git a/src/GameLogic/MUnique.OpenMU.GameLogic.csproj b/src/GameLogic/MUnique.OpenMU.GameLogic.csproj index 0d05fb058..e685545a2 100644 --- a/src/GameLogic/MUnique.OpenMU.GameLogic.csproj +++ b/src/GameLogic/MUnique.OpenMU.GameLogic.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -24,6 +24,7 @@ + diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs index 0d1fa7010..2655e0931 100644 --- a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs @@ -20,7 +20,6 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; /// Item pickup (Zen, Jewels, Excellent, Ancient, and named extra items) /// Skill and movement animations broadcast to nearby observers /// -/// Party support is not implemented. /// public sealed class OfflineLevelingIntelligence : IDisposable { @@ -86,7 +85,7 @@ public void Dispose() this._aiTimer = null; } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", 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 diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs index 675661746..a287427d8 100644 --- a/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingManager.cs @@ -70,22 +70,37 @@ public async ValueTask StartAsync(Player realPlayer, string loginName) } } + var party = realPlayer.Party; + // Suppress the disconnection event to prevent double-save or redundant log-offs. realPlayer.SuppressDisconnectedEvent(); + // Clear the player's party reference before disconnecting so that DisconnectAsync + // does NOT call LeaveTemporarilyAsync and create a stale OfflinePartyMember snapshot. + // The ghost will be properly inserted into the party after it is fully initialized. + if (party is not null) + { + realPlayer.Party = null; + } + // Perform disconnection: removes player from map and saves progress. await realPlayer.DisconnectAsync().ConfigureAwait(false); // Dispose the old context so entities can be attached to the ghost's new context. realPlayer.PersistenceContext.Dispose(); - // Now the name slot is free, initialize the ghost in-place. if (!await sentinel.InitializeAsync(account, character).ConfigureAwait(false)) { this._activePlayers.TryRemove(loginName, out _); return false; } + // Now that the offline player is fully initialized, swap it into the party. + if (party is not null) + { + await party.ReplaceMemberAsync(realPlayer, sentinel).ConfigureAwait(false); + } + return true; } catch @@ -112,4 +127,10 @@ public async ValueTask StopAsync(string loginName) /// /// The account login name. public bool IsActive(string loginName) => this._activePlayers.ContainsKey(loginName); + + /// + /// Returns a snapshot of all currently active offline leveling players. + /// + public IReadOnlyCollection GetOfflineLevelingPlayers() + => this._activePlayers.Values.ToList(); } \ No newline at end of file diff --git a/src/GameLogic/OfflinePartyMember.cs b/src/GameLogic/OfflinePartyMember.cs new file mode 100644 index 000000000..1187b5406 --- /dev/null +++ b/src/GameLogic/OfflinePartyMember.cs @@ -0,0 +1,87 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Pathfinding; +using MUnique.OpenMU.PlugIns; +using Nito.AsyncEx; + +/// +/// A party member who is currently offline. +/// +public sealed class OfflinePartyMember : IPartyMember +{ + /// + /// Initializes a new instance of the class. + /// + /// The player. + public OfflinePartyMember(IPartyMember player) + { + this.Name = player.Name; + this.Id = player.Id; + this.CharacterId = player.CharacterId; + this.CurrentHealth = player.CurrentHealth; + this.MaximumHealth = player.MaximumHealth; + this.CurrentMap = player.CurrentMap; + this.Position = player.Position; + this.Logger = (player as ILoggerOwner)?.Logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + } + + /// + public Party? Party { get; set; } + + /// + public IPartyMember? LastPartyRequester { get; set; } + + /// + public uint MaximumHealth { get; } + + /// + public uint CurrentHealth { get; } + + /// + public string Name { get; } + + /// + public Guid CharacterId { get; } + + /// + public bool IsConnected => false; + + /// + public ushort Id { get; } + + /// + public Point Position { get; set; } + + /// + public GameMap? CurrentMap { get; } + + /// + public ICustomPlugInContainer ViewPlugIns { get; } = new DummyViewPlugInContainer(); + + /// + public AsyncReaderWriterLock ObserverLock { get; } = new(); + + /// + public ISet Observers { get; } = new HashSet(); + + /// + public ILogger Logger { get; } + + /// + public ValueTask AddObserverAsync(IWorldObserver observer) => ValueTask.CompletedTask; + + /// + public ValueTask RemoveObserverAsync(IWorldObserver observer) => ValueTask.CompletedTask; + + private class DummyViewPlugInContainer : ICustomPlugInContainer + { + public T? GetPlugIn() + where T : class, IViewPlugIn => null; + } +} diff --git a/src/GameLogic/Party.cs b/src/GameLogic/Party.cs index 45bd8fb0a..af138bd21 100644 --- a/src/GameLogic/Party.cs +++ b/src/GameLogic/Party.cs @@ -24,6 +24,8 @@ public sealed class Party : Disposable private readonly ILogger _logger; + private readonly IPartyManager _partyManager; + private readonly Timer _healthUpdate; private readonly byte _maxPartySize; @@ -31,14 +33,16 @@ public sealed class Party : Disposable private readonly List _distributionList; private readonly AsyncLock _distributionLock = new AsyncLock(); - + /// /// Initializes a new instance of the class. /// + /// The party manager for persistence. /// Maximum size of the party. /// Logger of this party. - public Party(byte maxPartySize, ILogger logger) + public Party(IPartyManager partyManager, byte maxPartySize, ILogger logger) { + this._partyManager = partyManager; this._maxPartySize = maxPartySize; this._logger = logger; @@ -64,10 +68,17 @@ public Party(byte maxPartySize, ILogger logger) /// public IPartyMember? PartyMaster { get; private set; } + private static string MeterName => typeof(Party).FullName ?? nameof(Party); + /// - /// Gets the name of the meter of this class. + /// Notifies the party that a member has disconnected temporarily. + /// Delegates offline snapshot creation to . /// - internal static string MeterName => typeof(Party).FullName ?? nameof(Party); + /// The member who disconnected. + public async ValueTask LeaveTemporarilyAsync(IPartyMember member) + { + await this._partyManager.OnMemberDisconnectedAsync(member).ConfigureAwait(false); + } /// /// Kicks the player from the party. @@ -114,11 +125,47 @@ public async ValueTask AddAsync(IPartyMember newPartyMate) this.PartyList.Add(newPartyMate); newPartyMate.Party = this; + this._partyManager.SaveParty(newPartyMate.CharacterId, this); + await this.SendPartyListAsync().ConfigureAwait(false); await this.UpdateNearbyCountAsync().ConfigureAwait(false); return true; } + /// + /// Replaces the specified with a . + /// Used during offline leveling handover to keep the party intact. + /// + /// The old member. + /// The new member. + public async ValueTask ReplaceMemberAsync(IPartyMember oldMember, IPartyMember newMember) + { + var index = this.PartyList.IndexOf(oldMember); + if (index < 0) + { + return; + } + + this.PartyList[index] = newMember; + newMember.Party = this; + oldMember.Party = null; + this._partyManager.RemoveParty(oldMember.CharacterId); + this._partyManager.SaveParty(newMember.CharacterId, this); + + if (this.PartyMaster == oldMember) + { + this.PartyMaster = newMember; + } + + await this.SendPartyListAsync().ConfigureAwait(false); + await this.UpdateNearbyCountAsync().ConfigureAwait(false); + + if (oldMember is Player oldPlayer && oldPlayer.Attributes is { } oldAttr) + { + oldAttr[Stats.NearbyPartyMemberCount] = 0; + } + } + /// /// Sends the chat message to all party members. /// @@ -172,6 +219,11 @@ public async ValueTask DistributeMoneyAfterKillAsync(IAttackable killedObject, I try { this._distributionList.AddRange(this.PartyList.OfType().Where(p => p.CurrentMap == killer.CurrentMap && !p.IsAtSafezone() && p.Attributes is { })); + if (this._distributionList.Count == 0) + { + return; + } + var moneyPart = amount / this._distributionList.Count; this._distributionList.ForEach(p => p.TryAddMoney((int)(moneyPart * p.Attributes![Stats.MoneyAmountRate]))); } @@ -302,7 +354,9 @@ private async ValueTask InternalDistributeExperienceAfterKillAsync(IAttacka private async ValueTask ExitPartyAsync(IPartyMember player, byte index) { - if (this.PartyList.Count < 3 || Equals(this.PartyMaster, player)) + // We dissolve only if fewer than 2 real members exist. + var remainingMembers = this.PartyList.Count(m => m != player); + if (remainingMembers < 2) { this.Dispose(); return; @@ -310,6 +364,7 @@ private async ValueTask ExitPartyAsync(IPartyMember player, byte index) this.PartyList.Remove(player); player.Party = null; + this._partyManager.RemoveParty(player.CharacterId); try { await player.InvokeViewPlugInAsync(p => p.PartyMemberRemovedAsync(index)).ConfigureAwait(false); diff --git a/src/GameLogic/PartyManager.cs b/src/GameLogic/PartyManager.cs new file mode 100644 index 000000000..f00f589c1 --- /dev/null +++ b/src/GameLogic/PartyManager.cs @@ -0,0 +1,82 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using Microsoft.Extensions.Caching.Memory; + +/// +/// Default implementation of using for persistence. +/// +public sealed class PartyManager : IPartyManager, IDisposable +{ + private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); + private readonly ILogger _partyLogger; + private readonly byte _maxPartySize; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum number of members per party. + /// The logger for party instances. + public PartyManager(byte maxPartySize, ILogger partyLogger) + { + this._maxPartySize = maxPartySize; + this._partyLogger = partyLogger; + } + + /// + public Party CreateParty() + { + return new Party(this, this._maxPartySize, this._partyLogger); + } + + /// + public void SaveParty(Guid characterId, Party party) + { + this._cache.Set(characterId, party, TimeSpan.FromHours(24)); + } + + /// + public void RemoveParty(Guid characterId) + { + this._cache.Remove(characterId); + } + + /// + public async ValueTask OnMemberDisconnectedAsync(IPartyMember member) + { + if (member.Party is not { } party) + { + return; + } + + var snapshot = new OfflinePartyMember(member); + await party.ReplaceMemberAsync(member, snapshot).ConfigureAwait(false); + } + + /// + public async ValueTask OnMemberReconnectedAsync(IPartyMember member) + { + if (!this._cache.TryGetValue(member.CharacterId, out Party? party) || party is null) + { + return; + } + + var offlineMember = party.PartyList.FirstOrDefault(m => m.CharacterId == member.CharacterId && !m.IsConnected); + if (offlineMember is null) + { + return; + } + + await party.ReplaceMemberAsync(offlineMember, member).ConfigureAwait(false); + } + + /// + public void Dispose() + { + this._cache.Dispose(); + } + +} \ No newline at end of file diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index db7f3c583..eecc435af 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. // @@ -193,6 +193,9 @@ public int Money /// public string Name => this.SelectedCharacter?.Name ?? string.Empty; + /// + public Guid CharacterId => this.SelectedCharacter?.Id ?? Guid.Empty; + /// public int Level => (int)(this.Attributes?[Stats.Level] ?? 0); @@ -336,7 +339,7 @@ private set public ComboStateMachine? ComboState => this.Attributes?[Stats.IsSkillComboAvailable] > 0 ? this._comboStateLazy?.Value : null; /// - /// Gets the summon. + /// Gets summon. /// public (Monster, INpcIntelligence)? Summon { get; private set; } @@ -349,6 +352,9 @@ private set /// public Party? Party { get; set; } + /// + public bool IsConnected => !this.PlayerState.CurrentState.IsDisconnectedOrFinished(); + /// public bool IsAlive { get; set; } @@ -557,6 +563,7 @@ public async ValueTask SetSelectedCharacterAsync(Character? character) } this.DuelRoom = null; + this._selectedCharacter = null; } else @@ -1059,7 +1066,7 @@ public async ValueTask WarpToAsync(ExitGate gate) /// Respawns the player to the specified gate. /// /// The gate at which the player should be respawned. - public async ValueTask RespawnAtAsync(ExitGate gate) + public virtual async ValueTask RespawnAtAsync(ExitGate gate) { var isRespawnOnSameMap = object.Equals(this.CurrentMap?.Definition, gate.Map); @@ -1700,7 +1707,7 @@ public async ValueTask CreateSummonedMonsterAsync(MonsterDefinition definition) } /// - /// Notifies the player object that the summon died. + /// Notifies the player object that summon died. /// public void SummonDied() { @@ -1708,7 +1715,7 @@ public void SummonDied() } /// - /// Removes the summon. + /// Removes summon. /// public async ValueTask RemoveSummonAsync() { @@ -1816,7 +1823,7 @@ public async ValueTask RemoveFromGameAsync() await this.RemoveFromCurrentMapAsync().ConfigureAwait(false); if (this.Party is { } party) { - await party.KickMySelfAsync(this).ConfigureAwait(false); + await party.LeaveTemporarilyAsync(this).ConfigureAwait(false); } await this.RestoreTemporaryStorageItemsAsync().ConfigureAwait(false); @@ -1864,7 +1871,7 @@ protected override async ValueTask DisposeAsyncCore() await this.RemoveFromCurrentMapAsync().ConfigureAwait(false); if (this.Party is { } party) { - await party.KickMySelfAsync(this).ConfigureAwait(false); + await party.LeaveTemporarilyAsync(this).ConfigureAwait(false); } await this._observerToWorldViewAdapter.ClearObservingObjectsListAsync().ConfigureAwait(false); @@ -1944,10 +1951,10 @@ private void PlaceAtGate(ExitGate gate) private async ValueTask RemoveFromCurrentMapAsync() { - if (this.CurrentMap != null) + if (this._currentMap is { } map) { - await this.CurrentMap.RemoveAsync(this).ConfigureAwait(false); - this.CurrentMap = null; + await map.RemoveAsync(this).ConfigureAwait(false); + this._currentMap = null; } } @@ -2206,6 +2213,12 @@ async Task RespawnAsync(CancellationToken cancellationToken) try { await Task.Delay(3000, cancellationToken).ConfigureAwait(false); + + if (cancellationToken.IsCancellationRequested || this.CurrentMap is null) + { + return; + } + if (this.Summon?.Item1 is { } summon) { await summon.CurrentMap.RemoveAsync(summon).ConfigureAwait(false); diff --git a/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs b/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs index 97f67dbf5..de2d44bb7 100644 --- a/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs +++ b/src/GameLogic/PlayerActions/Party/PartyResponseAction.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -58,7 +58,7 @@ public async ValueTask HandleResponseAsync(Player player, bool accepted) else { var master = player.LastPartyRequester; - var party = new Party(player.GameContext.Configuration.MaximumPartySize, player.GameContext.LoggerFactory.CreateLogger()); + var party = player.GameContext.PartyManager.CreateParty(); await party.AddAsync(master).ConfigureAwait(false); await party.AddAsync(player).ConfigureAwait(false); } diff --git a/src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs b/src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs new file mode 100644 index 000000000..fbd5e7afb --- /dev/null +++ b/src/GameLogic/PlugIns/PartyAutoRejoinPlugIn.cs @@ -0,0 +1,39 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Views.Party; +using MUnique.OpenMU.PlugIns; + +/// +/// Listens for the and automatically rejoins the player +/// to their previous party if it still exists when they enter the world. +/// +[PlugIn] +[Display( + Name = nameof(PlugInResources.PartyAutoRejoinPlugIn_Name), + Description = nameof(PlugInResources.PartyAutoRejoinPlugIn_Description), + ResourceType = typeof(PlugInResources))] +[Guid("013406B3-02AD-45EF-906B-177CBEC9B2D4")] +public sealed class PartyAutoRejoinPlugIn : IPlayerStateChangedPlugIn +{ + /// + public async ValueTask PlayerStateChangedAsync(Player player, State previousState, State currentState) + { + if (currentState != PlayerState.EnteredWorld) + { + return; + } + + await player.GameContext.PartyManager.OnMemberReconnectedAsync(player).ConfigureAwait(false); + + if (player.Party is not null) + { + player.Logger.LogDebug("Player {0} rejoined their previous party.", player.Name); + await player.InvokeViewPlugInAsync(p => p.UpdatePartyHealthAsync()).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index acefdf41e..36d774377 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -3124,5 +3124,17 @@ public static string OfflineLevelingStopOnLoginPlugIn_Description { return ResourceManager.GetString("OfflineLevelingStopOnLoginPlugIn_Description", resourceCulture); } } + + public static string PartyAutoRejoinPlugIn_Name { + get { + return ResourceManager.GetString("PartyAutoRejoinPlugIn_Name", resourceCulture); + } + } + + public static string PartyAutoRejoinPlugIn_Description { + get { + return ResourceManager.GetString("PartyAutoRejoinPlugIn_Description", resourceCulture); + } + } } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 0de31adc9..e80e79cfa 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1140,4 +1140,10 @@ Automatically stops an active offline leveling session when the account owner logs back in, allowing the real player to take over their character. + + Party Auto-Rejoin + + + Automatically rejoins players to their previous party upon entering the world. + diff --git a/src/GameServer/GameServer.cs b/src/GameServer/GameServer.cs index 17403d52a..61e02ed89 100644 --- a/src/GameServer/GameServer.cs +++ b/src/GameServer/GameServer.cs @@ -281,6 +281,12 @@ public async ValueTask DisconnectPlayerAsync(string playerName) /// public async ValueTask DisconnectAccountAsync(string accountName) { + if (this._gameContext.OfflineLevelingManager.IsActive(accountName)) + { + await this._gameContext.OfflineLevelingManager.StopAsync(accountName).ConfigureAwait(false); + return true; + } + var players = await this._gameContext.GetPlayersAsync().ConfigureAwait(false); var player = players.FirstOrDefault(p => p.Account?.LoginName == accountName); if (player != null) diff --git a/src/GameServer/RemoteView/Party/PartyHealthViewPlugIn.cs b/src/GameServer/RemoteView/Party/PartyHealthViewPlugIn.cs index 403969fb4..9c3c746f4 100644 --- a/src/GameServer/RemoteView/Party/PartyHealthViewPlugIn.cs +++ b/src/GameServer/RemoteView/Party/PartyHealthViewPlugIn.cs @@ -50,6 +50,9 @@ public async ValueTask UpdatePartyHealthAsync() return; } + // Refresh the cache before sending so direct calls don't send stale values. + this.UpdateHealthValues(); + var connection = this._player.Connection; if (connection is null) { diff --git a/src/Interfaces/LocalizedString.cs b/src/Interfaces/LocalizedString.cs index 643c2c421..1a11bfe08 100644 --- a/src/Interfaces/LocalizedString.cs +++ b/src/Interfaces/LocalizedString.cs @@ -66,6 +66,7 @@ public string ValueInNeutralLanguage /// A that contains the neutral language text, or an empty span /// if the underlying value is . /// + [System.Text.Json.Serialization.JsonIgnore] public ReadOnlySpan ValueInNeutralLanguageAsSpan { get @@ -159,7 +160,7 @@ public static implicit operator LocalizedString(string localizedString) { var span = this.GetTranslationAsSpan(cultureInfo, fallbackToNeutral); return span.IsEmpty - ? null + ? string.Empty : new(span); } diff --git a/src/Network/Packets/ClientToServer/ClientToServerPackets.cs b/src/Network/Packets/ClientToServer/ClientToServerPackets.cs index 2e04c91bd..a3151fc95 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 => 8; + public static int Length => 9; /// /// Gets the header of this packet. @@ -6681,7 +6681,7 @@ private CashShopStorageItemConsumeRequest(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 => 5; + public static int Length => 15; /// /// Gets the header of this packet. @@ -17690,5 +17690,4 @@ 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 e0dfbd933..9ae13208b 100644 --- a/src/Network/Packets/ClientToServer/ClientToServerPackets.xml +++ b/src/Network/Packets/ClientToServer/ClientToServerPackets.xml @@ -1514,7 +1514,7 @@ D2 05 CashShopStorageListRequest - 8 + 9 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. @@ -1563,7 +1563,7 @@ D2 0B CashShopStorageItemConsumeRequest - 5 + 15 ClientToServer The player wants to get or consume an item which is in the cash shop storage. The item is applied or added to the inventory. diff --git a/src/Web/AdminPanel/Pages/LoggedIn.razor b/src/Web/AdminPanel/Pages/LoggedIn.razor index d46f7dbc0..bccafad94 100644 --- a/src/Web/AdminPanel/Pages/LoggedIn.razor +++ b/src/Web/AdminPanel/Pages/LoggedIn.razor @@ -2,19 +2,21 @@ @using MUnique.OpenMU.DataModel @using MUnique.OpenMU.DataModel.Entities @using MUnique.OpenMU.Web.AdminPanel.Properties +@using MUnique.OpenMU.Web.Shared.Services @inject LoggedInAccountService AccountService; +@inject OfflineLevelingAccountService OfflineLevelingService; OpenMU: @Resources.OnlineAccounts

@Resources.OnlineAccounts

-
+
- @typeof(Account).GetPropertyCaption(nameof(Account.LoginName)) - @Resources.ServerID - @Resources.Action + @typeof(Account).GetPropertyCaption(nameof(Account.LoginName)) + @Resources.ServerID + @Resources.Action @item.LoginName @@ -27,3 +29,28 @@
+ +

@Resources.OfflineLeveling

+ +
+ + + @typeof(Account).GetPropertyCaption(nameof(Account.LoginName)) + @Resources.Character + @Resources.ServerID + @Resources.StartedAt + @Resources.Action + + + @item.LoginName + @item.CharacterName + @item.ServerId + @item.StartedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC + + + + + +
diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 881d8d6be..ea54c2b12 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -1331,5 +1331,32 @@ internal static string YesCreateTestAccounts { return ResourceManager.GetString("YesCreateTestAccounts", resourceCulture); } } + + /// + /// Looks up a localized string similar to Character. + /// + public static string Character { + get { + return ResourceManager.GetString("Character", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Started at. + /// + public static string StartedAt { + get { + return ResourceManager.GetString("StartedAt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Offline Leveling. + /// + public static string OfflineLeveling { + get { + return ResourceManager.GetString("OfflineLeveling", resourceCulture); + } + } } } diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index eb288814d..0280827be 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -543,4 +543,13 @@ Game server count + + Character + + + Started At + + + Offline Leveling + \ No newline at end of file diff --git a/src/Web/AdminPanel/WebApplicationExtensions.cs b/src/Web/AdminPanel/WebApplicationExtensions.cs index 7f6071b9b..0a505f28e 100644 --- a/src/Web/AdminPanel/WebApplicationExtensions.cs +++ b/src/Web/AdminPanel/WebApplicationExtensions.cs @@ -84,6 +84,8 @@ public static WebApplicationBuilder AddAdminPanel(this WebApplicationBuilder bui services.AddScoped(); services.AddScoped(); services.AddScoped>(serviceProvider => serviceProvider.GetService()!); + services.AddScoped(); + services.AddScoped>(serviceProvider => serviceProvider.GetService()!); StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration); return builder; diff --git a/src/Web/Shared/Services/OfflineLevelingAccount.cs b/src/Web/Shared/Services/OfflineLevelingAccount.cs new file mode 100644 index 000000000..543a7e8cd --- /dev/null +++ b/src/Web/Shared/Services/OfflineLevelingAccount.cs @@ -0,0 +1,10 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +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); diff --git a/src/Web/Shared/Services/OfflineLevelingAccountService.cs b/src/Web/Shared/Services/OfflineLevelingAccountService.cs new file mode 100644 index 000000000..d80a97cac --- /dev/null +++ b/src/Web/Shared/Services/OfflineLevelingAccountService.cs @@ -0,0 +1,66 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Services; + +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.Interfaces; + +/// +/// Service for the offline leveling table on the LoggedIn page. +/// +public class OfflineLevelingAccountService : IDataService, ISupportDataChangedNotification +{ + private readonly IServerProvider _serverProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The server provider. + public OfflineLevelingAccountService(IServerProvider serverProvider) + { + this._serverProvider = serverProvider; + } + + /// + public event EventHandler? DataChanged; + + /// + /// Stops the offline leveling session for the given account. + /// + /// 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) + { + await server.DisconnectAccountAsync(account.LoginName).ConfigureAwait(false); + } + + this.DataChanged?.Invoke(this, EventArgs.Empty); + } + + /// + public Task> GetAsync(int offset, int count) + { + var result = this._serverProvider.Servers + .OfType() + .SelectMany(s => s.Context.OfflineLevelingManager + .GetOfflineLevelingPlayers() + .Select(p => new OfflineLevelingAccount( + p.AccountLoginName ?? string.Empty, + p.CharacterName ?? string.Empty, + (byte)((IManageableServer)s).Id, + p.StartTimestamp))) + .OrderBy(a => a.LoginName) + .Skip(offset) + .Take(count) + .ToList(); + + return Task.FromResult(result); + } +} \ No newline at end of file diff --git a/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs new file mode 100644 index 000000000..99b8ff571 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Offlevel/OfflineLevelingTest.cs @@ -0,0 +1,337 @@ +// +// 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.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 MuHelperPlayerConfiguration { 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 MuHelperPlayerConfiguration { 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 MuHelperPlayerConfiguration { 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 MuHelperPlayerConfiguration { 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 MuHelperPlayerConfiguration + { + 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 CombatHandler_HealthRecovery_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); + + 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 CombatHandler_HealthRecovery_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 MuHelperPlayerConfiguration + { + UseHealPotion = true, + PotionThresholdPercent = 50, + }; + + var origin = player.Position; + var movement = new MovementHandler(player, config, origin); + var handler = new CombatHandler(player, config, movement, origin); + 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 MuHelperPlayerConfiguration + { + 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/Party/PartyManagerTest.cs b/tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs new file mode 100644 index 000000000..f9a7724b6 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Party/PartyManagerTest.cs @@ -0,0 +1,173 @@ +// +// 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.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.Persistence.InMemory; +using MUnique.OpenMU.PlugIns; + +/// +/// Tests for . +/// +[TestFixture] +public class PartyManagerTest +{ + private const byte MaxPartySize = 5; + + private PartyManager _partyManager = null!; + + /// + /// Sets up a fresh before each test. + /// + [SetUp] + public void SetUp() + { + this._partyManager = new PartyManager(MaxPartySize, new NullLogger()); + } + + /// + /// Tests that returns a party with the configured max size. + /// + [Test] + public void CreateParty_RespectsMaxPartySize() + { + var party = this._partyManager.CreateParty(); + Assert.That(party.MaxPartySize, Is.EqualTo(MaxPartySize)); + } + + /// + /// Tests that a member added to the party is present in the party list. + /// + [Test] + public async ValueTask CreateParty_AddedMemberIsInPartyList() + { + var member = await this.CreatePartyMemberAsync().ConfigureAwait(false); + var party = this._partyManager.CreateParty(); + + await party.AddAsync(member).ConfigureAwait(false); + + Assert.That(party.PartyList, Contains.Item(member)); + } + + /// + /// Tests that replaces the live member + /// with an snapshot at the same index. + /// + [Test] + public async ValueTask OnMemberDisconnected_ReplacesWithOfflineSnapshot() + { + var (party, member1, _) = await this.CreatePartyWithTwoMembersAsync().ConfigureAwait(false); + + await this._partyManager.OnMemberDisconnectedAsync(member1).ConfigureAwait(false); + + Assert.That(party.PartyList[0], Is.InstanceOf()); + Assert.That(party.PartyList[0].CharacterId, Is.EqualTo(member1.CharacterId)); + } + + /// + /// Tests that the offline snapshot preserves the disconnected member's name. + /// + [Test] + public async ValueTask OnMemberDisconnected_SnapshotPreservesName() + { + var (party, member1, _) = await this.CreatePartyWithTwoMembersAsync().ConfigureAwait(false); + + await this._partyManager.OnMemberDisconnectedAsync(member1).ConfigureAwait(false); + + Assert.That(party.PartyList[0].Name, Is.EqualTo(member1.Name)); + } + + /// + /// Tests that swaps the offline snapshot + /// back to the live member. + /// + [Test] + public async ValueTask OnMemberReconnected_RestoresLiveMember() + { + var (party, member1, _) = await this.CreatePartyWithTwoMembersAsync().ConfigureAwait(false); + + await this._partyManager.OnMemberDisconnectedAsync(member1).ConfigureAwait(false); + await this._partyManager.OnMemberReconnectedAsync(member1).ConfigureAwait(false); + + Assert.That(party.PartyList[0], Is.SameAs(member1)); + } + + /// + /// Tests that after reconnect the live member's Party reference is restored. + /// + [Test] + public async ValueTask OnMemberReconnected_RestoresPartyReference() + { + var (party, member1, _) = await this.CreatePartyWithTwoMembersAsync().ConfigureAwait(false); + + await this._partyManager.OnMemberDisconnectedAsync(member1).ConfigureAwait(false); + await this._partyManager.OnMemberReconnectedAsync(member1).ConfigureAwait(false); + + Assert.That(member1.Party, Is.SameAs(party)); + } + + /// + /// Tests that does nothing + /// when the member has no cached party. + /// + [Test] + public async ValueTask OnMemberReconnected_DoesNothingWhenNoCachedParty() + { + var member = await this.CreatePartyMemberAsync().ConfigureAwait(false); + + Assert.DoesNotThrowAsync(async () => + await this._partyManager.OnMemberReconnectedAsync(member).ConfigureAwait(false)); + + Assert.That(member.Party, Is.Null); + } + + /// + /// Tests that when a member is kicked the party list no longer contains them. + /// + [Test] + public async ValueTask KickedMember_IsRemovedFromPartyList() + { + var (party, member1, member2) = await this.CreatePartyWithTwoMembersAsync().ConfigureAwait(false); + + await party.KickPlayerAsync((byte)party.PartyList.IndexOf(member2)).ConfigureAwait(false); + + Assert.That(party.PartyList, Is.Not.Contains(member2)); + Assert.That(member2.Party, Is.Null); + } + + private async ValueTask<(Party Party, Player Member1, Player Member2)> CreatePartyWithTwoMembersAsync() + { + var member1 = await this.CreatePartyMemberAsync().ConfigureAwait(false); + var member2 = await this.CreatePartyMemberAsync().ConfigureAwait(false); + var party = this._partyManager.CreateParty(); + await party.AddAsync(member1).ConfigureAwait(false); + await party.AddAsync(member2).ConfigureAwait(false); + return (party, member1, member2); + } + + private async ValueTask CreatePartyMemberAsync() + { + var result = await TestHelper.CreatePlayerAsync(this.GetGameContext()).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/PartyTest.cs b/tests/MUnique.OpenMU.Tests/Party/PartyTest.cs similarity index 93% rename from tests/MUnique.OpenMU.Tests/PartyTest.cs rename to tests/MUnique.OpenMU.Tests/Party/PartyTest.cs index 0cc2c49c4..57734d3a0 100644 --- a/tests/MUnique.OpenMU.Tests/PartyTest.cs +++ b/tests/MUnique.OpenMU.Tests/Party/PartyTest.cs @@ -28,7 +28,7 @@ public class PartyTest public async ValueTask PartyMemberAddAsync() { var partyMember = await this.CreatePartyMemberAsync().ConfigureAwait(false); - var party = new Party(5, new NullLogger()); + var party = new Party(new PartyManager(5, new NullLogger()), 5, new NullLogger()); await party.AddAsync(partyMember).ConfigureAwait(false); Assert.That(party.PartyList, Contains.Item(partyMember)); @@ -88,8 +88,12 @@ public async ValueTask PartyMasterKicksHimselfAsync() var partyMember = party.PartyList[1]; await this._kickAction.KickPlayerAsync((Player)partyMaster, (byte)party.PartyList.IndexOf(partyMaster)).ConfigureAwait(false); - Assert.That(partyMember.Party, Is.Null); - Assert.That(party.PartyList, Is.Null.Or.Empty); + + // Master leaves the party; the remaining 2 members stay. + Assert.That(partyMaster.Party, Is.Null); + Assert.That(party.PartyList, Does.Not.Contain(partyMaster)); + Assert.That(partyMember.Party, Is.SameAs(party)); + Assert.That(party.PartyList, Has.Count.EqualTo(2)); } /// @@ -99,7 +103,7 @@ public async ValueTask PartyMasterKicksHimselfAsync() public async ValueTask PartyAutoCloseAsync() { var partyMember1 = await this.CreatePartyMemberAsync().ConfigureAwait(false); - var party = new Party(5, new NullLogger()); + var party = new Party(new PartyManager(5, new NullLogger()), 5, new NullLogger()); await party.AddAsync(partyMember1).ConfigureAwait(false); var partyMember1Index = (byte)(party.PartyList.Count - 1); var partyMember2 = await this.CreatePartyMemberAsync().ConfigureAwait(false); @@ -187,7 +191,7 @@ private async ValueTask CreatePartyMemberAsync() private async ValueTask CreatePartyWithMembersAsync(int numberOfMembers) { - var party = new Party(5, new NullLogger()); + var party = new Party(new PartyManager(5, new NullLogger()), 5, new NullLogger()); for (ushort i = 0; i < numberOfMembers; i++) { var partyMember = await this.CreatePartyMemberAsync().ConfigureAwait(false); @@ -203,9 +207,9 @@ private IGameContext GetGameContext() 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()); - gameContext.Configuration.MaximumPartySize = 5; mapInitializer.PlugInManager = gameContext.PlugInManager; mapInitializer.PathFinderPool = gameContext.PathFinderPool; diff --git a/tests/MUnique.OpenMU.Tests/TestHelper.cs b/tests/MUnique.OpenMU.Tests/TestHelper.cs index b235eeccf..5ade3bb20 100644 --- a/tests/MUnique.OpenMU.Tests/TestHelper.cs +++ b/tests/MUnique.OpenMU.Tests/TestHelper.cs @@ -13,6 +13,7 @@ namespace MUnique.OpenMU.Tests; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic.OfflineLeveling; using MUnique.OpenMU.Persistence.InMemory; using MUnique.OpenMU.PlugIns; @@ -65,6 +66,7 @@ public static async ValueTask CreatePlayerAsync(IGameContext gameContext characterMock.Setup(c => c.DropItemGroups).Returns(new List()); var inventoryMock = new Mock(); + inventoryMock.SetupAllProperties(); inventoryMock.Setup(i => i.Items).Returns(new List()); var character = characterMock.Object; @@ -135,6 +137,22 @@ public static async ValueTask CreatePlayerAsync(IGameContext gameContext return player; } + /// + /// Creates an . + /// + /// The game context. + /// The offline leveling player at . + public static async ValueTask CreateOfflineLevelingPlayerAsync(IGameContext gameContext) + { + var regularPlayer = await CreatePlayerAsync(gameContext).ConfigureAwait(false); + var offlinePlayer = new OfflineLevelingPlayer(gameContext) { Account = regularPlayer.Account }; + await offlinePlayer.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await offlinePlayer.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await offlinePlayer.PlayerState.TryAdvanceToAsync(PlayerState.CharacterSelection).ConfigureAwait(false); + await offlinePlayer.SetSelectedCharacterAsync(regularPlayer.SelectedCharacter!).ConfigureAwait(false); + return offlinePlayer; + } + private class TestPlayer : Player { public TestPlayer(IGameContext gameContext) From a7a06e4733bb8960b3bd98f65954fb58b84e7636 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:15:22 -0300 Subject: [PATCH 02/32] feature: offline party support bug fixing --- .../MuHelper/MuHelperZenCostCalculator.cs | 2 +- src/GameLogic/OfflineLeveling/BuffHandler.cs | 22 ++- .../OfflineLeveling/HealingHandler.cs | 11 +- .../OfflineLevelingIntelligence.cs | 2 +- .../OfflineLeveling/OfflineLevelingPlayer.cs | 52 +++--- src/GameLogic/Party.cs | 2 +- .../MUnique.OpenMU.Web.AdminPanel.csproj | 152 +++++++++--------- src/Web/AdminPanel/Pages/LoggedIn.razor | 2 - .../Shared/MUnique.OpenMU.Web.Shared.csproj | 6 +- .../Shared/Services/LoggedInAccountService.cs | 6 +- .../Shared/Services/OfflineLevelingAccount.cs | 2 +- .../Services/OfflineLevelingAccountService.cs | 14 +- src/Web/Shared/wwwroot/css/shared.css | 39 ++--- .../Offlevel/OfflineLevelingTest.cs | 31 ++-- 14 files changed, 184 insertions(+), 159 deletions(-) diff --git a/src/GameLogic/MuHelper/MuHelperZenCostCalculator.cs b/src/GameLogic/MuHelper/MuHelperZenCostCalculator.cs index 3e886bbb2..dd85f2405 100644 --- a/src/GameLogic/MuHelper/MuHelperZenCostCalculator.cs +++ b/src/GameLogic/MuHelper/MuHelperZenCostCalculator.cs @@ -21,7 +21,7 @@ public static class MuHelperZenCostCalculator /// The Zen amount to deduct; 0 if the configuration has no cost entries. public static int Calculate(Player player, MuHelperConfiguration configuration, DateTime startTimestamp) { - if (configuration.CostPerStage.Count == 0) + if (configuration.CostPerStage.Count == 0 || configuration.StageInterval <= TimeSpan.Zero) { return 0; } diff --git a/src/GameLogic/OfflineLeveling/BuffHandler.cs b/src/GameLogic/OfflineLeveling/BuffHandler.cs index 8e07e490f..f41090001 100644 --- a/src/GameLogic/OfflineLeveling/BuffHandler.cs +++ b/src/GameLogic/OfflineLeveling/BuffHandler.cs @@ -53,6 +53,7 @@ public async ValueTask PerformBuffsAsync() this.UpdatePeriodicBuffTimer(); + this._buffSkillIndex = 0; for (int i = 0; i < BuffSlotCount; i++) { int buffId = buffIds[this._buffSkillIndex]; @@ -88,6 +89,17 @@ public async ValueTask PerformBuffsAsync() return true; } + private static bool IsSkillQualifiedForTarget(SkillEntry skillEntry) + { + if (skillEntry.Skill is not { } skill) + { + return false; + } + + return skill.Target != SkillTarget.ImplicitPlayer + && skill.TargetRestriction != SkillTargetRestriction.Self; + } + private List GetConfiguredBuffIds() { if (this._config is null) @@ -128,6 +140,11 @@ private async ValueTask TryBuffPartyMembersAsync(SkillEntry skillEntry) continue; } + if (!IsSkillQualifiedForTarget(skillEntry)) + { + continue; + } + if (await this.TryBuffTargetAsync(member, skillEntry, true).ConfigureAwait(false)) { return true; @@ -173,6 +190,9 @@ private async ValueTask TryBuffTargetAsync(IAttackable target, SkillEntry if (shouldApply) { + await this._player.ForEachWorldObserverAsync( + p => p.ShowSkillAnimationAsync(this._player, target, skillEntry.Skill!, true), + includeThis: true).ConfigureAwait(false); await target.ApplyMagicEffectAsync(this._player, skillEntry).ConfigureAwait(false); this.MoveNextSlot(); return true; @@ -180,4 +200,4 @@ private async ValueTask TryBuffTargetAsync(IAttackable target, SkillEntry return false; } -} +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/HealingHandler.cs b/src/GameLogic/OfflineLeveling/HealingHandler.cs index 3e74ba1cb..f70bba73e 100644 --- a/src/GameLogic/OfflineLeveling/HealingHandler.cs +++ b/src/GameLogic/OfflineLeveling/HealingHandler.cs @@ -10,6 +10,7 @@ namespace MUnique.OpenMU.GameLogic.OfflineLeveling; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions; +using MUnique.OpenMU.GameLogic.Views.World; using MUnique.OpenMU.Interfaces; /// @@ -64,6 +65,9 @@ private async ValueTask PerformSelfHealingAsync() var healSkill = this.FindSkillByType(SkillType.Regeneration); if (healSkill is not null && this._config.AutoHeal) { + await this._player.ForEachWorldObserverAsync( + p => p.ShowSkillAnimationAsync(this._player, this._player, healSkill.Skill!, true), + includeThis: true).ConfigureAwait(false); await this._player.ApplyRegenerationAsync(this._player, healSkill).ConfigureAwait(false); return; } @@ -102,7 +106,10 @@ private async ValueTask PerformPartyHealingAsync() if (this.IsHealthBelowThreshold(member, this._config.HealPartyThresholdPercent)) { - await this._player.ApplyRegenerationAsync(member, healSkill).ConfigureAwait(false); + await this._player.ForEachWorldObserverAsync( + p => p.ShowSkillAnimationAsync(this._player, member, healSkill.Skill!, true), + includeThis: true).ConfigureAwait(false); + await member.ApplyRegenerationAsync(this._player, healSkill).ConfigureAwait(false); } } } @@ -144,4 +151,4 @@ await ConsumeAction.HandleConsumeRequestAsync( { return this._player.SkillList?.Skills.FirstOrDefault(s => s.Skill?.SkillType == type); } -} +} \ No newline at end of file diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs index 0f5a3206f..494b71e7a 100644 --- a/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingIntelligence.cs @@ -100,7 +100,7 @@ private async Task SafeTickAsync() } catch (Exception ex) { - this._player.Logger.LogError(ex, "Error in offline leveling AI tick for {Name}.", this._player.CharacterName); + this._player.Logger.LogError(ex, "Error in offline leveling AI tick for {AccountLoginName}.", this._player.AccountLoginName); } } diff --git a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs index 2588e0041..2c0387b31 100644 --- a/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs +++ b/src/GameLogic/OfflineLeveling/OfflineLevelingPlayer.cs @@ -26,9 +26,9 @@ public OfflineLevelingPlayer(IGameContext gameContext) } /// - /// Gets the character name. + /// Gets the login name this offline player belongs to. /// - public string? CharacterName => this.SelectedCharacter?.Name; + public string? AccountLoginName => this.Account?.LoginName; /// /// Gets the start timestamp of the offline leveling session. @@ -65,11 +65,34 @@ public async ValueTask InitializeAsync(Account account, Character characte } catch (Exception ex) { - this.Logger.LogError(ex, "Failed to initialize offline player for {CharacterName}.", this.CharacterName); + this.Logger.LogError(ex, "Failed to initialize offline player for {AccountLoginName}.", this.AccountLoginName); return false; } } + /// + /// Stops the offline player and removes it from the world. + /// + public async ValueTask StopAsync() + { + this._intelligence?.Dispose(); + this._intelligence = null; + try + { + await this.SaveProgressAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to save progress of offline leveling player {AccountLoginName}.", this.AccountLoginName); + } + + await this.DisconnectAsync().ConfigureAwait(false); + } + + /// + protected override ICustomPlugInContainer CreateViewPlugInContainer() + => new NullViewPlugInContainer(); + private async ValueTask AdvanceToEnteredWorldStateAsync() { // Advance state to allow the intelligence to perform actions. @@ -95,27 +118,4 @@ private void StartIntelligence() this._intelligence = new OfflineLevelingIntelligence(this); this._intelligence.Start(); } - - /// - /// Stops the offline player and removes it from the world. - /// - public async ValueTask StopAsync() - { - this._intelligence?.Dispose(); - this._intelligence = null; - try - { - await this.SaveProgressAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Failed to save progress of offline leveling player {CharacterName}.", this.CharacterName); - } - - await this.DisconnectAsync().ConfigureAwait(false); - } - - /// - protected override ICustomPlugInContainer CreateViewPlugInContainer() - => new NullViewPlugInContainer(); } \ No newline at end of file diff --git a/src/GameLogic/Party.cs b/src/GameLogic/Party.cs index af138bd21..a2ddc708a 100644 --- a/src/GameLogic/Party.cs +++ b/src/GameLogic/Party.cs @@ -33,7 +33,7 @@ public sealed class Party : Disposable private readonly List _distributionList; private readonly AsyncLock _distributionLock = new AsyncLock(); - + /// /// Initializes a new instance of the class. /// diff --git a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj index ba235797e..69d82b0b5 100644 --- a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj +++ b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj @@ -1,81 +1,89 @@  - - 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 - + + 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 + - - - - - - - - + + + + + + + + + + + - - - ResXFileCodeGenerator - Resources.Designer.cs - - - - - True - True - Resources.resx - - - - - + + + + + + + + - - - - <_StaticWebAsset Include="@(StaticWebAsset)" /> - - + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + True + True + Resources.resx + + + + - + + + + + + + + + + + + + + <_StaticWebAsset Include="@(StaticWebAsset)"/> + + + + diff --git a/src/Web/AdminPanel/Pages/LoggedIn.razor b/src/Web/AdminPanel/Pages/LoggedIn.razor index bccafad94..2681dc368 100644 --- a/src/Web/AdminPanel/Pages/LoggedIn.razor +++ b/src/Web/AdminPanel/Pages/LoggedIn.razor @@ -36,14 +36,12 @@ @typeof(Account).GetPropertyCaption(nameof(Account.LoginName)) - @Resources.Character @Resources.ServerID @Resources.StartedAt @Resources.Action @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 + + + + + +