// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.Offline; using MUnique.OpenMU.GameLogic.PlayerActions.Character; /// /// Handles the third-generation ("master") stage of a bot's career: the evolution into the master /// class at the game's maximum level - the same class assignment the level-400 master quests perform /// for a human player - and the investment of the master points earned per master level, through the /// regular with all its validations. /// /// /// On servers with the reset feature the evolution follows one iron rule: a bot only becomes a master /// when no reset can ever follow, i.e. the configured reset limit is exhausted. While resets remain - /// or when no limit is configured at all, so resetting forever is the endgame - the bot keeps resetting /// and never masters, like the players of such servers. Without the reset feature it evolves as soon as /// it reaches the maximum level. /// The master class's base attributes (master experience rate, master points per level) and the master /// level stat are only mounted into the attribute system when the character enters the world, so the /// caller must restart the bot after the evolution (see BotManager.RestartBotAsync) - the ghost /// equivalent of a player relogging; master experience only flows after that fresh world entry. /// internal static class BotMasterHandler { /// /// A master skill unlocks the next rank of its root (and satisfies "required skill" links) at this /// level, see . /// private const int RankUnlockLevel = 10; /// The item groups of the weapons a master bonus can be tied to (see ). private const byte SwordGroup = 0; /// The item group of the maces - the scepters live here as well. private const byte MaceGroup = 2; /// The item group of the spears. private const byte SpearGroup = 3; /// The item group of the bows and crossbows. private const byte BowGroup = 4; /// The item group of the staffs - the sticks and books live here as well. private const byte StaffGroup = 5; private static readonly AddMasterPointAction AddPointAction = new(); /// /// Determines whether the bot is due for its master evolution: it has a master class to evolve /// into, stands at the game's maximum level, and - on reset servers - exhausted the reset limit /// (see the remarks of for the rationale). /// /// The bot player. /// True, if the bot should evolve into its master class now. public static bool IsMasterEvolutionDue(Player player) { if (player.SelectedCharacter is not { CharacterClass: { } currentClass } || player.Attributes is not { } attributes || BotProgression.GetMasterEvolutionTarget(currentClass) is null) { return false; } if ((int)attributes[Stats.Level] < player.GameContext.Configuration.MaximumLevel) { return false; } if (BotResetHandler.GetResetConfiguration(player.GameContext) is { } resetConfiguration && (resetConfiguration.ResetLimit is not > 0 || (int)attributes[Stats.Resets] < resetConfiguration.ResetLimit)) { return false; } return true; } /// /// Evolves the bot into its master class when due. The caller must restart the bot afterwards /// (see the remarks of ). /// /// The bot player. /// True, if the evolution was performed. public static async ValueTask TryEvolveAsync(OfflinePlayer player) { if (!IsMasterEvolutionDue(player) || player.SelectedCharacter is not { } character) { return false; } var masterClass = BotProgression.GetMasterEvolutionTarget(character.CharacterClass!)!; character.CharacterClass = masterClass; player.Logger.LogInformation( "Bot '{Name}' evolved into master class {Class} at level {Level}.", player.Name, masterClass.Name, player.Level); try { // Persist right away like a performed reset - the following restart reloads the character // from the database, so the class change must be down there before it. await player.SaveProgressAsync().ConfigureAwait(false); } catch (Exception ex) { player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after its master evolution; the logout save will retry.", player.Name); } return true; } /// /// Determines whether the bot is a master with points to invest - the cheap per-tick guard for /// queueing . /// /// The bot player. /// True, if there are master points to spend. public static bool HasMasterPointsToSpend(Player player) => player.SelectedCharacter is { CharacterClass.IsMasterClass: true, MasterLevelUpPoints: > 0 }; /// /// Invests the bot's available master points (earned one per master level) into its master skill /// tree through the regular . Learning a skill mutates the /// skill list, so this must run inside the bot's AI tick - queue it via /// (see the call site in BotNavigator); /// with no points available it is a cheap no-op. /// /// The bot player. public static async ValueTask TrySpendMasterPointsAsync(OfflinePlayer player) { if (player.SelectedCharacter is not { CharacterClass.IsMasterClass: true } character || character.MasterLevelUpPoints < 1) { return; } while (character.MasterLevelUpPoints > 0 && PickNextMasterSkill(player) is { } skill) { var pointsBefore = character.MasterLevelUpPoints; await AddPointAction.AddMasterPointAsync(player, (ushort)skill.Number).ConfigureAwait(false); if (character.MasterLevelUpPoints >= pointsBefore) { // The action refused - its own checks are authoritative, don't loop on the same pick. break; } player.Logger.LogInformation( "Bot '{Name}' invested {Points} master point(s) into '{Skill}'.", player.Name, pointsBefore - character.MasterLevelUpPoints, skill.Name); } } /// /// Picks the master skill the bot invests its next point into, or null when nothing is /// eligible. The policy fills the tree like a player: first push a started skill to the rank-unlock /// level of 10, then learn a new eligible skill - preferring "useful" ones, i.e. passives boosting a /// stat or strengtheners of a skill the bot actually has - and finally pump the learned skills /// towards their maximum. Deterministic, so a bot builds the same tree across sessions. /// Pure decision logic - exposed for unit tests. /// /// The bot player. internal static Skill? PickNextMasterSkill(Player player) { if (player.SelectedCharacter is not { CharacterClass: { } characterClass } character || player.SkillList is not { } skillList) { return null; } var learned = character.LearnedSkills .Where(l => l.Skill?.MasterDefinition?.Root is not null) .ToList(); if (learned .Where(l => l.Level < RankUnlockLevel && l.Level < l.Skill!.MasterDefinition!.MaximumLevel) .OrderBy(l => l.Skill!.MasterDefinition!.Rank) .ThenBy(l => l.Skill!.Number) .FirstOrDefault() is { } gate) { return gate.Skill; } if (player.GameContext.Configuration.Skills .Where(s => s.MasterDefinition?.Root is not null && s.QualifiedCharacters.Contains(characterClass) && character.LearnedSkills.All(l => l.Skill != s) && CanLearn(player, s, character.MasterLevelUpPoints)) .OrderBy(s => IsUsefulPick(player, s, skillList) ? 0 : 1) .ThenBy(s => s.MasterDefinition!.Rank) .ThenBy(s => s.Number) .FirstOrDefault() is { } newSkill) { return newSkill; } return learned .Where(l => l.Level < l.Skill!.MasterDefinition!.MaximumLevel) .OrderBy(l => IsUsefulPick(player, l.Skill!, skillList) ? 0 : 1) .ThenBy(l => l.Skill!.MasterDefinition!.Rank) .ThenBy(l => l.Skill!.Number) .FirstOrDefault()?.Skill; } /// /// Mirrors the private requisition checks of (minimum points, /// previous rank of the same root at 10+, required skills), so the picker only proposes skills the /// action will accept. A mismatch is harmless: the action refuses and the spend loop stops. /// private static bool CanLearn(Player player, Skill skill, int availablePoints) { var definition = skill.MasterDefinition!; if (availablePoints < definition.MinimumLevel) { return false; } if (definition.Rank > 1 && !player.SelectedCharacter!.LearnedSkills.Any(l => l.Skill?.MasterDefinition?.Root is { } root && root.Id == definition.Root?.Id && l.Skill.MasterDefinition.Rank == definition.Rank - 1 && l.Level >= RankUnlockLevel)) { return false; } if (definition.RequiredMasterSkills?.Any() == true && !definition.RequiredMasterSkills.All(s => player.SelectedCharacter!.LearnedSkills.Any(l => l.Skill == s && l.Level >= RankUnlockLevel) || (s.MasterDefinition is null && player.SkillList?.ContainsSkill((ushort)s.Number) == true))) { return false; } return true; } /// /// A pick is "useful" when it demonstrably does something for this bot: a passive boosting a stat, /// or a strengthener/mastery of a skill the bot actually has in its list. A passive tied to a WEAPON /// type the bot does not fight with is not (a bow strengthener does nothing for a bot swinging a /// sword), and neither is one which only applies against other PLAYERS: a bot spends its life /// hunting monsters, and it may not even attack a player unless attacked first (see /// ). Both get filled last, after everything which actually helps. /// private static bool IsUsefulPick(Player player, Skill skill, ISkillList skillList) { var definition = skill.MasterDefinition!; if (definition.TargetAttribute is { } target) { return !IsPvpOnlyBonus(target) && (WeaponGroupOfBonus(target) is not { } weaponGroup || CarriesWeaponOfGroup(player, weaponGroup)); } return definition.ReplacedSkill is { } replaced && skillList.ContainsSkill((ushort)replaced.Number); } /// /// Whether the master bonus only ever applies in a fight against another player, which is not what a /// bot's points are for. /// /// The bonus attribute. private static bool IsPvpOnlyBonus(AttributeDefinition attribute) { return attribute == Stats.AttackRatePvp || attribute == Stats.DefenseRatePvp; } /// /// The item group of the weapon a master bonus attribute belongs to, or null when the bonus /// helps regardless of the weapon (health, defense, an attack rate, ...). The item groups come from /// the item data: scepters live in the mace group, the Rage Fighter's gloves in the sword group, and /// sticks and books next to the staffs. /// private static byte? WeaponGroupOfBonus(AttributeDefinition attribute) { if (attribute == Stats.OneHandedSwordBonusDamage || attribute == Stats.TwoHandedSwordStrBonusDamage || attribute == Stats.TwoHandedSwordMasteryBonusDamage || attribute == Stats.GloveWeaponBonusDamage) { return SwordGroup; } if (attribute == Stats.MaceBonusDamage || attribute == Stats.MaceMasteryStunChance || attribute == Stats.ScepterStrBonusDamage || attribute == Stats.ScepterMasteryBonusDamage || attribute == Stats.ScepterPetBonusDamage || attribute == Stats.BonusDamageWithScepterCmdDiv) { return MaceGroup; } if (attribute == Stats.SpearBonusDamage || attribute == Stats.SpearMasteryDoubleDamageChance) { return SpearGroup; } if (attribute == Stats.BowStrBonusDamage || attribute == Stats.CrossBowStrBonusDamage || attribute == Stats.CrossBowMasteryBonusDamage) { return BowGroup; } if (attribute == Stats.OneHandedStaffBonusBaseDamage || attribute == Stats.TwoHandedStaffBonusBaseDamage || attribute == Stats.TwoHandedStaffMasteryBonusDamage || attribute == Stats.StickBonusBaseDamage || attribute == Stats.StickMasteryBonusDamage || attribute == Stats.BookBonusBaseDamage) { return StaffGroup; } return null; } /// /// Whether the bot fights with a weapon of this item group - what it carries right now, and what its /// build makes it pick up in the future (see ), so /// a bot which is momentarily unarmed does not start collecting bonuses for the wrong weapon. /// private static bool CarriesWeaponOfGroup(Player player, byte weaponGroup) { if (player.Inventory?.GetItem(InventoryConstants.LeftHandSlot)?.Definition is { } weapon && weapon.Group == weaponGroup) { return true; } return player.SelectedCharacter is { CharacterClass: { } characterClass } character && BotProgression.IsPreferredWeaponGroup( characterClass, character.Name, BotResetHandler.GetResetConfiguration(player.GameContext) is not null, weaponGroup); } }