Some checks failed
.NET Core / build (push) Has been cancelled
MoveHomeAsync wrote PositionX/PositionY/CurrentMap/Rotation on the character record directly, which is a partial copy of Player.PlaceAtGateAsync: it placed the player but skipped removing him from the map and telling the client. Player.Position is backed by those very fields, so the coordinates jumped on the server while the client never got a map change. The client then interpolated a walk to the new spot and the character visibly slid across the map after a reset. WarpToAsync does the same placement plus the map removal and the map change notification, and it handles respawning on the same map. It is the path every other caller uses (duel room, gate NPCs, mini games, castle siege portal). The existing tests all ran with MoveHome = false, which is why this path was never covered. The new test pins the notification: it fails on the old code because MapChangeAsync is never invoked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
249 lines
11 KiB
C#
249 lines
11 KiB
C#
// <copyright file="ResetCharacterActionTest.cs" company="MUnique">
|
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
|
// </copyright>
|
|
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="ResetCharacterAction"/>.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class ResetCharacterActionTest
|
|
{
|
|
/// <summary>
|
|
/// Verifies that reset is rejected when required items are missing, without charging zen.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that reset consumes configured zen and required items when all conditions are met.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies cumulative point replacement across multiple resets when point tiers are configured.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies add mode behavior when replace mode is disabled.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that a reset with <see cref="ResetConfiguration.MoveHome"/> 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.
|
|
/// </summary>
|
|
[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<IMapChangePlugIn>()!);
|
|
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<GameMapDefinition>();
|
|
mapMock.SetupAllProperties();
|
|
mapMock.Setup(m => m.DropItemGroups).Returns(new List<DropItemGroup>());
|
|
mapMock.Setup(m => m.MonsterSpawns).Returns(new List<MonsterSpawnArea>());
|
|
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<ExitGate> { 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>();
|
|
item.Definition = requiredItem;
|
|
item.Durability = 1;
|
|
var added = await player.Inventory!.AddItemAsync(item).ConfigureAwait(false);
|
|
Assert.That(added, Is.True);
|
|
}
|
|
}
|
|
|
|
private static async ValueTask<NonPlayerCharacter> 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,
|
|
};
|
|
}
|
|
}
|