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