// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Tests; using Moq; 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.Resets; using MUnique.OpenMU.GameLogic.Views.World; using MUnique.OpenMU.Pathfinding; /// /// Tests for . /// [TestFixture] public class ResetCharacterActionTest { /// /// Verifies that reset is rejected when required items are missing, without charging zen. /// [Test] public async Task NotEnoughItemsRejectsResetAndKeepsZenAsync() { var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); player.Attributes![Stats.Level] = 400; player.Money = 1_000; var requiredItem = new ItemDefinition { Name = "Jewel of Creation", Group = 14, Number = 22, Width = 1, Height = 1 }; var configuration = this.CreateConfiguration(requiredItem); player.GameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true); await AddRequiredItemsAsync(player, requiredItem, 1).ConfigureAwait(false); var action = new ResetCharacterAction(player, await CreateResetNpcAsync(player).ConfigureAwait(false)); await action.ResetCharacterAsync().ConfigureAwait(false); Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(0)); Assert.That(player.Money, Is.EqualTo(1_000)); Assert.That(player.Inventory!.Items.Count(i => i.Definition == requiredItem), Is.EqualTo(1)); } /// /// Verifies that reset consumes configured zen and required items when all conditions are met. /// [Test] public async Task EnoughItemsAndZenConsumesCostsOnceAsync() { var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); player.Attributes![Stats.Level] = 400; player.Money = 1_000; var requiredItem = new ItemDefinition { Name = "Jewel of Creation", Group = 14, Number = 22, Width = 1, Height = 1 }; var configuration = this.CreateConfiguration(requiredItem); player.GameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true); await AddRequiredItemsAsync(player, requiredItem, 2).ConfigureAwait(false); Assert.That(player.Level, Is.EqualTo(400)); Assert.That(player.Money, Is.EqualTo(1_000)); Assert.That( player.Inventory!.Items.Count(i => i.Definition is { } definition && definition.Group == requiredItem.Group && definition.Number == requiredItem.Number), Is.EqualTo(2)); var progression = ResetProgressionCalculator.Calculate((int)player.Attributes[Stats.Resets], (int)player.Attributes[Stats.PointsPerReset], configuration); Assert.That(progression.RequiredItemAmount, Is.EqualTo(2)); Assert.That(progression.RequiredZen, Is.EqualTo(500)); var action = new ResetCharacterAction(player, await CreateResetNpcAsync(player).ConfigureAwait(false)); await action.ResetCharacterAsync().ConfigureAwait(false); Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(1)); Assert.That(player.Money, Is.EqualTo(500)); Assert.That(player.Inventory!.Items.Count(i => i.Definition == requiredItem), Is.EqualTo(0)); Assert.That(player.SelectedCharacter!.LevelUpPoints, Is.EqualTo(800)); Assert.That((int)player.Attributes[Stats.Level], Is.EqualTo(1)); } /// /// Verifies cumulative point replacement across multiple resets when point tiers are configured. /// [Test] public async Task ReplacePointsUsesCumulativeTierTotalAcrossResetsAsync() { var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); player.Attributes![Stats.Level] = 400; player.Money = 2_000; var requiredItem = new ItemDefinition { Name = "Jewel of Creation", Group = 14, Number = 22, Width = 1, Height = 1 }; var configuration = this.CreateConfiguration(requiredItem); player.GameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true); await AddRequiredItemsAsync(player, requiredItem, 4).ConfigureAwait(false); var action = new ResetCharacterAction(player, await CreateResetNpcAsync(player).ConfigureAwait(false)); await action.ResetCharacterAsync().ConfigureAwait(false); Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(1)); Assert.That(player.SelectedCharacter!.LevelUpPoints, Is.EqualTo(800)); Assert.That(player.Money, Is.EqualTo(1_500)); player.Attributes[Stats.Level] = 400; await action.ResetCharacterAsync().ConfigureAwait(false); Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(2)); Assert.That(player.SelectedCharacter!.LevelUpPoints, Is.EqualTo(1_600)); Assert.That(player.Money, Is.EqualTo(500)); } /// /// Verifies add mode behavior when replace mode is disabled. /// [Test] public async Task TieredPointsAreAddedWhenReplaceIsDisabledAsync() { var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); player.Attributes![Stats.Level] = 400; player.Money = 1_000; player.SelectedCharacter!.LevelUpPoints = 1_000; var requiredItem = new ItemDefinition { Name = "Jewel of Creation", Group = 14, Number = 22, Width = 1, Height = 1 }; var configuration = this.CreateConfiguration(requiredItem); configuration.ReplacePointsPerReset = false; player.GameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true); await AddRequiredItemsAsync(player, requiredItem, 2).ConfigureAwait(false); var action = new ResetCharacterAction(player, await CreateResetNpcAsync(player).ConfigureAwait(false)); await action.ResetCharacterAsync().ConfigureAwait(false); Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(1)); Assert.That(player.SelectedCharacter!.LevelUpPoints, Is.EqualTo(1_800)); } /// /// Verifies that a reset with moves the player through /// the regular warp path, so the client is told about the new position. /// Writing the character's position fields directly moves the player without notifying anyone, /// which makes the character slide across the map on the client instead of respawning. /// [Test] public async Task MoveHomeWarpsPlayerSoTheClientIsNotifiedAsync() { var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); player.Attributes![Stats.Level] = 400; player.Money = 1_000; // The home map has to be one whose GameMap instance does not exist yet: the spawn gate is // resolved once, in the GameMap constructor, so a map created earlier would ignore it. var homeMap = CreateMapDefinitionWithSpawnGate(1, 125); player.GameContext.Configuration.Maps.Add(homeMap); Mock.Get(player.SelectedCharacter!.CharacterClass!).Setup(c => c.HomeMap).Returns(homeMap); var configuration = this.CreateConfiguration(null); configuration.MoveHome = true; player.GameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true); var mapChangeView = Mock.Get(player.ViewPlugIns.GetPlugIn()!); var action = new ResetCharacterAction(player); await action.ResetCharacterAsync().ConfigureAwait(false); Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(1)); Assert.That(player.Position, Is.EqualTo(new Point(125, 125))); mapChangeView.Verify(v => v.MapChangeAsync(), Times.Once); } private static GameMapDefinition CreateMapDefinitionWithSpawnGate(short number, byte spawnCoordinate) { var mapMock = new Mock(); mapMock.SetupAllProperties(); mapMock.Setup(m => m.DropItemGroups).Returns(new List()); mapMock.Setup(m => m.MonsterSpawns).Returns(new List()); mapMock.Object.TerrainData = new byte[ushort.MaxValue + 3]; mapMock.Object.Number = number; var spawnGate = new ExitGate { IsSpawnGate = true, X1 = spawnCoordinate, X2 = spawnCoordinate, Y1 = spawnCoordinate, Y2 = spawnCoordinate, Map = mapMock.Object, }; mapMock.Setup(m => m.ExitGates).Returns(new List { spawnGate }); return mapMock.Object; } private static async ValueTask AddRequiredItemsAsync(Player player, ItemDefinition requiredItem, int count) { for (byte i = 0; i < count; i++) { var item = player.PersistenceContext.CreateNew(); item.Definition = requiredItem; item.Durability = 1; var added = await player.Inventory!.AddItemAsync(item).ConfigureAwait(false); Assert.That(added, Is.True); } } private static async ValueTask CreateResetNpcAsync(Player player) { var spawnArea = new MonsterSpawnArea { X1 = 125, X2 = 125, Y1 = 125, Y2 = 125, }; var definition = new MonsterDefinition { Number = ResetCharacterNpcPlugin.ResetNpcNumber, ObjectKind = NpcObjectKind.PassiveNpc, Designation = "Reset Helper", }; var map = await player.GameContext.GetMapAsync(0).ConfigureAwait(false) ?? throw new InvalidOperationException("Could not resolve map 0 for NPC test setup."); return new NonPlayerCharacter(spawnArea, definition, map); } private ResetConfiguration CreateConfiguration(ItemDefinition? requiredItem) { return new ResetConfiguration { RequiredLevel = 400, LevelAfterReset = 1, RequiredMoney = 500, MultiplyRequiredMoneyByResetCount = true, RequiredResetItem = requiredItem, ItemCostTiers = [ new() { MinimumResetCount = 1, RequiredItemAmount = 2 }, ], PointsTiers = [ new() { MinimumResetCount = 1, PointsGranted = 800 }, ], MoveHome = false, LogOut = false, ResetStats = false, ReplacePointsPerReset = true, }; } }