diff --git a/src/GameLogic/GameContext.cs b/src/GameLogic/GameContext.cs index abbbb0955..a15327f84 100644 --- a/src/GameLogic/GameContext.cs +++ b/src/GameLogic/GameContext.cs @@ -343,6 +343,17 @@ public async ValueTask> GetPlayersAsync() /// The player. public virtual async ValueTask RemovePlayerAsync(Player player) { + bool removed; + using (await this._playerListLock.WriterLockAsync()) + { + removed = this._playerList.Remove(player); + } + + if (!removed) + { + return; + } + PlayerCounter.Add(-1); if (player.SelectedCharacter != null) { @@ -351,11 +362,6 @@ public virtual async ValueTask RemovePlayerAsync(Player player) player.CurrentMap?.RemoveAsync(player); - using (await this._playerListLock.WriterLockAsync()) - { - this._playerList.Remove(player); - } - player.PlayerDisconnected -= this.RemovePlayerAsync; player.PlayerEnteredWorld -= this.PlayerEnteredWorldAsync; player.PlayerLeftWorld -= this.PlayerLeftWorldAsync; diff --git a/src/GameLogic/IGameContext.cs b/src/GameLogic/IGameContext.cs index 0fb984f48..57283e4c5 100644 --- a/src/GameLogic/IGameContext.cs +++ b/src/GameLogic/IGameContext.cs @@ -138,6 +138,12 @@ public interface IGameContext /// The player. ValueTask AddPlayerAsync(Player player); + /// + /// Removes the player from the game. + /// + /// The player. + ValueTask RemovePlayerAsync(Player player); + /// /// Gets the maps which is meant to be hosted by the game. /// diff --git a/src/GameLogic/Offline/ItemPickupHandler.cs b/src/GameLogic/Offline/ItemPickupHandler.cs index 9bbaa190b..ac65f2a50 100644 --- a/src/GameLogic/Offline/ItemPickupHandler.cs +++ b/src/GameLogic/Offline/ItemPickupHandler.cs @@ -16,7 +16,6 @@ namespace MUnique.OpenMU.GameLogic.Offline; public sealed class ItemPickupHandler { private const byte MinPickupRange = 1; - private const byte JewelItemGroup = 14; private static readonly PickupItemAction PickupAction = new(); @@ -35,7 +34,7 @@ public ItemPickupHandler(OfflinePlayer player, IMuHelperSettings? config) } /// - /// Scans for and picks up items within configurable range. + /// Scans for and picks up items within a configurable range. /// public async ValueTask PickupItemsAsync() { @@ -61,6 +60,17 @@ public async ValueTask PickupItemsAsync() } } + private static bool IsJewel(Item item) + { + if (item.Definition is not { } definition) + { + return false; + } + + return (definition.Group == 14 && definition.Number is 13 or 14 or 16 or 22 or 31 or 41 or 42 or 43 or 44) + || (definition.Group == 12 && definition.Number == 15); + } + private bool ShouldPickUpDrop(IIdentifiable drop) { if (this._config!.PickAllItems) @@ -93,7 +103,7 @@ private bool ShouldPickUp(Item item) return false; } - if (this._config.PickJewel && item.Definition?.Group == JewelItemGroup) + if (this._config.PickJewel && IsJewel(item)) { return true; } diff --git a/src/GameLogic/Offline/MovementHandler.cs b/src/GameLogic/Offline/MovementHandler.cs index ef4ddb2f4..98ab06df5 100644 --- a/src/GameLogic/Offline/MovementHandler.cs +++ b/src/GameLogic/Offline/MovementHandler.cs @@ -71,9 +71,9 @@ public async ValueTask RegroupAsync() /// The range to stop within. public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range) { - if (target.IsInRange(this._originPosition, this.HuntingRange)) + if (this._player.CurrentMap is { } map && target.IsInRange(this._originPosition, this.HuntingRange)) { - var walkTarget = this._player.CurrentMap!.Terrain.GetRandomCoordinate(target.Position, range); + var walkTarget = map.Terrain.GetRandomCoordinate(target.Position, range); await this.WalkToAsync(walkTarget).ConfigureAwait(false); } } diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 66a6bab23..908e49f38 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -15,6 +15,7 @@ namespace MUnique.OpenMU.GameLogic.Offline; public sealed class OfflinePlayer : Player { private OfflinePlayerMuHelper? _intelligence; + private Task? _intelligenceDisposeTask; /// /// Initializes a new instance of the class. @@ -36,18 +37,32 @@ public OfflinePlayer(IGameContext gameContext) public DateTime StartTimestamp { get; internal set; } /// - /// Initializes the offline player from captured references. + /// Initializes the offline player by loading the account fresh from the database. /// - /// The account. - /// The character. + /// The account login name. + /// The character name to continue with. /// true if successfully started. - public async ValueTask InitializeAsync(Account account, Character character) + public async ValueTask InitializeAsync(string loginName, string characterName) { try { this.StartTimestamp = DateTime.UtcNow; + + var account = await this.PersistenceContext.GetAccountByLoginNameAsync(loginName).ConfigureAwait(false); + if (account is null) + { + this.Logger.LogError("Failed to load account {LoginName} for offline session.", loginName); + return false; + } + + var character = account.Characters?.FirstOrDefault(c => c.Name == characterName); + if (character is null) + { + this.Logger.LogError("Character {CharacterName} not found in account {LoginName}.", characterName, loginName); + return false; + } + this.Account = account; - this.PersistenceContext.Attach(account); await this.AdvanceToCharacterSelectionStateAsync().ConfigureAwait(false); @@ -67,7 +82,7 @@ public async ValueTask InitializeAsync(Account account, Character characte } catch (Exception ex) { - this.Logger.LogError(ex, "Failed to initialize offline player for {player}.", this); + this.Logger.LogError(ex, "Failed to initialize offline player for {LoginName}.", loginName); return false; } } @@ -76,23 +91,41 @@ public async ValueTask InitializeAsync(Account account, Character characte /// Stops the offline player and removes it from the world. /// public async ValueTask StopAsync() + { + await this.DisconnectAsync().ConfigureAwait(false); + } + + /// + protected override async ValueTask InternalDisconnectAsync() { if (this._intelligence is { } intelligence) { - await intelligence.DisposeAsync().ConfigureAwait(false); this._intelligence = null; + this._intelligenceDisposeTask = Task.Run(async () => + { + try + { + await intelligence.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Error disposing intelligence for offline player {AccountLoginName}.", this.AccountLoginName); + } + }); } - try - { - await this.SaveProgressAsync().ConfigureAwait(false); - } - catch (Exception ex) + await base.InternalDisconnectAsync().ConfigureAwait(false); + } + + /// + protected override async ValueTask DisposeAsyncCore() + { + if (this._intelligenceDisposeTask is { } disposeTask) { - this.Logger.LogError(ex, "Failed to save progress of offline player {AccountLoginName}.", this.AccountLoginName); + await disposeTask.ConfigureAwait(false); } - await this.DisconnectAsync().ConfigureAwait(false); + await base.DisposeAsyncCore().ConfigureAwait(false); } /// @@ -109,14 +142,8 @@ private async ValueTask AdvanceToCharacterSelectionStateAsync() private async ValueTask SetupCharacterAsync(Character character) { - // Add to context and set character. await this.GameContext.AddPlayerAsync(this).ConfigureAwait(false); await this.SetSelectedCharacterAsync(character).ConfigureAwait(false); - - if (this.SelectedCharacter is { } selectedCharacter) - { - this.PersistenceContext.Attach(selectedCharacter); - } } private void StartIntelligence() diff --git a/src/GameLogic/Offline/OfflinePlayerManager.cs b/src/GameLogic/Offline/OfflinePlayerManager.cs index a5a3db2d9..b3d5dc886 100644 --- a/src/GameLogic/Offline/OfflinePlayerManager.cs +++ b/src/GameLogic/Offline/OfflinePlayerManager.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.Offline; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.MuHelper; +using MUnique.OpenMU.GameLogic.Views.Login; /// /// Manages active sessions. @@ -29,10 +30,9 @@ public IReadOnlyCollection OfflinePlayers /// true if the offline session was started successfully. public async ValueTask StartAsync(Player realPlayer, string loginName) { - var account = realPlayer.Account; - var character = realPlayer.SelectedCharacter; + var characterName = realPlayer.SelectedCharacter?.Name; - if (account is null || character is null) + if (string.IsNullOrEmpty(characterName)) { return false; } @@ -56,9 +56,9 @@ public async ValueTask StartAsync(Player realPlayer, string loginName) { await this.TransitionToOfflineAsync(realPlayer, loginName).ConfigureAwait(false); - if (!await sentinel.InitializeAsync(account, character).ConfigureAwait(false)) + if (!await sentinel.InitializeAsync(loginName, characterName).ConfigureAwait(false)) { - this._activePlayers.TryRemove(loginName, out _); + await this.RemoveAndDisposeAsync(loginName, sentinel).ConfigureAwait(false); return false; } @@ -66,7 +66,7 @@ public async ValueTask StartAsync(Player realPlayer, string loginName) } catch { - this._activePlayers.TryRemove(loginName, out _); + await this.RemoveAndDisposeAsync(loginName, sentinel).ConfigureAwait(false); throw; } } @@ -77,10 +77,25 @@ public async ValueTask StartAsync(Player realPlayer, string loginName) /// The account login name. public async ValueTask StopAsync(string loginName) { - if (this._activePlayers.TryRemove(loginName, out var offlinePlayer)) + if (!this._activePlayers.TryRemove(loginName, out var offlinePlayer)) + { + // The session might have been started and stopped outside the manager (e.g. in tests). + return; + } + + try { await offlinePlayer.StopAsync().ConfigureAwait(false); } + catch (Exception ex) + { + offlinePlayer.Logger.LogError(ex, "Error stopping offline player session for {0}.", loginName); + } + finally + { + await offlinePlayer.GameContext.RemovePlayerAsync(offlinePlayer).ConfigureAwait(false); + await offlinePlayer.DisposeAsync().ConfigureAwait(false); + } } /// @@ -102,9 +117,12 @@ private async ValueTask TransitionToOfflineAsync(Player realPlayer, string login { await this.LogOffFromLoginServerAsync(realPlayer, loginName).ConfigureAwait(false); - realPlayer.SuppressDisconnectedEvent(); + // Send a close-game packet so the client exits cleanly without auto-reconnecting. + // DisconnectAsync will then fire PlayerDisconnected, which triggers + // RemovePlayerAsync (player list cleanup) and OnPlayerDisconnectedAsync (dispose). + await realPlayer.InvokeViewPlugInAsync(p => p.LogoutAsync(LogoutType.CloseGame)).ConfigureAwait(false); + await realPlayer.DisconnectAsync().ConfigureAwait(false); - realPlayer.PersistenceContext.Dispose(); } /// diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs index 2804e3958..ca0626af4 100644 --- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs +++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs @@ -36,8 +36,8 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable private readonly PetHandler _petHandler; private readonly CancellationTokenSource _cts = new(); private readonly EventHandler _deathHandler; + private readonly PeriodicTimer _timer = new(TimeSpan.FromMilliseconds(500)); - private Timer? _aiTimer; private bool _isDead; /// @@ -72,21 +72,17 @@ public OfflinePlayerMuHelper(OfflinePlayer player) this._player.Died += this._deathHandler; } - /// Starts the 500 ms AI timer and a separate pet AI. + /// Starts the AI loop and a separate pet AI. public void Start() { _ = this._petHandler.InitializeAsync(); - - this._aiTimer ??= new Timer( - state => _ = this.SafeTickAsync(this._cts.Token), - null, - TimeSpan.FromSeconds(1), - TimeSpan.FromMilliseconds(500)); + _ = this.RunLoopAsync(); } /// protected override async ValueTask DisposeAsyncCore() { + this._timer.Dispose(); await this._cts.CancelAsync().ConfigureAwait(false); await this._petHandler.StopAsync().ConfigureAwait(false); await base.DisposeAsyncCore().ConfigureAwait(false); @@ -98,8 +94,6 @@ protected override void Dispose(bool disposing) if (disposing) { this._player.Died -= this._deathHandler; - this._aiTimer?.Dispose(); - this._aiTimer = null; this._cts.Dispose(); } @@ -110,7 +104,22 @@ private void OnPlayerDied(DeathInformation e) { this._player.Logger.LogDebug("Offline player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName); this._isDead = true; - this._cts.Cancel(); + } + + private async Task RunLoopAsync() + { + try + { + while (!this.IsDisposed + && await this._timer.WaitForNextTickAsync(this._cts.Token).ConfigureAwait(false)) + { + await this.SafeTickAsync(this._cts.Token).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + // Expected during shutdown. + } } private async Task SafeTickAsync(CancellationToken cancellationToken) @@ -146,7 +155,15 @@ private async ValueTask TickAsync(CancellationToken cancellationToken) return; } - await this._zenHandler.DeductZenAsync().ConfigureAwait(false); + if (!await this._zenHandler.DeductZenAsync().ConfigureAwait(false)) + { + if (this._player.Account?.LoginName is { } loginName) + { + await this._player.GameContext.OfflinePlayerManager.StopAsync(loginName).ConfigureAwait(false); + } + + return; + } await this._repairHandler.PerformRepairsAsync().ConfigureAwait(false); await this._petHandler.CheckPetDurabilityAsync().ConfigureAwait(false); @@ -156,7 +173,7 @@ private async ValueTask TickAsync(CancellationToken cancellationToken) return; } - // CMuHelper::Work() order: Buff → RecoverHealth → ObtainItem → Regroup → Attack + // CMuHelper::Work() order: Buff → RecoverHealth → ObtainItem → Regroup → Attack. if (!await this._buffHandler.PerformBuffsAsync().ConfigureAwait(false)) { return; @@ -185,7 +202,7 @@ private async ValueTask HandleDeathAsync() { if (!this._player.IsAlive) { - // The offline player is dead. We wait for the server's 3-second respawn timer to finish. + // Player hasn't respawned yet, skip the tick and check again on the next interval. return true; } diff --git a/src/GameLogic/Offline/RepairHandler.cs b/src/GameLogic/Offline/RepairHandler.cs index 79e20a3f1..9ac8da07a 100644 --- a/src/GameLogic/Offline/RepairHandler.cs +++ b/src/GameLogic/Offline/RepairHandler.cs @@ -12,6 +12,12 @@ namespace MUnique.OpenMU.GameLogic.Offline; /// internal sealed class RepairHandler { + /// + /// The durability health threshold (inclusive, in percent) below which a repair is triggered. + /// Mirrors the client's DEFAULT_DURABILITY_THRESHOLD constant in MuHelper.cpp. + /// + private const int DurabilityRepairThresholdPercent = 50; + private readonly Player _player; private readonly IMuHelperSettings? _config; private readonly ItemRepairAction _repairAction = new(); @@ -28,7 +34,8 @@ public RepairHandler(Player player, IMuHelperSettings? config) } /// - /// Performs repairs on equipped items if the configuration allows it. + /// Performs repairs on equipped items if the configuration allows it + /// and the item's durability is at or below %. /// public async ValueTask PerformRepairsAsync() { @@ -47,7 +54,13 @@ public async ValueTask PerformRepairsAsync() continue; } - if (this._player.Inventory?.GetItem(i) is null) + var item = this._player.Inventory?.GetItem(i); + if (item is null) + { + continue; + } + + if (!NeedsDurabilityRepair(item)) { continue; } @@ -55,4 +68,21 @@ public async ValueTask PerformRepairsAsync() await this._repairAction.RepairItemAsync(this._player, i).ConfigureAwait(false); } } + + /// + /// Returns when the item's durability health is at or below + /// %, using ceiling-integer arithmetic + /// to match the client formula: iHealth = (durability * 100 + max - 1) / max. + /// + private static bool NeedsDurabilityRepair(Item item) + { + var max = item.GetMaximumDurabilityOfOnePiece(); + if (max == 0) + { + return false; + } + + var durabilityHealthPercent = ((int)item.Durability * 100 + max - 1) / max; + return durabilityHealthPercent <= DurabilityRepairThresholdPercent; + } } diff --git a/src/GameLogic/Offline/ZenConsumptionHandler.cs b/src/GameLogic/Offline/ZenConsumptionHandler.cs index a0487f357..0cd5d7709 100644 --- a/src/GameLogic/Offline/ZenConsumptionHandler.cs +++ b/src/GameLogic/Offline/ZenConsumptionHandler.cs @@ -31,11 +31,12 @@ public ZenConsumptionHandler(OfflinePlayer player) /// /// Deducts Zen from the player based on the server configuration and the player's level. /// - public async Task DeductZenAsync() + /// true if the player can continue; false if insufficient Zen. + public async ValueTask DeductZenAsync() { if (DateTime.UtcNow - this._lastPayTimestamp < this._configuration.PayInterval) { - return; + return true; } var amount = MuHelperZenCostCalculator.Calculate(this._player, this._configuration, this._player.StartTimestamp); @@ -46,15 +47,15 @@ public async Task DeductZenAsync() await this._player .InvokeViewPlugInAsync(p => p.ConsumeMoneyAsync((uint)amount)) .ConfigureAwait(false); + return true; } - else if (amount > 0) - { - this._player.Logger.LogDebug("Offline player stopped for {CharacterName} due to insufficient Zen.", this._player.Name); - await this._player.StopAsync().ConfigureAwait(false); - } - else + + if (amount > 0) { - // Price is 0 or less, no action required. + this._player.Logger.LogDebug("Insufficient Zen for {CharacterName}.", this._player.Name); + return false; } + + return true; } } \ No newline at end of file diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index e286aeb64..cbc46b64b 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -1412,17 +1412,6 @@ public async Task RegenerateAsync() } } - /// - /// Clears all subscribers from the event so that - /// will not raise it. Used by offline player to prevent - /// GameServer.OnPlayerDisconnectedAsync from double-saving and double-logging off - /// after the real client disconnects. - /// - public void SuppressDisconnectedEvent() - { - this.PlayerDisconnected = null; - } - /// /// Disconnects the player from the game. Remote connections will be closed and data will be saved. /// diff --git a/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs b/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs index a928869d9..81e57d82f 100644 --- a/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs +++ b/tests/MUnique.OpenMU.Tests/GameContextTestHelper.cs @@ -30,6 +30,7 @@ public static IGameContext CreateGameContext() gameConfig.MaximumPartySize = 5; gameConfig.RecoveryInterval = int.MaxValue; gameConfig.MaximumInventoryMoney = int.MaxValue; + gameConfig.ItemDropDuration = TimeSpan.FromMinutes(1); var mapInitializer = new MapInitializer(gameConfig, new NullLogger(), NullDropGenerator.Instance, null); var plugInConfigurations = new List diff --git a/tests/MUnique.OpenMU.Tests/Offline/ItemPickupHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/ItemPickupHandlerTests.cs index 70434413c..da89f62a7 100644 --- a/tests/MUnique.OpenMU.Tests/Offline/ItemPickupHandlerTests.cs +++ b/tests/MUnique.OpenMU.Tests/Offline/ItemPickupHandlerTests.cs @@ -4,6 +4,9 @@ namespace MUnique.OpenMU.Tests.Offline; +using Moq; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.Offline; @@ -68,4 +71,64 @@ private async ValueTask CreateOfflinePlayerAsync() return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false); } + private Item CreateItem(byte group, short number) + { + var definition = new Mock(); + definition.SetupAllProperties(); + definition.Object.Group = group; + definition.Object.Number = number; + definition.Object.Width = 1; + definition.Object.Height = 1; + + 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()); + return item.Object; + } + + /// + /// Tests that a jewel pickup option only picks up actual jewels. + /// + [Test] + public async ValueTask PickJewel_PicksUpJewelsOnlyAsync() + { + // Arrange + var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false); + Assert.That(player.CurrentMap, Is.Not.Null); + + var config = new MuHelperSettings + { + PickSelectItems = true, + PickJewel = true, + ObtainRange = 5, + }; + + var handler = new ItemPickupHandler(player, config); + + var bless = this.CreateItem(14, 13); + var chaos = this.CreateItem(12, 15); + var potion = this.CreateItem(14, 0); + var eye = this.CreateItem(14, 17); + + var blessDrop = new DroppedItem(bless, player.Position, player.CurrentMap!, player); + var chaosDrop = new DroppedItem(chaos, player.Position, player.CurrentMap!, player); + var potionDrop = new DroppedItem(potion, player.Position, player.CurrentMap!, player); + var eyeDrop = new DroppedItem(eye, player.Position, player.CurrentMap!, player); + + await player.CurrentMap!.AddAsync(blessDrop).ConfigureAwait(false); + await player.CurrentMap.AddAsync(chaosDrop).ConfigureAwait(false); + await player.CurrentMap.AddAsync(potionDrop).ConfigureAwait(false); + await player.CurrentMap.AddAsync(eyeDrop).ConfigureAwait(false); + + // Act + await handler.PickupItemsAsync().ConfigureAwait(false); + + // Assert + Assert.That(player.CurrentMap.GetObject(blessDrop.Id), Is.Null, "Bless should be picked up"); + Assert.That(player.CurrentMap.GetObject(chaosDrop.Id), Is.Null, "Chaos should be picked up"); + Assert.That(player.CurrentMap.GetObject(potionDrop.Id), Is.Not.Null, "Potion should NOT be picked up"); + Assert.That(player.CurrentMap.GetObject(eyeDrop.Id), Is.Not.Null, "Devil's Eye should NOT be picked up"); + } } diff --git a/tests/MUnique.OpenMU.Tests/Offline/OfflinePlayerManagerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/OfflinePlayerManagerTests.cs index 53c2659ce..1fd9454ef 100644 --- a/tests/MUnique.OpenMU.Tests/Offline/OfflinePlayerManagerTests.cs +++ b/tests/MUnique.OpenMU.Tests/Offline/OfflinePlayerManagerTests.cs @@ -4,15 +4,12 @@ namespace MUnique.OpenMU.Tests.Offline; -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.Offline; +using MUnique.OpenMU.Persistence; /// /// Tests for . @@ -21,8 +18,10 @@ namespace MUnique.OpenMU.Tests.Offline; public class OfflinePlayerManagerTests { private const string TestUserLoginName = "test"; + private const string TestCharacterName = "testChar"; private IGameContext _gameContext = null!; + private IPersistenceContextProvider _contextProvider = null!; /// /// Sets up a fresh game context before each test. @@ -31,6 +30,7 @@ public class OfflinePlayerManagerTests public void SetUp() { this._gameContext = GameContextTestHelper.CreateGameContext(); + this._contextProvider = this._gameContext.PersistenceContextProvider; } /// @@ -41,8 +41,7 @@ public async ValueTask StartAsync_WithValidPlayer_ReturnsTrueAsync() { // Arrange var manager = new OfflinePlayerManager(); - var realPlayer = await PlayerTestHelper.CreatePlayerAsync(this._gameContext).ConfigureAwait(false); - realPlayer.Account!.LoginName = TestUserLoginName; + var realPlayer = await this.CreatePlayerWithPersistedAccountAsync().ConfigureAwait(false); realPlayer.TryAddMoney(1_000_000); realPlayer.Attributes![Stats.Level] = 100; @@ -62,8 +61,7 @@ public async ValueTask StartAsync_WithInsufficientZen_ReturnsFalseAsync() { // Arrange var manager = new OfflinePlayerManager(); - var realPlayer = await PlayerTestHelper.CreatePlayerAsync(this._gameContext).ConfigureAwait(false); - realPlayer.Account!.LoginName = TestUserLoginName; + var realPlayer = await this.CreatePlayerWithPersistedAccountAsync().ConfigureAwait(false); realPlayer.Money = 0; // No money realPlayer.Attributes![Stats.Level] = 100; @@ -83,12 +81,12 @@ public async ValueTask StopAsync_WhenSessionActive_StopsSuccessfullyAsync() { // Arrange var manager = new OfflinePlayerManager(); - var realPlayer = await PlayerTestHelper.CreatePlayerAsync(this._gameContext).ConfigureAwait(false); - realPlayer.Account!.LoginName = TestUserLoginName; + var realPlayer = await this.CreatePlayerWithPersistedAccountAsync().ConfigureAwait(false); realPlayer.TryAddMoney(1_000_000); realPlayer.Attributes![Stats.Level] = 100; - await manager.StartAsync(realPlayer, TestUserLoginName).ConfigureAwait(false); + var started = await manager.StartAsync(realPlayer, TestUserLoginName).ConfigureAwait(false); + Assert.That(started, Is.True); Assert.That(manager.IsActive(TestUserLoginName), Is.True); // Act @@ -97,4 +95,52 @@ public async ValueTask StopAsync_WhenSessionActive_StopsSuccessfullyAsync() // Assert Assert.That(manager.IsActive(TestUserLoginName), Is.False); } + + /// + /// Creates a player whose account is stored in the in-memory repository, + /// so that can find it. + /// + private async ValueTask CreatePlayerWithPersistedAccountAsync() + { + var config = this._gameContext.Configuration; + + // Create persisted account + character in the in-memory repository. + using (var ctx = this._contextProvider.CreateNewPlayerContext(config)) + { + var account = ctx.CreateNew(); + account.LoginName = TestUserLoginName; + + var character = ctx.CreateNew(); + character.Name = TestCharacterName; + + if (config.CharacterClasses.FirstOrDefault() is { } existingClass) + { + character.CharacterClass = existingClass; + } + else + { + // Build a minimal CharacterClass so OnPlayerEnteredWorldAsync does not throw. + var characterClass = ctx.CreateNew(); + characterClass.HomeMap = config.Maps.FirstOrDefault(); + character.CharacterClass = characterClass; + } + + account.Characters.Add(character); + await ctx.SaveChangesAsync().ConfigureAwait(false); + } + + var player = await PlayerTestHelper.CreatePlayerAsync(this._gameContext).ConfigureAwait(false); + player.Account!.LoginName = TestUserLoginName; + + // Ensure the mock character name matches the persisted one. + var mockCharacter = player.SelectedCharacter!; + mockCharacter.Name = TestCharacterName; + mockCharacter.CharacterClass ??= player.Account.UnlockedCharacterClasses.FirstOrDefault(); + if (mockCharacter.CharacterClass is null && config.CharacterClasses.FirstOrDefault() is { } cc) + { + mockCharacter.CharacterClass = cc; + } + + return player; + } } diff --git a/tests/MUnique.OpenMU.Tests/Offline/RepairHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/RepairHandlerTests.cs index 207a34cab..6b41613bd 100644 --- a/tests/MUnique.OpenMU.Tests/Offline/RepairHandlerTests.cs +++ b/tests/MUnique.OpenMU.Tests/Offline/RepairHandlerTests.cs @@ -31,7 +31,8 @@ public void SetUp() } /// - /// Tests that auto-repair restores item durability when the player has enough Zen. + /// Items at or below the 50% durability threshold should be repaired when the player + /// has enough Zen. Uses 10% (well below threshold) to confirm the happy path. /// [Test] public async ValueTask RepairsItemWhenSufficientZenAsync() @@ -53,7 +54,7 @@ public async ValueTask RepairsItemWhenSufficientZenAsync() } /// - /// Tests that auto-repair does nothing when disabled in the configuration. + /// Auto-repair does nothing when disabled in the configuration, regardless of durability. /// [Test] public async ValueTask DoesNothingWhenDisabledAsync() @@ -75,7 +76,7 @@ public async ValueTask DoesNothingWhenDisabledAsync() } /// - /// Tests that auto-repair does not repair when the player has insufficient Zen. + /// Auto-repair does not repair when the player has insufficient Zen. /// [Test] public async ValueTask DoesNotRepairWhenInsufficientZenAsync() @@ -96,7 +97,7 @@ public async ValueTask DoesNotRepairWhenInsufficientZenAsync() } /// - /// Tests that fully durable items are skipped by the repair handler. + /// Fully durable items are skipped and no Zen is spent. /// [Test] public async ValueTask SkipsFullyDurableItemsAsync() @@ -118,6 +119,85 @@ public async ValueTask SkipsFullyDurableItemsAsync() Assert.That(player.Money, Is.EqualTo(initialMoney)); } + /// + /// An item at exactly 50% durability (the threshold boundary) must be repaired. + /// Mirrors the client check: iHealth <= DEFAULT_DURABILITY_THRESHOLD (50). + /// + [Test] + public async ValueTask RepairsItemAtExactlyFiftyPercentThresholdAsync() + { + // Arrange — 50 / 100 = 50% (ceiling-integer: (50*100 + 99) / 100 = 50) + var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false); + var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 50); + 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)); + } + + /// + /// An item at 51% durability is above the threshold and must NOT be repaired, + /// so no Zen is spent. Mirrors the client check: iHealth <= 50. + /// + [Test] + public async ValueTask SkipsItemAboveFiftyPercentThresholdAsync() + { + // Arrange — 51 / 100 = 51% (ceiling-integer: (51*100 + 99) / 100 = 51) + var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false); + var item = this.CreateDamagedItem(maxDurability: 100, currentDurability: 51); + 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 — durability unchanged, no money spent + Assert.That(item.Durability, Is.EqualTo(51)); + Assert.That(player.Money, Is.EqualTo(initialMoney)); + } + + /// + /// Verifies that the ceiling-integer formula handles non-round max-durability values + /// correctly: 13 / 25 = 52% (above threshold → skip), but 12 / 25 = 48% (≤ 50% → repair). + /// + [TestCase(13, false, TestName = "NonRoundMax_52pct_Skipped")] + [TestCase(12, true, TestName = "NonRoundMax_48pct_Repaired")] + public async ValueTask ThresholdWithNonRoundMaxDurabilityAsync(byte currentDurability, bool expectRepair) + { + // Arrange — max = 25; ceiling(d*100/25) = ceiling(d*4) + var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false); + var item = this.CreateDamagedItem(maxDurability: 25, currentDurability: currentDurability); + 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 + if (expectRepair) + { + Assert.That(item.Durability, Is.EqualTo(25)); + } + else + { + Assert.That(item.Durability, Is.EqualTo(currentDurability)); + } + } + private async ValueTask CreateOfflinePlayerAsync() { return await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false); @@ -138,5 +218,4 @@ private Item CreateDamagedItem(byte maxDurability, byte currentDurability) Durability = currentDurability, }; } - } diff --git a/tests/MUnique.OpenMU.Tests/Offline/ZenConsumptionHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/ZenConsumptionHandlerTests.cs index e03e8f58f..7752c1ba1 100644 --- a/tests/MUnique.OpenMU.Tests/Offline/ZenConsumptionHandlerTests.cs +++ b/tests/MUnique.OpenMU.Tests/Offline/ZenConsumptionHandlerTests.cs @@ -67,10 +67,11 @@ public async ValueTask DoesNotDeductZenWhenIntervalNotPassedAsync() } /// - /// Tests that offline player stops when the player has insufficient Zen. + /// Tests that returns false + /// when the player has insufficient Zen. /// [Test] - public async ValueTask StopsOfflineLevelingWhenInsufficientZenAsync() + public async ValueTask ReturnsFalseWhenInsufficientZenAsync() { // Arrange var player = await this.CreateOfflinePlayerAsync().ConfigureAwait(false); @@ -81,10 +82,10 @@ public async ValueTask StopsOfflineLevelingWhenInsufficientZenAsync() player.StartTimestamp = DateTime.UtcNow.AddMinutes(-2); // Act - await handler.DeductZenAsync().ConfigureAwait(false); + var result = await handler.DeductZenAsync().ConfigureAwait(false); // Assert - Assert.That(player.PlayerState.CurrentState, Is.EqualTo(PlayerState.Finished)); + Assert.That(result, Is.False); } private async ValueTask CreateOfflinePlayerAsync()