//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.PlayerActions.Character;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
///
/// Grows a server-side bot like a real player when it levels up during play: the earned stat points are
/// invested according to the bot's class build (see ), and any
/// class skill whose learn requirements (total energy, leadership, character level, ...) are now met is
/// learned - attack skills as well as the class's own buffs and heals. Skills are only ever learned for
/// the character's own class (), using the same requirements the
/// game enforces for human players, so a grown bot matches a freshly generated one of the same level.
///
[PlugIn]
[Display(Name = "Bot skill progression", Description = "Invests level-up stat points and teaches server-side bots new class- and level-appropriate skills as they level up.")]
[Guid("D1F4A7C2-6B3E-4A59-8E71-9C0D2F5B6A84")]
public class BotSkillProgressionPlugIn : ICharacterLevelUpPlugIn
{
private static readonly IncreaseStatsAction IncreaseStatsAction = new();
private static readonly BotSkillProgressionPlugIn CatchUp = new();
///
/// Applies the progression a bot is owed but has not received, when it enters the world. Both stat
/// points and skills are otherwise only handed out on a level-up, so anything a bot became entitled
/// to while it was not playing waits for its next one - and a bot at the maximum level has no next
/// one. That covers a freshly generated character, one whose level-up handler failed, and a bot which
/// qualifies for a skill it was not allowed to learn when it last levelled.
///
/// The bot which entered the world.
public static void CatchUpPendingProgress(Player player)
{
CatchUp.CharacterLeveledUp(player);
}
///
public void CharacterLeveledUp(Player player)
{
if (player.Account?.IsBot != true
|| player.SelectedCharacter?.CharacterClass is not { }
|| player.SkillList is not { })
{
return;
}
// Queue the progression into the bot's AI tick instead of running it right here: this hook fires
// from the experience/level-up path while the combat handler may be enumerating the skill list on
// its own timer - the tick serializes both. No own SaveChanges here - the new stats and skills
// are persisted by the periodic save, avoiding extra concurrency pressure.
if (player is Offline.OfflinePlayer offlinePlayer)
{
offlinePlayer.PendingBotActions.Enqueue(() => new ValueTask(this.ProgressAsync(offlinePlayer)));
}
else
{
_ = this.ProgressAsync(player);
}
}
private async Task ProgressAsync(Player player)
{
try
{
this.EvolveClassIfDue(player);
await this.SpendStatPointsAsync(player).ConfigureAwait(false);
await this.LearnNewSkillsAsync(player).ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogError(ex, "Failed to progress bot '{Name}' after level-up.", player.Name);
}
}
///
/// Changes the bot into its second-generation class (Dark Knight -> Blade Knight etc.) once it
/// reaches - the exact assignment the class-change
/// quest performs for a human player (see QuestCompletionAction); simulating the quest run
/// itself would be invisible to observers anyway. Skills and gear of the new class follow
/// automatically, because all bot progression keys off the current class's qualifications.
///
private void EvolveClassIfDue(Player player)
{
var character = player.SelectedCharacter!;
if (player.Level < BotProgression.ClassEvolutionLevel
|| BotProgression.GetEvolutionTarget(character.CharacterClass!) is not { } evolvedClass)
{
return;
}
character.CharacterClass = evolvedClass;
player.Logger.LogInformation(
"Bot '{Name}' evolved into {Class} at level {Level}.",
player.Name,
evolvedClass.Name,
player.Level);
}
private async ValueTask SpendStatPointsAsync(Player player)
{
var character = player.SelectedCharacter!;
var characterClass = character.CharacterClass!;
var points = character.LevelUpPoints;
if (points <= 0)
{
return;
}
var resetMeta = BotResetHandler.GetResetConfiguration(player.GameContext) is not null;
var weights = BotProgression.GetStatWeights(characterClass, character.Name, resetMeta);
var vitalityTarget = resetMeta ? BotProgression.GetVitalityTarget(character.Name) : (int?)null;
// Mirrors the capacity checks of the stat-increase action (a stat's configured maximum on fun
// servers), plus the bot's personal vitality target on reset-meta servers: a full stat drops
// out of the split, so its share flows into the rest of the build instead of getting lost.
long CapacityOf(AttributeDefinition stat)
{
var classBase = characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == stat);
var current = (long)(player.Attributes?[stat] ?? 0f);
var capacity = long.MaxValue;
if (classBase?.Attribute?.MaximumValue is { } maximumValue)
{
capacity = (long)maximumValue - current;
}
if (vitalityTarget is { } target && stat == Stats.BaseVitality)
{
var invested = current - (long)(classBase?.BaseValue ?? 0f);
capacity = Math.Min(capacity, target - invested);
}
return capacity;
}
foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights, CapacityOf))
{
if (amount > 0)
{
await IncreaseStatsAction.IncreaseStatsAsync(player, stat, (ushort)amount).ConfigureAwait(false);
}
}
}
private async ValueTask LearnNewSkillsAsync(Player player)
{
var characterClass = player.SelectedCharacter!.CharacterClass!;
var skillList = player.SkillList!;
float? GetValue(AttributeDefinition attribute) => player.Attributes?[attribute];
foreach (var skill in player.GameContext.Configuration.Skills)
{
if (!BotProgression.IsBotLearnableSkill(skill)
|| !skill.QualifiedCharacters.Contains(characterClass)
|| skillList.ContainsSkill((ushort)skill.Number)
|| !BotProgression.MeetsRequirements(skill, GetValue))
{
continue;
}
await skillList.AddLearnedSkillAsync(skill).ConfigureAwait(false);
player.Logger.LogInformation("Bot '{Name}' learned '{Skill}' at level {Level}.", player.Name, skill.Name, player.Level);
}
}
}