Merge pull request #820 from nolt/feature-bots
(cherry picked from commit b10de0645a869485fbb5771a739abd06b5708c2d)
This commit is contained in:
177
tests/MUnique.OpenMU.Tests/BotEquipmentHandlerTest.cs
Normal file
177
tests/MUnique.OpenMU.Tests/BotEquipmentHandlerTest.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
// <copyright file="BotEquipmentHandlerTest.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;
|
||||
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.Bots;
|
||||
|
||||
/// <summary>
|
||||
/// Tests which gear a bot considers an upgrade (<see cref="BotEquipmentHandler.IsUpgradeFor"/>, also
|
||||
/// the pickup filter of the offline <see cref="MUnique.OpenMU.GameLogic.Offline.ItemPickupHandler"/>)
|
||||
/// and the hand rule it shares with the engine (<see cref="ItemExtensions.ConflictsWithEquippedHands"/>).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotEquipmentHandlerTest
|
||||
{
|
||||
private const byte StaffGroup = 5;
|
||||
private const byte ShieldGroup = 6;
|
||||
private const byte ArmorGroup = 8;
|
||||
|
||||
/// <summary>
|
||||
/// A better weapon of the bot's own fighting style is an upgrade worth picking up. The test player's
|
||||
/// class is energy-based, so its style is the staff (see <see cref="BotProgression.IsPreferredWeaponGroup"/>).
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask BetterWeaponIsAnUpgradeAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 10), InventoryConstants.LeftHandSlot).ConfigureAwait(false);
|
||||
var better = CreateItem(CreateDefinition(player, StaffGroup, 2, dropLevel: 40));
|
||||
|
||||
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, better), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A two-handed weapon needs the other hand: while a shield is worn, it is only worth it if it beats
|
||||
/// the weapon AND the shield it displaces. Without counting the shield, the bot took its gear off,
|
||||
/// had the equip refused by the engine (a two-hander needs the hand free), put the old weapon back on
|
||||
/// and started over - hundreds of swaps an hour, unarmed half of the time.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask TwoHandedWeaponIsNoUpgradeWhenItLosesToWeaponAndShieldAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 50), InventoryConstants.LeftHandSlot).ConfigureAwait(false);
|
||||
await WearAsync(player, CreateDefinition(player, ShieldGroup, 1, dropLevel: 40, slot: InventoryConstants.RightHandSlot), InventoryConstants.RightHandSlot).ConfigureAwait(false);
|
||||
|
||||
var twoHanded = CreateItem(CreateDefinition(player, StaffGroup, 3, dropLevel: 60, width: 2));
|
||||
|
||||
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, twoHanded), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A two-handed weapon which beats the worn weapon and shield together is worth the swap - the bot
|
||||
/// frees the hand for it, like a player would.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask TwoHandedWeaponIsAnUpgradeWhenItBeatsWeaponAndShieldAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 50), InventoryConstants.LeftHandSlot).ConfigureAwait(false);
|
||||
await WearAsync(player, CreateDefinition(player, ShieldGroup, 1, dropLevel: 40, slot: InventoryConstants.RightHandSlot), InventoryConstants.RightHandSlot).ConfigureAwait(false);
|
||||
|
||||
var twoHanded = CreateItem(CreateDefinition(player, StaffGroup, 3, dropLevel: 120, width: 2));
|
||||
|
||||
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, twoHanded), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Without a shield in the way, the same two-handed weapon is a welcome upgrade.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask TwoHandedWeaponIsAnUpgradeWithFreeOffHandAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 10), InventoryConstants.LeftHandSlot).ConfigureAwait(false);
|
||||
|
||||
var twoHanded = CreateItem(CreateDefinition(player, StaffGroup, 3, dropLevel: 60, width: 2));
|
||||
|
||||
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, twoHanded), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A weapon never goes into the free off-hand: a bot dual-wielding the junk weapons it happens to be
|
||||
/// qualified for is neither useful nor a sight a real character offers.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask JunkWeaponIsNoUpgradeForTheFreeOffHandAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 50), InventoryConstants.LeftHandSlot).ConfigureAwait(false);
|
||||
|
||||
var junk = CreateItem(CreateDefinition(player, StaffGroup, 2, dropLevel: 5));
|
||||
|
||||
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, junk), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gear the bot's class cannot wear is no upgrade, however good it is.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask UnqualifiedGearIsNoUpgradeAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var definition = CreateDefinition(player, ArmorGroup, 1, dropLevel: 80, slot: InventoryConstants.ArmorSlot);
|
||||
definition.QualifiedCharacters.Clear();
|
||||
|
||||
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, CreateItem(definition)), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An empty armor slot takes any qualified piece - that is what makes a naked bot dress itself.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask ArmorForAnEmptySlotIsAnUpgradeAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var armor = CreateItem(CreateDefinition(player, ArmorGroup, 1, dropLevel: 20, slot: InventoryConstants.ArmorSlot));
|
||||
|
||||
Assert.That(BotEquipmentHandler.IsUpgradeFor(player, armor), Is.True);
|
||||
}
|
||||
|
||||
private static ItemDefinition CreateDefinition(Player player, byte group, short number, byte dropLevel, byte width = 1, int? slot = null)
|
||||
{
|
||||
var definitionMock = new Mock<ItemDefinition>();
|
||||
definitionMock.SetupAllProperties();
|
||||
definitionMock.Setup(d => d.QualifiedCharacters).Returns(new List<CharacterClass>());
|
||||
definitionMock.Setup(d => d.PossibleItemOptions).Returns(new List<ItemOptionDefinition>());
|
||||
definitionMock.Setup(d => d.BasePowerUpAttributes).Returns(new List<ItemBasePowerUpDefinition>());
|
||||
definitionMock.Setup(d => d.Requirements).Returns(new List<AttributeRequirement>());
|
||||
|
||||
var slotTypeMock = new Mock<ItemSlotType>();
|
||||
var targetSlot = slot ?? InventoryConstants.LeftHandSlot;
|
||||
var slots = targetSlot == InventoryConstants.LeftHandSlot && group <= ShieldGroup && width < 2
|
||||
? new List<int> { InventoryConstants.LeftHandSlot, InventoryConstants.RightHandSlot }
|
||||
: new List<int> { targetSlot };
|
||||
slotTypeMock.Setup(s => s.ItemSlots).Returns(slots);
|
||||
definitionMock.Setup(d => d.ItemSlot).Returns(slotTypeMock.Object);
|
||||
|
||||
var definition = definitionMock.Object;
|
||||
definition.Group = group;
|
||||
definition.Number = number;
|
||||
definition.Width = width;
|
||||
definition.Height = 2;
|
||||
definition.Durability = 100;
|
||||
definition.DropLevel = dropLevel;
|
||||
definition.QualifiedCharacters.Add(player.SelectedCharacter!.CharacterClass!);
|
||||
player.GameContext.Configuration.Items.Add(definition);
|
||||
return definition;
|
||||
}
|
||||
|
||||
private static Item CreateItem(ItemDefinition definition)
|
||||
{
|
||||
var itemMock = new Mock<Item>();
|
||||
itemMock.SetupAllProperties();
|
||||
itemMock.Setup(i => i.ItemOptions).Returns(new List<ItemOptionLink>());
|
||||
itemMock.Setup(i => i.ItemSetGroups).Returns(new List<ItemOfItemSet>());
|
||||
var item = itemMock.Object;
|
||||
item.Definition = definition;
|
||||
item.Durability = definition.Durability;
|
||||
return item;
|
||||
}
|
||||
|
||||
private static async ValueTask<Item> WearAsync(Player player, ItemDefinition definition, byte slot)
|
||||
{
|
||||
var item = CreateItem(definition);
|
||||
await player.Inventory!.AddItemAsync(slot, item).ConfigureAwait(false);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
197
tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs
Normal file
197
tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
// <copyright file="BotJewelHandlerTest.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;
|
||||
using MUnique.OpenMU.DataModel.Configuration.Items;
|
||||
using MUnique.OpenMU.DataModel.Entities;
|
||||
using MUnique.OpenMU.GameLogic;
|
||||
using MUnique.OpenMU.GameLogic.Bots;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the jewel usage policy of <see cref="BotJewelHandler"/> - which jewel a bot picks for which
|
||||
/// equipped item; the actual consumption goes through the regular consume handlers and is covered by
|
||||
/// <see cref="ItemConsumptionTest"/>.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotJewelHandlerTest
|
||||
{
|
||||
private const byte FirstBackpackSlot = 12;
|
||||
|
||||
/// <summary>
|
||||
/// A Bless in stock and an equipped piece below +6: the weakest piece is chosen for the Bless.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask PrefersBlessOnWeakestUpgradeableItemAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
|
||||
// The stronger piece is the one the bot must NOT pick, so it only has to be there.
|
||||
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 4).ConfigureAwait(false);
|
||||
var weakPiece = await AddEquippedItemAsync(player, InventoryConstants.RightHandSlot, 2).ConfigureAwait(false);
|
||||
var bless = await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfBless).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, false);
|
||||
|
||||
Assert.That(plan, Is.Not.Null);
|
||||
Assert.That(plan!.Value.Jewel, Is.SameAs(bless));
|
||||
Assert.That(plan.Value.Target, Is.SameAs(weakPiece));
|
||||
Assert.That(plan.Value.IsLife, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All gear at +6 or above: a Soul is only risked with a spare in stock.
|
||||
/// </summary>
|
||||
/// <param name="soulCount">The number of souls in the backpack.</param>
|
||||
/// <param name="expectsUse">Whether a jewel use is expected.</param>
|
||||
[TestCase(1, false)]
|
||||
[TestCase(2, true)]
|
||||
public async ValueTask RisksSoulOnlyWithSpareStockAsync(int soulCount, bool expectsUse)
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 6).ConfigureAwait(false);
|
||||
for (var i = 0; i < soulCount; i++)
|
||||
{
|
||||
await AddJewelAsync(player, (byte)(FirstBackpackSlot + i), ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, false);
|
||||
|
||||
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Soul prefers a lucky target (its success bonus) over a lower-level one without luck.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask SoulPrefersLuckyItemAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 6).ConfigureAwait(false);
|
||||
var luckyPiece = await AddEquippedItemAsync(player, InventoryConstants.RightHandSlot, 7, withLuck: true).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, false);
|
||||
|
||||
Assert.That(plan, Is.Not.Null);
|
||||
Assert.That(plan!.Value.Target, Is.SameAs(luckyPiece));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Without luck the Soul risk stops at +6 (a failure from +7 on resets the item to +0), so only
|
||||
/// lucky items may be pushed further - up to the jewel ceiling of +9.
|
||||
/// </summary>
|
||||
/// <param name="itemLevel">The level of the equipped item.</param>
|
||||
/// <param name="withLuck">Whether the equipped item has luck.</param>
|
||||
/// <param name="expectsUse">Whether a jewel use is expected.</param>
|
||||
[TestCase(7, false, false)]
|
||||
[TestCase(7, true, true)]
|
||||
[TestCase(8, true, true)]
|
||||
[TestCase(9, true, false)]
|
||||
public async ValueTask RisksSoulAbovePlusSixOnlyWithLuckAsync(byte itemLevel, bool withLuck, bool expectsUse)
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, itemLevel, withLuck).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false);
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, false);
|
||||
|
||||
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Life is the last resort and is planned at most once per trip.
|
||||
/// </summary>
|
||||
/// <param name="lifeAlreadyUsed">Whether a life was already used within the trip.</param>
|
||||
/// <param name="expectsUse">Whether a jewel use is expected.</param>
|
||||
[TestCase(false, true)]
|
||||
[TestCase(true, false)]
|
||||
public async ValueTask UsesLifeAtMostOncePerTripAsync(bool lifeAlreadyUsed, bool expectsUse)
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var target = await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 9).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfLife).ConfigureAwait(false);
|
||||
await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfLife).ConfigureAwait(false);
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, lifeAlreadyUsed);
|
||||
|
||||
Assert.That(plan.HasValue, Is.EqualTo(expectsUse));
|
||||
if (expectsUse)
|
||||
{
|
||||
Assert.That(plan!.Value.Target, Is.SameAs(target));
|
||||
Assert.That(plan.Value.IsLife, Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Without any applicable jewel or target, nothing is planned.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask PlansNothingWithoutJewelsAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 2).ConfigureAwait(false);
|
||||
|
||||
var plan = BotJewelHandler.PlanNextUse(player, false);
|
||||
|
||||
Assert.That(plan, Is.Null);
|
||||
}
|
||||
|
||||
private static async ValueTask<Item> AddEquippedItemAsync(Player player, byte slot, byte level, bool withLuck = false)
|
||||
{
|
||||
var item = new Mock<Item>();
|
||||
item.SetupAllProperties();
|
||||
var itemOptions = new List<ItemOptionLink>();
|
||||
item.Setup(i => i.ItemOptions).Returns(itemOptions);
|
||||
item.Setup(i => i.ItemSetGroups).Returns(new List<ItemOfItemSet>());
|
||||
var definition = new Mock<ItemDefinition>();
|
||||
definition.SetupAllProperties();
|
||||
var itemSlot = new Mock<ItemSlotType>();
|
||||
itemSlot.Setup(s => s.ItemSlots).Returns(new List<int> { slot });
|
||||
definition.Setup(d => d.ItemSlot).Returns(itemSlot.Object);
|
||||
item.Object.Definition = definition.Object;
|
||||
item.Object.Definition.Width = 1;
|
||||
item.Object.Definition.Height = 1;
|
||||
item.Object.Definition.MaximumItemLevel = 15;
|
||||
item.Object.Definition.Durability = 1;
|
||||
item.Object.Durability = 1;
|
||||
item.Object.Level = level;
|
||||
|
||||
if (withLuck)
|
||||
{
|
||||
var optionLink = new Mock<ItemOptionLink>();
|
||||
optionLink.SetupAllProperties();
|
||||
var option = new Mock<IncreasableItemOption>();
|
||||
option.SetupAllProperties();
|
||||
option.Object.OptionType = ItemOptionTypes.Luck;
|
||||
optionLink.Object.ItemOption = option.Object;
|
||||
itemOptions.Add(optionLink.Object);
|
||||
}
|
||||
|
||||
await player.Inventory!.AddItemAsync(slot, item.Object).ConfigureAwait(false);
|
||||
return item.Object;
|
||||
}
|
||||
|
||||
private static async ValueTask<Item> AddJewelAsync(Player player, int slot, ItemIdentifier identifier)
|
||||
{
|
||||
var jewel = new Item
|
||||
{
|
||||
Definition = new ItemDefinition
|
||||
{
|
||||
Number = identifier.Number ?? 0,
|
||||
Group = identifier.Group,
|
||||
Width = 1,
|
||||
Height = 1,
|
||||
},
|
||||
Durability = 1,
|
||||
};
|
||||
await player.Inventory!.AddItemAsync((byte)slot, jewel).ConfigureAwait(false);
|
||||
return jewel;
|
||||
}
|
||||
}
|
||||
175
tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs
Normal file
175
tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
// <copyright file="BotMiniGameHandlerTest.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 MUnique.OpenMU.DataModel.Configuration;
|
||||
using MUnique.OpenMU.DataModel.Entities;
|
||||
using MUnique.OpenMU.GameLogic;
|
||||
using MUnique.OpenMU.GameLogic.Attributes;
|
||||
using MUnique.OpenMU.GameLogic.Bots;
|
||||
using MUnique.OpenMU.GameLogic.Offline;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the entry decisions of <see cref="BotMiniGameHandler"/> - which party bots may follow
|
||||
/// their human leader into a mini game event, and who is taken along at all. The actual entry
|
||||
/// goes through the regular <see cref="MUnique.OpenMU.GameLogic.MiniGames.MiniGameContext"/>.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotMiniGameHandlerTest
|
||||
{
|
||||
/// <summary>
|
||||
/// The event's character level bracket is enforced in both directions.
|
||||
/// </summary>
|
||||
/// <param name="level">The bot's character level.</param>
|
||||
/// <param name="expected">Whether the bot qualifies.</param>
|
||||
[TestCase(200, false)]
|
||||
[TestCase(281, true)]
|
||||
[TestCase(330, true)]
|
||||
[TestCase(331, false)]
|
||||
public async ValueTask EnforcesLevelBracketAsync(int level, bool expected)
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext).ConfigureAwait(false);
|
||||
bot.Attributes![Stats.Level] = level;
|
||||
var definition = new MiniGameDefinition { MinimumCharacterLevel = 281, MaximumCharacterLevel = 330 };
|
||||
|
||||
var eligible = BotMiniGameHandler.IsEligible(bot, definition, out var reason);
|
||||
|
||||
Assert.That(eligible, Is.EqualTo(expected));
|
||||
Assert.That(reason, expected ? Is.Empty : Is.Not.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The special characters (Magic Gladiator, Dark Lord, Rage Fighter, Summoner) enter in their own
|
||||
/// level bracket, exactly like they do for a player: a qualified Magic Gladiator must not be judged
|
||||
/// - and kicked out of its leader's party - by the bracket of the regular classes.
|
||||
/// </summary>
|
||||
/// <param name="level">The bot's character level.</param>
|
||||
/// <param name="expected">Whether the bot qualifies.</param>
|
||||
[TestCase(200, false)]
|
||||
[TestCase(221, true)]
|
||||
[TestCase(280, true)]
|
||||
[TestCase(281, false)]
|
||||
public async ValueTask EnforcesSpecialCharacterLevelBracketAsync(int level, bool expected)
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext).ConfigureAwait(false);
|
||||
bot.Attributes![Stats.Level] = level;
|
||||
|
||||
// That is what makes a character "special" for the entry rules (see CharacterExtensions).
|
||||
bot.SelectedCharacter!.CharacterClass!.LevelWarpRequirementReductionPercent = 50;
|
||||
var definition = new MiniGameDefinition
|
||||
{
|
||||
MinimumCharacterLevel = 281,
|
||||
MaximumCharacterLevel = 330,
|
||||
MinimumSpecialCharacterLevel = 221,
|
||||
MaximumSpecialCharacterLevel = 280,
|
||||
};
|
||||
|
||||
var eligible = BotMiniGameHandler.IsEligible(bot, definition, out _);
|
||||
|
||||
Assert.That(eligible, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An event for master classes only is not entered before the bot's master evolution.
|
||||
/// </summary>
|
||||
/// <param name="isMasterClass">Whether the bot evolved into its master class.</param>
|
||||
/// <param name="expected">Whether the bot qualifies.</param>
|
||||
[TestCase(false, false)]
|
||||
[TestCase(true, true)]
|
||||
public async ValueTask EnforcesMasterClassRequirementAsync(bool isMasterClass, bool expected)
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext).ConfigureAwait(false);
|
||||
bot.Attributes![Stats.Level] = 400;
|
||||
bot.SelectedCharacter!.CharacterClass!.IsMasterClass = isMasterClass;
|
||||
var definition = new MiniGameDefinition { MinimumCharacterLevel = 0, MaximumCharacterLevel = 400, RequiresMasterClass = true };
|
||||
|
||||
var eligible = BotMiniGameHandler.IsEligible(bot, definition, out _);
|
||||
|
||||
Assert.That(eligible, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A player killer bot (should never happen, but the rule is mirrored from the player entry)
|
||||
/// cannot enter events which disallow player killers.
|
||||
/// </summary>
|
||||
/// <param name="killersAllowed">Whether the event allows player killers.</param>
|
||||
/// <param name="expected">Whether the bot qualifies.</param>
|
||||
[TestCase(false, false)]
|
||||
[TestCase(true, true)]
|
||||
public async ValueTask EnforcesPlayerKillerRestrictionAsync(bool killersAllowed, bool expected)
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext).ConfigureAwait(false);
|
||||
bot.Attributes![Stats.Level] = 100;
|
||||
bot.SelectedCharacter!.State = HeroState.PlayerKiller1stStage;
|
||||
var definition = new MiniGameDefinition { MinimumCharacterLevel = 0, MaximumCharacterLevel = 400, ArePlayerKillersAllowedToEnter = killersAllowed };
|
||||
|
||||
var eligible = BotMiniGameHandler.IsEligible(bot, definition, out _);
|
||||
|
||||
Assert.That(eligible, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only the bots of the party whose MASTER enters are taken along - and only the bots, not the
|
||||
/// human members.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask SnapshotTakesOnlyBotsOfTheEnteringMasterAsync()
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var leader = await CreateHumanAsync(gameContext, "Leader").ConfigureAwait(false);
|
||||
var member = await CreateHumanAsync(gameContext, "Member").ConfigureAwait(false);
|
||||
var bot1 = await CreateBotAsync(gameContext, "BotOne").ConfigureAwait(false);
|
||||
var bot2 = await CreateBotAsync(gameContext, "BotTwo").ConfigureAwait(false);
|
||||
|
||||
var party = gameContext.PartyManager.CreateParty();
|
||||
await party.AddAsync(leader).ConfigureAwait(false);
|
||||
await party.AddAsync(member).ConfigureAwait(false);
|
||||
await party.AddAsync(bot1).ConfigureAwait(false);
|
||||
await party.AddAsync(bot2).ConfigureAwait(false);
|
||||
|
||||
var fromLeader = BotMiniGameHandler.SnapshotPartyBots(leader);
|
||||
var fromMember = BotMiniGameHandler.SnapshotPartyBots(member);
|
||||
|
||||
Assert.That(fromLeader, Is.EquivalentTo(new[] { bot1, bot2 }));
|
||||
Assert.That(fromMember, Is.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A player without a party (or a bot, however it would get here) takes nobody along.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask SnapshotIsEmptyWithoutPartyAsync()
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var solo = await CreateHumanAsync(gameContext, "Solo").ConfigureAwait(false);
|
||||
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
|
||||
|
||||
Assert.That(BotMiniGameHandler.SnapshotPartyBots(solo), Is.Empty);
|
||||
Assert.That(BotMiniGameHandler.SnapshotPartyBots(bot), Is.Empty);
|
||||
}
|
||||
|
||||
private static async ValueTask<OfflinePlayer> CreateBotAsync(IGameContext gameContext, string name = "Bot")
|
||||
{
|
||||
var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false);
|
||||
await bot.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
|
||||
bot.SelectedCharacter!.Name = name;
|
||||
bot.IsAlive = true;
|
||||
bot.Account!.IsBot = true;
|
||||
return bot;
|
||||
}
|
||||
|
||||
private static async ValueTask<Player> CreateHumanAsync(IGameContext gameContext, string name)
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
|
||||
await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
|
||||
player.SelectedCharacter!.Name = name;
|
||||
player.IsAlive = true;
|
||||
return player;
|
||||
}
|
||||
}
|
||||
49
tests/MUnique.OpenMU.Tests/BotSelfHealingTest.cs
Normal file
49
tests/MUnique.OpenMU.Tests/BotSelfHealingTest.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
// <copyright file="BotSelfHealingTest.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 MUnique.OpenMU.GameLogic.Bots;
|
||||
|
||||
/// <summary>
|
||||
/// Tests how a bot reacts to its AI failing: the engine's attribute system is not thread-safe, and a
|
||||
/// lost race can corrupt a character's attribute graph for good - from then on every tick throws, the
|
||||
/// bot stops playing and floods the log. It asks for a restart, which rebuilds the graph and heals it.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotSelfHealingTest
|
||||
{
|
||||
/// <summary>
|
||||
/// A tick failing now and then is simply skipped, like before - no restart.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SingleFailuresDoNotRestartTheBot()
|
||||
{
|
||||
var bot = new BotPlayer(GameContextTestHelper.CreateGameContext());
|
||||
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
bot.OnAiTickFailed();
|
||||
bot.OnAiTickSucceeded();
|
||||
}
|
||||
|
||||
Assert.That(bot.AwaitsFaultRestart, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bot whose ticks keep failing is broken and asks the maintenance pass to restart it.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void PersistentFailuresRestartTheBot()
|
||||
{
|
||||
var bot = new BotPlayer(GameContextTestHelper.CreateGameContext());
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
bot.OnAiTickFailed();
|
||||
}
|
||||
|
||||
Assert.That(bot.AwaitsFaultRestart, Is.True);
|
||||
}
|
||||
}
|
||||
123
tests/MUnique.OpenMU.Tests/BotServerPartitionTest.cs
Normal file
123
tests/MUnique.OpenMU.Tests/BotServerPartitionTest.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
// <copyright file="BotServerPartitionTest.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 MUnique.OpenMU.GameLogic.Bots;
|
||||
|
||||
/// <summary>
|
||||
/// Tests how the bot population is split over the game servers of a deployment
|
||||
/// (<see cref="BotServerPartition"/>): bots count towards the player limit of their server, so a server
|
||||
/// must never animate more of them than its reserved share allows - and the servers must agree on who
|
||||
/// animates whom without asking each other.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotServerPartitionTest
|
||||
{
|
||||
/// <summary>
|
||||
/// A single game server animates the accounts which fit into its share; the rest stays offline
|
||||
/// instead of filling the server up, because the remaining capacity belongs to the players.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SingleServerTakesWhatFits()
|
||||
{
|
||||
var (partition, assigned) = BotServerPartition.Split([(0, 120)], 0, 220);
|
||||
|
||||
Assert.That(partition.FirstAccount, Is.EqualTo(1));
|
||||
Assert.That(partition.AccountCount, Is.EqualTo(120));
|
||||
Assert.That(partition.IsGenerator, Is.True);
|
||||
Assert.That(assigned, Is.EqualTo(120));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The scenario the split is made for: one server was crowded, a second one is added, and the
|
||||
/// population spreads over BOTH of them - a player who picks the new server meets bots there, too.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void PopulationSpreadsOverBothServers()
|
||||
{
|
||||
List<(byte ServerId, int Capacity)> capacities = [(0, 120), (1, 120)];
|
||||
|
||||
var (first, assigned) = BotServerPartition.Split(capacities, 0, 220);
|
||||
var (second, _) = BotServerPartition.Split(capacities, 1, 220);
|
||||
|
||||
Assert.That(assigned, Is.EqualTo(220));
|
||||
Assert.That(first.AccountCount, Is.EqualTo(110));
|
||||
Assert.That(second.AccountCount, Is.EqualTo(110));
|
||||
Assert.That(second.FirstAccount, Is.EqualTo(111));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The invariant which protects the characters: every account is animated by exactly one server.
|
||||
/// Two servers animating one account is the cross-context situation which corrupts it.
|
||||
/// </summary>
|
||||
/// <param name="requestedAccounts">The configured number of bot accounts.</param>
|
||||
[TestCase(1)]
|
||||
[TestCase(7)]
|
||||
[TestCase(220)]
|
||||
[TestCase(1000)]
|
||||
public void EveryAccountIsAnimatedExactlyOnce(int requestedAccounts)
|
||||
{
|
||||
// Deliberately uneven capacities, so the rounding of the shares is exercised.
|
||||
List<(byte ServerId, int Capacity)> capacities = [(0, 37), (1, 90), (2, 113)];
|
||||
var partitions = capacities
|
||||
.Select(server => BotServerPartition.Split(capacities, server.ServerId, requestedAccounts))
|
||||
.ToList();
|
||||
var assigned = partitions[0].AssignedAccounts;
|
||||
|
||||
Assert.That(partitions.Sum(p => p.Partition.AccountCount), Is.EqualTo(assigned));
|
||||
for (var account = 1; account <= assigned; account++)
|
||||
{
|
||||
Assert.That(partitions.Count(p => p.Partition.Owns(account)), Is.EqualTo(1), $"account {account}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exactly one server generates the population, so the accounts - and their unique character names -
|
||||
/// are never created twice at the same time.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void OnlyTheFirstServerGenerates()
|
||||
{
|
||||
List<(byte ServerId, int Capacity)> capacities = [(0, 50), (1, 50), (2, 50)];
|
||||
|
||||
Assert.That(BotServerPartition.Split(capacities, 0, 150).Partition.IsGenerator, Is.True);
|
||||
Assert.That(BotServerPartition.Split(capacities, 1, 150).Partition.IsGenerator, Is.False);
|
||||
Assert.That(BotServerPartition.Split(capacities, 2, 150).Partition.IsGenerator, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The servers may have different player limits; the shares follow their capacity.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SharesFollowTheServerCapacity()
|
||||
{
|
||||
List<(byte ServerId, int Capacity)> capacities = [(0, 30), (1, 90)];
|
||||
|
||||
var (small, _) = BotServerPartition.Split(capacities, 0, 120);
|
||||
var (big, _) = BotServerPartition.Split(capacities, 1, 120);
|
||||
|
||||
Assert.That(small.AccountCount, Is.EqualTo(30));
|
||||
Assert.That(big.AccountCount, Is.EqualTo(90));
|
||||
Assert.That(big.FirstAccount, Is.EqualTo(31));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A server without any bot capacity (its player limit is reserved for players entirely) animates
|
||||
/// nothing, and the other servers still cover the whole population.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ServerWithoutCapacityAnimatesNothing()
|
||||
{
|
||||
List<(byte ServerId, int Capacity)> capacities = [(0, 100), (1, 0)];
|
||||
|
||||
var (empty, assigned) = BotServerPartition.Split(capacities, 1, 60);
|
||||
|
||||
Assert.That(empty.AccountCount, Is.EqualTo(0));
|
||||
Assert.That(empty.IsGenerator, Is.False);
|
||||
Assert.That(empty.Owns(1), Is.False);
|
||||
Assert.That(assigned, Is.EqualTo(60));
|
||||
Assert.That(BotServerPartition.Split(capacities, 0, 60).Partition.AccountCount, Is.EqualTo(60));
|
||||
}
|
||||
}
|
||||
225
tests/MUnique.OpenMU.Tests/BotWingHandlerTest.cs
Normal file
225
tests/MUnique.OpenMU.Tests/BotWingHandlerTest.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
// <copyright file="BotWingHandlerTest.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.AttributeSystem;
|
||||
using MUnique.OpenMU.DataModel;
|
||||
using MUnique.OpenMU.DataModel.Attributes;
|
||||
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.Bots;
|
||||
|
||||
/// <summary>
|
||||
/// Tests the wing milestone policy of <see cref="BotWingHandler"/> - which wings a bot has earned
|
||||
/// at which level; the creation and equipping go through the regular persistence context and
|
||||
/// <see cref="MUnique.OpenMU.GameLogic.PlayerActions.Items.MoveItemAction"/>.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotWingHandlerTest
|
||||
{
|
||||
private const short FirstTierWingNumber = 2;
|
||||
private const short SecondTierWingNumber = 5;
|
||||
private const short ThirdTierWingNumber = 36;
|
||||
|
||||
/// <summary>The Cape of Lord lives in group 13, unlike all other wings (group 12).</summary>
|
||||
private const short CapeNumber = 30;
|
||||
private const byte CapeGroup = 13;
|
||||
|
||||
/// <summary>
|
||||
/// Below the first milestone no wings are due.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask PlansNothingBelowFirstMilestoneAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
AddWingDefinition(player, FirstTierWingNumber, Stats.PhysicalBaseDmg);
|
||||
player.Attributes![Stats.Level] = 179;
|
||||
|
||||
var plan = BotWingHandler.PlanNextGrant(player);
|
||||
|
||||
Assert.That(plan, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// At the milestones the earned tier is granted with the agreed item level and option level.
|
||||
/// </summary>
|
||||
/// <param name="level">The character level.</param>
|
||||
/// <param name="expectedNumber">The expected wing number.</param>
|
||||
/// <param name="expectedItemLevel">The expected item level of the grant.</param>
|
||||
/// <param name="expectedOptionLevel">The expected level of the wing option.</param>
|
||||
[TestCase(180, FirstTierWingNumber, 0, 3)]
|
||||
[TestCase(280, SecondTierWingNumber, 9, 4)]
|
||||
[TestCase(400, ThirdTierWingNumber, 15, 4)]
|
||||
public async ValueTask GrantsEarnedTierAtMilestoneAsync(int level, short expectedNumber, byte expectedItemLevel, int expectedOptionLevel)
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
AddWingDefinition(player, FirstTierWingNumber, Stats.PhysicalBaseDmg);
|
||||
AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg);
|
||||
AddWingDefinition(player, ThirdTierWingNumber, Stats.PhysicalBaseDmg);
|
||||
player.Attributes![Stats.Level] = level;
|
||||
|
||||
var plan = BotWingHandler.PlanNextGrant(player);
|
||||
|
||||
Assert.That(plan, Is.Not.Null);
|
||||
Assert.That(plan!.Value.Definition.Number, Is.EqualTo(expectedNumber));
|
||||
Assert.That(plan.Value.ItemLevel, Is.EqualTo(expectedItemLevel));
|
||||
Assert.That(plan.Value.OptionLevel, Is.EqualTo(expectedOptionLevel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bot re-levelling through the lower milestones after a reset keeps its better wings.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask NeverDowngradesWornWingsAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
AddWingDefinition(player, FirstTierWingNumber, Stats.PhysicalBaseDmg);
|
||||
var secondTier = AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg);
|
||||
await WearWingsAsync(player, secondTier, 9).ConfigureAwait(false);
|
||||
player.Attributes![Stats.Level] = 200;
|
||||
|
||||
var plan = BotWingHandler.PlanNextGrant(player);
|
||||
|
||||
Assert.That(plan, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wearing the earned tier already: nothing to do.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask PlansNothingWhenEarnedTierIsWornAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var secondTier = AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg);
|
||||
await WearWingsAsync(player, secondTier, 9).ConfigureAwait(false);
|
||||
player.Attributes![Stats.Level] = 300;
|
||||
|
||||
var plan = BotWingHandler.PlanNextGrant(player);
|
||||
|
||||
Assert.That(plan, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bot which did not evolve into its master class yet is not qualified for the third tier
|
||||
/// wings and falls back to the best qualified tier.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask FallsBackWhenThirdTierIsNotQualifiedAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg);
|
||||
var thirdTier = AddWingDefinition(player, ThirdTierWingNumber, Stats.PhysicalBaseDmg);
|
||||
thirdTier.QualifiedCharacters.Clear();
|
||||
player.Attributes![Stats.Level] = 400;
|
||||
|
||||
var plan = BotWingHandler.PlanNextGrant(player);
|
||||
|
||||
Assert.That(plan, Is.Not.Null);
|
||||
Assert.That(plan!.Value.Definition.Number, Is.EqualTo(SecondTierWingNumber));
|
||||
Assert.That(plan.Value.Tier, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The capes are the only pre-master wing of their classes: granted at the first milestone at +0
|
||||
/// and re-granted as a fresh +9 cape at the second one.
|
||||
/// </summary>
|
||||
/// <param name="wornCapeLevel">The item level of the worn cape.</param>
|
||||
/// <param name="expectsGrant">Whether a new cape is expected.</param>
|
||||
[TestCase(0, true)]
|
||||
[TestCase(9, false)]
|
||||
public async ValueTask RegrantsCapeAtSecondMilestoneAsync(byte wornCapeLevel, bool expectsGrant)
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var cape = AddWingDefinition(player, CapeNumber, Stats.PhysicalBaseDmg, CapeGroup);
|
||||
await WearWingsAsync(player, cape, wornCapeLevel).ConfigureAwait(false);
|
||||
player.Attributes![Stats.Level] = 280;
|
||||
|
||||
var plan = BotWingHandler.PlanNextGrant(player);
|
||||
|
||||
Assert.That(plan.HasValue, Is.EqualTo(expectsGrant));
|
||||
if (expectsGrant)
|
||||
{
|
||||
Assert.That(plan!.Value.Definition, Is.SameAs(cape));
|
||||
Assert.That(plan.Value.ItemLevel, Is.EqualTo(9));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When a class qualifies for more than one pair (the Magic Gladiator may wear both Wings of
|
||||
/// Heaven and Satan), the pair whose option matches the fighting style wins.
|
||||
/// </summary>
|
||||
/// <param name="baseEnergy">The bot's base energy (base strength is 28).</param>
|
||||
/// <param name="expectedNumber">The expected wing number.</param>
|
||||
[TestCase(200, 1)]
|
||||
[TestCase(0, 2)]
|
||||
public async ValueTask PrefersWingsMatchingFightingStyleAsync(int baseEnergy, short expectedNumber)
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
AddWingDefinition(player, 1, Stats.WizardryBaseDmg);
|
||||
AddWingDefinition(player, 2, Stats.PhysicalBaseDmg);
|
||||
player.Attributes![Stats.BaseEnergy] = baseEnergy;
|
||||
player.Attributes[Stats.Level] = 180;
|
||||
|
||||
var plan = BotWingHandler.PlanNextGrant(player);
|
||||
|
||||
Assert.That(plan, Is.Not.Null);
|
||||
Assert.That(plan!.Value.Definition.Number, Is.EqualTo(expectedNumber));
|
||||
}
|
||||
|
||||
private static ItemDefinition AddWingDefinition(Player player, short number, AttributeDefinition optionTarget, byte group = 12)
|
||||
{
|
||||
var definitionMock = new Mock<ItemDefinition>();
|
||||
definitionMock.SetupAllProperties();
|
||||
definitionMock.Setup(d => d.QualifiedCharacters).Returns(new List<CharacterClass>());
|
||||
definitionMock.Setup(d => d.PossibleItemOptions).Returns(new List<ItemOptionDefinition>());
|
||||
var slotType = new Mock<ItemSlotType>();
|
||||
slotType.Setup(s => s.ItemSlots).Returns(new List<int> { InventoryConstants.WingsSlot });
|
||||
definitionMock.Setup(d => d.ItemSlot).Returns(slotType.Object);
|
||||
|
||||
var definition = definitionMock.Object;
|
||||
definition.Group = group;
|
||||
definition.Number = number;
|
||||
definition.Width = 5;
|
||||
definition.Height = 3;
|
||||
definition.Durability = 200;
|
||||
definition.MaximumItemLevel = 15;
|
||||
definition.QualifiedCharacters.Add(player.SelectedCharacter!.CharacterClass!);
|
||||
|
||||
var optionDefinitionMock = new Mock<ItemOptionDefinition>();
|
||||
optionDefinitionMock.SetupAllProperties();
|
||||
optionDefinitionMock.Setup(o => o.PossibleOptions).Returns(new List<IncreasableItemOption>());
|
||||
var optionMock = new Mock<IncreasableItemOption>();
|
||||
optionMock.SetupAllProperties();
|
||||
var powerUpMock = new Mock<PowerUpDefinition>();
|
||||
powerUpMock.SetupAllProperties();
|
||||
powerUpMock.Object.TargetAttribute = optionTarget;
|
||||
optionMock.Object.OptionType = ItemOptionTypes.Option;
|
||||
optionMock.Object.PowerUpDefinition = powerUpMock.Object;
|
||||
optionDefinitionMock.Object.PossibleOptions.Add(optionMock.Object);
|
||||
definition.PossibleItemOptions.Add(optionDefinitionMock.Object);
|
||||
|
||||
player.GameContext.Configuration.Items.Add(definition);
|
||||
return definition;
|
||||
}
|
||||
|
||||
private static async ValueTask<Item> WearWingsAsync(Player player, ItemDefinition definition, byte level)
|
||||
{
|
||||
var itemMock = new Mock<Item>();
|
||||
itemMock.SetupAllProperties();
|
||||
itemMock.Setup(i => i.ItemOptions).Returns(new List<ItemOptionLink>());
|
||||
itemMock.Setup(i => i.ItemSetGroups).Returns(new List<ItemOfItemSet>());
|
||||
var item = itemMock.Object;
|
||||
item.Definition = definition;
|
||||
item.Level = level;
|
||||
item.Durability = definition.Durability;
|
||||
|
||||
await player.Inventory!.AddItemAsync(InventoryConstants.WingsSlot, item).ConfigureAwait(false);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
267
tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs
Normal file
267
tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
// <copyright file="BotMasterHandlerTests.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.Tests.Offline;
|
||||
|
||||
using Moq;
|
||||
using MUnique.OpenMU.DataModel.Configuration;
|
||||
using MUnique.OpenMU.DataModel.Entities;
|
||||
using MUnique.OpenMU.GameLogic;
|
||||
using MUnique.OpenMU.GameLogic.Attributes;
|
||||
using MUnique.OpenMU.GameLogic.Bots;
|
||||
using MUnique.OpenMU.GameLogic.Resets;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="BotMasterHandler"/>: when a bot evolves into its master class (including the
|
||||
/// iron rule of reset servers) and which master skill it invests its points into. The point investment
|
||||
/// itself goes through the regular <see cref="MUnique.OpenMU.GameLogic.PlayerActions.Character.AddMasterPointAction"/>,
|
||||
/// whose rules are covered by <see cref="MasterSystemTest"/>.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotMasterHandlerTests
|
||||
{
|
||||
private IGameContext _gameContext = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a fresh game context with the usual maximum level before each test.
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
this._gameContext = GameContextTestHelper.CreateGameContext();
|
||||
this._gameContext.Configuration.MaximumLevel = 400;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Without the reset feature the evolution is due exactly at the game's maximum level.
|
||||
/// </summary>
|
||||
/// <param name="level">The character level.</param>
|
||||
/// <param name="expectsDue">Whether the evolution is expected to be due.</param>
|
||||
[TestCase(399, false)]
|
||||
[TestCase(400, true)]
|
||||
public async ValueTask EvolutionIsDueAtMaximumLevelAsync(int level, bool expectsDue)
|
||||
{
|
||||
var player = await this.CreatePlayerWithMasterTargetAsync().ConfigureAwait(false);
|
||||
player.Attributes![Stats.Level] = level;
|
||||
|
||||
Assert.That(BotMasterHandler.IsMasterEvolutionDue(player), Is.EqualTo(expectsDue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A class which is already a master (or has no master target) never evolves again.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask NoEvolutionWithoutMasterTargetAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
|
||||
player.Attributes![Stats.Level] = 400;
|
||||
|
||||
Assert.That(BotMasterHandler.IsMasterEvolutionDue(player), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The iron rule of reset servers: the evolution is only due once the reset limit is exhausted,
|
||||
/// and with no limit configured (resetting forever is the endgame) it is never due at all.
|
||||
/// Uses the plain test context, where the added feature plugin is the effective one (see the
|
||||
/// remarks at <see cref="BotResetHandlerTests.EffectiveLevelCountsResetsAsLevelSpansAsync"/>).
|
||||
/// </summary>
|
||||
/// <param name="resetLimit">The configured reset limit; 0 means no limit.</param>
|
||||
/// <param name="resets">The bot's performed resets.</param>
|
||||
/// <param name="expectsDue">Whether the evolution is expected to be due.</param>
|
||||
[TestCase(3, 2, false)]
|
||||
[TestCase(3, 3, true)]
|
||||
[TestCase(0, 50, false)]
|
||||
public async ValueTask EvolutionOnResetServersOnlyAfterLastResetAsync(int resetLimit, int resets, bool expectsDue)
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
player.GameContext.Configuration.MaximumLevel = 400;
|
||||
GiveMasterTarget(player);
|
||||
player.GameContext.FeaturePlugIns.AddPlugIn(
|
||||
new ResetFeaturePlugIn { Configuration = new ResetConfiguration { RequiredLevel = 400, ResetLimit = resetLimit } },
|
||||
true);
|
||||
player.Attributes![Stats.Level] = 400;
|
||||
player.Attributes[Stats.Resets] = resets;
|
||||
|
||||
Assert.That(BotMasterHandler.IsMasterEvolutionDue(player), Is.EqualTo(expectsDue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A due evolution assigns the master class - the same assignment the master quest performs.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask EvolutionAssignsMasterClassAsync()
|
||||
{
|
||||
var player = await this.CreatePlayerWithMasterTargetAsync().ConfigureAwait(false);
|
||||
var masterClass = player.SelectedCharacter!.CharacterClass!.NextGenerationClass!;
|
||||
player.Attributes![Stats.Level] = 400;
|
||||
|
||||
var evolved = await BotMasterHandler.TryEvolveAsync(player).ConfigureAwait(false);
|
||||
|
||||
Assert.That(evolved, Is.True);
|
||||
Assert.That(player.SelectedCharacter!.CharacterClass, Is.SameAs(masterClass));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The point spending loop learns the picked skill through the regular action and invests all
|
||||
/// available points.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask SpendsPointsThroughRegularActionAsync()
|
||||
{
|
||||
// The plain test context is required here - its configuration accepts the mocked skills.
|
||||
var contextDonor = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(contextDonor.GameContext).ConfigureAwait(false);
|
||||
player.SelectedCharacter!.CharacterClass!.IsMasterClass = true;
|
||||
var skill = this.CreateMasterSkill(1, rank: 1, player.SelectedCharacter.CharacterClass);
|
||||
player.GameContext.Configuration.Skills.Add(skill);
|
||||
player.SelectedCharacter.MasterLevelUpPoints = 3;
|
||||
|
||||
await BotMasterHandler.TrySpendMasterPointsAsync(player).ConfigureAwait(false);
|
||||
|
||||
Assert.That(player.SelectedCharacter.MasterLevelUpPoints, Is.Zero);
|
||||
var learned = player.SelectedCharacter.LearnedSkills.FirstOrDefault(l => l.Skill == skill);
|
||||
Assert.That(learned, Is.Not.Null);
|
||||
Assert.That(learned!.Level, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A started skill is pushed to the rank-unlock level of 10 before anything new is learned.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask FinishesStartedSkillBeforeLearningNewAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var characterClass = player.SelectedCharacter!.CharacterClass!;
|
||||
var startedSkill = this.CreateMasterSkill(1, rank: 1, characterClass);
|
||||
var otherSkill = this.CreateMasterSkill(2, rank: 1, characterClass);
|
||||
player.GameContext.Configuration.Skills.Add(startedSkill);
|
||||
player.GameContext.Configuration.Skills.Add(otherSkill);
|
||||
player.SelectedCharacter.LearnedSkills.Add(new SkillEntry { Skill = startedSkill, Level = 5 });
|
||||
player.SelectedCharacter.MasterLevelUpPoints = 1;
|
||||
|
||||
Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.SameAs(startedSkill));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A skill of the next rank only becomes eligible once a skill of the previous rank of the same
|
||||
/// root reached level 10; a next-rank skill of another root stays out of reach and the points go
|
||||
/// into pumping the finished skill instead.
|
||||
/// </summary>
|
||||
/// <param name="sameRoot">Whether the rank-2 skill shares the root of the learned rank-1 skill.</param>
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
public async ValueTask RespectsRankGatePerRootAsync(bool sameRoot)
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var characterClass = player.SelectedCharacter!.CharacterClass!;
|
||||
var rank1 = this.CreateMasterSkill(1, rank: 1, characterClass, rootId: 1);
|
||||
var rank2 = this.CreateMasterSkill(2, rank: 2, characterClass, rootId: sameRoot ? (byte)1 : (byte)2);
|
||||
player.GameContext.Configuration.Skills.Add(rank1);
|
||||
player.GameContext.Configuration.Skills.Add(rank2);
|
||||
player.SelectedCharacter.LearnedSkills.Add(new SkillEntry { Skill = rank1, Level = 10 });
|
||||
player.SelectedCharacter.MasterLevelUpPoints = 1;
|
||||
|
||||
var pick = BotMasterHandler.PickNextMasterSkill(player);
|
||||
|
||||
Assert.That(pick, Is.SameAs(sameRoot ? rank2 : rank1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Among equally reachable new skills, a "useful" one (here: a passive boosting a stat) is
|
||||
/// preferred even when a useless one comes first by number.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask PrefersUsefulSkillAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var characterClass = player.SelectedCharacter!.CharacterClass!;
|
||||
var uselessSkill = this.CreateMasterSkill(1, rank: 1, characterClass);
|
||||
var passiveSkill = this.CreateMasterSkill(2, rank: 1, characterClass);
|
||||
passiveSkill.MasterDefinition!.TargetAttribute = Stats.MaximumHealth;
|
||||
player.GameContext.Configuration.Skills.Add(uselessSkill);
|
||||
player.GameContext.Configuration.Skills.Add(passiveSkill);
|
||||
player.SelectedCharacter.MasterLevelUpPoints = 1;
|
||||
|
||||
Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.SameAs(passiveSkill));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bonus which only applies against other players does nothing for a bot: it spends its life
|
||||
/// hunting monsters. It is picked last, after a passive which helps it there.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask PrefersPvmBonusOverPvpBonusAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var characterClass = player.SelectedCharacter!.CharacterClass!;
|
||||
var pvpSkill = this.CreateMasterSkill(1, rank: 1, characterClass);
|
||||
pvpSkill.MasterDefinition!.TargetAttribute = Stats.DefenseRatePvp;
|
||||
var pvmSkill = this.CreateMasterSkill(2, rank: 1, characterClass);
|
||||
pvmSkill.MasterDefinition!.TargetAttribute = Stats.MaximumHealth;
|
||||
player.GameContext.Configuration.Skills.Add(pvpSkill);
|
||||
player.GameContext.Configuration.Skills.Add(pvmSkill);
|
||||
player.SelectedCharacter.MasterLevelUpPoints = 1;
|
||||
|
||||
Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.SameAs(pvmSkill));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With everything learned at its maximum nothing is picked - the loop stops.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask PicksNothingWhenTreeIsFullAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
var characterClass = player.SelectedCharacter!.CharacterClass!;
|
||||
var skill = this.CreateMasterSkill(1, rank: 1, characterClass);
|
||||
player.GameContext.Configuration.Skills.Add(skill);
|
||||
player.SelectedCharacter.LearnedSkills.Add(new SkillEntry { Skill = skill, Level = 20 });
|
||||
player.SelectedCharacter.MasterLevelUpPoints = 5;
|
||||
|
||||
Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an offline test player whose class has a master class as next generation.
|
||||
/// </summary>
|
||||
private async ValueTask<GameLogic.Offline.OfflinePlayer> CreatePlayerWithMasterTargetAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
|
||||
GiveMasterTarget(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives the player's character class a master class as next generation.
|
||||
/// </summary>
|
||||
private static void GiveMasterTarget(Player player)
|
||||
{
|
||||
var masterClass = new CharacterClass
|
||||
{
|
||||
Name = "Test Master",
|
||||
IsMasterClass = true,
|
||||
};
|
||||
Mock.Get(player.SelectedCharacter!.CharacterClass!)
|
||||
.Setup(c => c.NextGenerationClass)
|
||||
.Returns(masterClass);
|
||||
}
|
||||
|
||||
private Skill CreateMasterSkill(short number, byte rank, CharacterClass qualifiedClass, byte rootId = 1)
|
||||
{
|
||||
var masterDefinition = new Mock<MasterSkillDefinition>();
|
||||
masterDefinition.SetupAllProperties();
|
||||
masterDefinition.Object.Rank = rank;
|
||||
masterDefinition.Object.MaximumLevel = 20;
|
||||
masterDefinition.Object.MinimumLevel = 1;
|
||||
masterDefinition.Object.Root = new MasterSkillRoot { Id = new Guid(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, rootId) };
|
||||
masterDefinition.Setup(m => m.RequiredMasterSkills).Returns(new List<Skill>());
|
||||
|
||||
var skill = new Mock<Skill>();
|
||||
skill.SetupAllProperties();
|
||||
skill.Object.Number = number;
|
||||
skill.Setup(s => s.QualifiedCharacters).Returns(new List<CharacterClass> { qualifiedClass });
|
||||
skill.Object.MasterDefinition = masterDefinition.Object;
|
||||
return skill.Object;
|
||||
}
|
||||
}
|
||||
79
tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs
Normal file
79
tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
// <copyright file="BotProgressionTests.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.Tests.Offline;
|
||||
|
||||
using MUnique.OpenMU.AttributeSystem;
|
||||
using MUnique.OpenMU.GameLogic.Attributes;
|
||||
using MUnique.OpenMU.GameLogic.Bots;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="BotProgression"/>: the point split with capacities and the per-bot rolls.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotProgressionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests that the split assigns all points proportionally when nothing is capped.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SplitPoints_AssignsAllPointsProportionally()
|
||||
{
|
||||
var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 35), (Stats.BaseVitality, 5) };
|
||||
|
||||
var result = BotProgression.SplitPoints(1000, weights).ToDictionary(r => r.Stat, r => r.Amount);
|
||||
|
||||
Assert.That(result.Values.Sum(), Is.EqualTo(1000));
|
||||
Assert.That(result[Stats.BaseAgility], Is.EqualTo(350));
|
||||
Assert.That(result[Stats.BaseVitality], Is.EqualTo(50));
|
||||
Assert.That(result[Stats.BaseStrength], Is.EqualTo(600));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that a capped stat drops out of the split and its share flows to the remaining stats.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SplitPoints_CappedStatOverflowsToOthers()
|
||||
{
|
||||
var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 35), (Stats.BaseVitality, 5) };
|
||||
long CapacityOf(AttributeDefinition stat) => stat == Stats.BaseVitality ? 10 : long.MaxValue;
|
||||
|
||||
var result = BotProgression.SplitPoints(1000, weights, CapacityOf).ToDictionary(r => r.Stat, r => r.Amount);
|
||||
|
||||
Assert.That(result[Stats.BaseVitality], Is.EqualTo(10));
|
||||
Assert.That(result.Values.Sum(), Is.EqualTo(1000));
|
||||
Assert.That(result[Stats.BaseStrength] + result[Stats.BaseAgility], Is.EqualTo(990));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that points stay unassigned when every stat is at its capacity, like for a maxed character.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SplitPoints_AllCapped_LeavesPointsUnassigned()
|
||||
{
|
||||
var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 40) };
|
||||
|
||||
// Every stat is capped at the same value, so which one is asked for does not matter.
|
||||
Func<AttributeDefinition, long> capacityOf = _ => 25;
|
||||
|
||||
var result = BotProgression.SplitPoints(1000, weights, capacityOf).ToDictionary(r => r.Stat, r => r.Amount);
|
||||
|
||||
Assert.That(result.Values.Sum(), Is.EqualTo(50));
|
||||
Assert.That(result.Values, Is.All.EqualTo(25));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the vitality target roll stays within 100..500 and is stable for the same name.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void GetVitalityTarget_IsStableAndWithinRange()
|
||||
{
|
||||
foreach (var name in new[] { "Kaeoris", "Milynara", "Hallin", "Oriwen", "X" })
|
||||
{
|
||||
var target = BotProgression.GetVitalityTarget(name);
|
||||
Assert.That(target, Is.InRange(100, 500), name);
|
||||
Assert.That(BotProgression.GetVitalityTarget(name), Is.EqualTo(target), name);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
tests/MUnique.OpenMU.Tests/Offline/BotResetHandlerTests.cs
Normal file
110
tests/MUnique.OpenMU.Tests/Offline/BotResetHandlerTests.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
// <copyright file="BotResetHandlerTests.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.Tests.Offline;
|
||||
|
||||
using MUnique.OpenMU.GameLogic;
|
||||
using MUnique.OpenMU.GameLogic.Attributes;
|
||||
using MUnique.OpenMU.GameLogic.Bots;
|
||||
using MUnique.OpenMU.GameLogic.Resets;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="BotResetHandler"/>.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotResetHandlerTests
|
||||
{
|
||||
private IGameContext _gameContext = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a fresh game context before each test.
|
||||
/// </summary>
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
this._gameContext = GameContextTestHelper.CreateGameContext();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Without the reset feature the effective level is simply the character level.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask EffectiveLevelWithoutResetFeatureIsPlainLevelAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
|
||||
player.Attributes![Stats.Level] = 123;
|
||||
|
||||
Assert.That(BotResetHandler.GetEffectiveLevel(player), Is.EqualTo(123));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With the reset feature every reset counts as the configured level span. Uses the same plain
|
||||
/// test context as <see cref="ResetCharacterActionTest"/>, where the added feature plugin is the
|
||||
/// effective one (the offline helper's context discovers the real, disabled-by-default plugin).
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask EffectiveLevelCountsResetsAsLevelSpansAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
|
||||
player.GameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = new ResetConfiguration { RequiredLevel = 400 } }, true);
|
||||
player.Attributes![Stats.Level] = 50;
|
||||
player.Attributes[Stats.Resets] = 3;
|
||||
|
||||
Assert.That(BotResetHandler.GetEffectiveLevel(player), Is.EqualTo((3 * 400) + 50));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A due reset raises the reset count, drops the level and grants the configured points, without
|
||||
/// consuming any costs when the bot doesn't pay them.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask TryResetPerformsResetAndSkipsCostsAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
|
||||
var configuration = new ResetConfiguration
|
||||
{
|
||||
RequiredLevel = 400,
|
||||
LevelAfterReset = 10,
|
||||
RequiredMoney = 500,
|
||||
MultiplyRequiredMoneyByResetCount = false,
|
||||
PointsPerReset = 1000,
|
||||
MultiplyPointsByResetCount = false,
|
||||
ReplacePointsPerReset = true,
|
||||
ResetStats = false,
|
||||
MoveHome = false,
|
||||
LogOut = false,
|
||||
};
|
||||
this._gameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true);
|
||||
player.Attributes![Stats.Level] = 400;
|
||||
player.Money = 100; // less than the required zen - must not matter for a non-paying bot
|
||||
|
||||
var performed = await BotResetHandler.TryResetAsync(player, configuration, payCosts: false).ConfigureAwait(false);
|
||||
|
||||
Assert.That(performed, Is.True);
|
||||
Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(1));
|
||||
Assert.That((int)player.Attributes[Stats.Level], Is.EqualTo(10));
|
||||
Assert.That(player.SelectedCharacter!.LevelUpPoints, Is.EqualTo(1000));
|
||||
Assert.That(player.SelectedCharacter.Experience, Is.EqualTo(0));
|
||||
Assert.That(player.Money, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reset is not performed below the required level or beyond the reset limit.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask TryResetRespectsLevelAndLimitAsync()
|
||||
{
|
||||
var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false);
|
||||
var configuration = new ResetConfiguration { RequiredLevel = 400, ResetLimit = 2, MoveHome = false, LogOut = false };
|
||||
this._gameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true);
|
||||
|
||||
player.Attributes![Stats.Level] = 399;
|
||||
Assert.That(await BotResetHandler.TryResetAsync(player, configuration, payCosts: false).ConfigureAwait(false), Is.False);
|
||||
|
||||
player.Attributes[Stats.Level] = 400;
|
||||
player.Attributes[Stats.Resets] = 2;
|
||||
Assert.That(await BotResetHandler.TryResetAsync(player, configuration, payCosts: false).ConfigureAwait(false), Is.False);
|
||||
Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(2));
|
||||
}
|
||||
}
|
||||
@@ -50,11 +50,17 @@ public class CombatHandlerTests
|
||||
await player.CurrentMap!.AddAsync(monster).ConfigureAwait(false);
|
||||
|
||||
var config = new MuHelperSettings { HuntingRange = 10 };
|
||||
var movementHandler = new MovementHandler(player, config, this._origin);
|
||||
player.HuntingOrigin = this._origin;
|
||||
var movementHandler = new MovementHandler(player, config);
|
||||
|
||||
var handler = new CombatHandler(player, config, movementHandler, this._origin);
|
||||
var handler = new CombatHandler(player, config, movementHandler);
|
||||
|
||||
// Act
|
||||
// The first call only acquires the target and turns towards it: a fresh target gets a small
|
||||
// randomized human-like reaction delay (up to 900 ms) before the bot engages, so we call
|
||||
// again after the delay has certainly elapsed.
|
||||
await handler.PerformAttackAsync().ConfigureAwait(false);
|
||||
await Task.Delay(1000).ConfigureAwait(false);
|
||||
await handler.PerformAttackAsync().ConfigureAwait(false);
|
||||
|
||||
// Assert
|
||||
@@ -93,9 +99,10 @@ public class CombatHandlerTests
|
||||
};
|
||||
await player.SkillList!.AddLearnedSkillAsync(drainSkill).ConfigureAwait(false);
|
||||
|
||||
var movementHandler = new MovementHandler(player, config, this._origin);
|
||||
player.HuntingOrigin = this._origin;
|
||||
var movementHandler = new MovementHandler(player, config);
|
||||
|
||||
var handler = new CombatHandler(player, config, movementHandler, this._origin);
|
||||
var handler = new CombatHandler(player, config, movementHandler);
|
||||
|
||||
// Act
|
||||
await handler.PerformDrainLifeRecoveryAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -44,7 +44,8 @@ public class MovementHandlerTests
|
||||
HuntingRange = 5,
|
||||
};
|
||||
|
||||
var handler = new MovementHandler(player, config, origin);
|
||||
player.HuntingOrigin = origin;
|
||||
var handler = new MovementHandler(player, config);
|
||||
|
||||
// Act
|
||||
var result = await handler.RegroupAsync().ConfigureAwait(false);
|
||||
|
||||
204
tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs
Normal file
204
tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
// <copyright file="BotPartyHandlerTest.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.GameLogic;
|
||||
using MUnique.OpenMU.GameLogic.Attributes;
|
||||
using MUnique.OpenMU.GameLogic.Bots;
|
||||
using MUnique.OpenMU.GameLogic.Offline;
|
||||
using MUnique.OpenMU.GameLogic.PlayerActions.Party;
|
||||
|
||||
/// <summary>
|
||||
/// Tests <see cref="BotPartyHandler"/> - how a server-side bot answers party invitations from
|
||||
/// players and when it leaves the party again.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class BotPartyHandlerTest
|
||||
{
|
||||
/// <summary>
|
||||
/// The happy path: an eligible invitation is scheduled and, once processed, forms a party with
|
||||
/// the inviter as its master.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask AcceptsInviteAndFormsPartyAsync()
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
|
||||
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
|
||||
|
||||
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
|
||||
Assert.That(scheduled, Is.True);
|
||||
Assert.That(bot.PendingPartyInvite, Is.Not.Null);
|
||||
Assert.That(bot.LastPartyRequester, Is.SameAs(requester));
|
||||
|
||||
await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false);
|
||||
|
||||
Assert.That(bot.Party, Is.Not.Null);
|
||||
Assert.That(bot.Party!.PartyMaster, Is.SameAs(requester));
|
||||
Assert.That(requester.Party, Is.SameAs(bot.Party));
|
||||
Assert.That(bot.PendingPartyInvite, Is.Null);
|
||||
Assert.That(bot.LastPartyRequester, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An inviter whose effective level is too far from the bot's is declined - the group would only
|
||||
/// be a power-leveling service.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask RejectsTooLargeLevelGapAsync()
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
|
||||
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
|
||||
requester.Attributes![Stats.Level] = 700;
|
||||
|
||||
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
|
||||
|
||||
Assert.That(scheduled, Is.False);
|
||||
Assert.That(bot.PendingPartyInvite, Is.Null);
|
||||
Assert.That(bot.LastPartyRequester, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bot on a shopping errand declines the invitation, like a busy player would.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask RejectsWhileOnShoppingTripAsync()
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
|
||||
bot.IsOnShoppingTrip = true;
|
||||
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
|
||||
|
||||
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
|
||||
|
||||
Assert.That(scheduled, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only server-side bot accounts answer; a regular offline session of a human account does not.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask RejectsForNonBotAccountAsync()
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext, "Bot", isBot: false).ConfigureAwait(false);
|
||||
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
|
||||
|
||||
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
|
||||
|
||||
Assert.That(scheduled, Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The invitation is re-validated when the delay passed: an inviter who joined another party as a
|
||||
/// plain member in the meantime cannot take the bot in anymore.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask CancelsWhenRequesterJoinedAnotherPartyAsync()
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
|
||||
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
|
||||
var thirdPlayer = await CreateHumanAsync(gameContext, "Third").ConfigureAwait(false);
|
||||
|
||||
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
|
||||
Assert.That(scheduled, Is.True);
|
||||
|
||||
// Meanwhile the inviter joins another party as a plain member (the third player is master).
|
||||
var otherParty = gameContext.PartyManager.CreateParty();
|
||||
await otherParty.AddAsync(thirdPlayer).ConfigureAwait(false);
|
||||
await otherParty.AddAsync(requester).ConfigureAwait(false);
|
||||
|
||||
await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false);
|
||||
|
||||
Assert.That(bot.Party, Is.Null);
|
||||
Assert.That(bot.PendingPartyInvite, Is.Null);
|
||||
Assert.That(bot.LastPartyRequester, Is.Null);
|
||||
Assert.That(otherParty.PartyList, Does.Not.Contain(bot));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After its rolled party time is up, the bot gets bored of the party with a human and leaves.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask LeavesPartyWithHumanWhenBoredAsync()
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
|
||||
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
|
||||
var party = gameContext.PartyManager.CreateParty();
|
||||
await party.AddAsync(requester).ConfigureAwait(false);
|
||||
await party.AddAsync(bot).ConfigureAwait(false);
|
||||
|
||||
bot.PartyBoredomAtUtc = DateTime.UtcNow - TimeSpan.FromSeconds(1);
|
||||
await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false);
|
||||
|
||||
Assert.That(bot.Party, Is.Null);
|
||||
Assert.That(bot.PartyBoredomAtUtc, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bot-only parties are managed by the hourly re-formation instead - no boredom timer runs, and
|
||||
/// the bot stays with its group.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask StaysInBotOnlyPartyAsync()
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
|
||||
var otherBot = await CreateBotAsync(gameContext, "OtherBot").ConfigureAwait(false);
|
||||
var party = gameContext.PartyManager.CreateParty();
|
||||
await party.AddAsync(otherBot).ConfigureAwait(false);
|
||||
await party.AddAsync(bot).ConfigureAwait(false);
|
||||
|
||||
bot.PartyBoredomAtUtc = DateTime.UtcNow - TimeSpan.FromSeconds(1);
|
||||
await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false);
|
||||
|
||||
Assert.That(bot.Party, Is.SameAs(party));
|
||||
Assert.That(bot.PartyBoredomAtUtc, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The full wiring: a party request through the regular request action reaches the bot via the
|
||||
/// <see cref="GameLogic.MuHelper.PartyRequestHandler"/> criteria and schedules the delayed answer.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async ValueTask PartyRequestActionSchedulesInviteForBotAsync()
|
||||
{
|
||||
var gameContext = GameContextTestHelper.CreateGameContext();
|
||||
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
|
||||
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
|
||||
requester.Observers.Add(bot);
|
||||
|
||||
var action = new PartyRequestAction();
|
||||
await action.HandlePartyRequestAsync(requester, bot).ConfigureAwait(false);
|
||||
|
||||
Assert.That(bot.PendingPartyInvite, Is.Not.Null);
|
||||
Assert.That(bot.PendingPartyInvite!.Requester, Is.SameAs(requester));
|
||||
Assert.That(bot.LastPartyRequester, Is.SameAs(requester));
|
||||
}
|
||||
|
||||
private static async ValueTask<OfflinePlayer> CreateBotAsync(IGameContext gameContext, string name, bool isBot = true)
|
||||
{
|
||||
var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false);
|
||||
await bot.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
|
||||
bot.SelectedCharacter!.Name = name;
|
||||
bot.IsAlive = true;
|
||||
bot.Account!.IsBot = isBot;
|
||||
bot.MuHelperSettings = new BotMuHelperSettings();
|
||||
return bot;
|
||||
}
|
||||
|
||||
private static async ValueTask<Player> CreateHumanAsync(IGameContext gameContext, string name)
|
||||
{
|
||||
var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
|
||||
await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
|
||||
player.SelectedCharacter!.Name = name;
|
||||
player.IsAlive = true;
|
||||
return player;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user