Merge pull request #820 from nolt/feature-bots

(cherry picked from commit b10de0645a869485fbb5771a739abd06b5708c2d)
This commit is contained in:
sven-n
2026-07-16 22:01:57 +02:00
committed by Acentech Dev
parent 8baf329654
commit d04cbecae3
58 changed files with 14856 additions and 62 deletions

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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));
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}
}

View 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));
}
}

View File

@@ -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);

View File

@@ -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);

View 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;
}
}