using PkmnLib.Dynamic.BattleFlow; using PkmnLib.Dynamic.Libraries; using PkmnLib.Dynamic.Models; using PkmnLib.Dynamic.Models.Choices; using PkmnLib.Dynamic.ScriptHandling; using PkmnLib.Static.Moves; using PkmnLib.Static.Utils; namespace PkmnLib.Dynamic.AI.Explicit; /// /// An explicit AI that has explicitly written logic for each Pokémon and move. /// /// /// This is heavily based on the AI used in Pokémon Essentials /// public class ExplicitAI : PokemonAI { public const int MoveFailScore = 20; public const int MoveUselessScore = 60; public const int MoveBaseScore = 100; private const float TrainerSkill = 100; // TODO: This should be configurable private bool CanPredictMoveFailure => true; // TODO: This should be configurable private bool ScoreMoves => true; // TODO: This should be configurable private float MoveScoreThreshold => (float)(0.6f + 0.35f * Math.Sqrt(Math.Min(TrainerSkill, 100) / 100f)); private readonly IReadOnlyExplicitAIHandlers _handlers; private readonly IRandom _random = new RandomImpl(); /// public ExplicitAI(IDynamicLibrary library) : base("explicit") { _handlers = library.ExplicitAIHandlers; } /// public override ITurnChoice GetChoice(IBattle battle, IPokemon pokemon) { if (battle.HasForcedTurn(pokemon, out var choice)) return choice; var moveChoices = GetMoveScores(pokemon, battle); if (moveChoices.Count == 0) { var opponentSide = (byte)(pokemon.BattleData!.SideIndex == 0 ? 1 : 0); return battle.Library.MiscLibrary.ReplacementChoice(pokemon, opponentSide, pokemon.BattleData.Position); } var maxScore = moveChoices.Max(x => x.score); // TODO: Consider switching to a different pokémon if the score is too low var threshold = (float)Math.Floor(maxScore * MoveScoreThreshold); var considerChoices = moveChoices.Select(x => (x, Math.Max(x.score - threshold, 0))).ToArray(); var totalScore = considerChoices.Sum(x => x.Item2); if (totalScore == 0) { var opponentSide = (byte)(pokemon.BattleData!.SideIndex == 0 ? 1 : 0); return battle.Library.MiscLibrary.ReplacementChoice(pokemon, opponentSide, pokemon.BattleData.Position); } var initialRandomValue = _random.GetFloat(0, totalScore); var randomValue = initialRandomValue; for (var i = 0; i < considerChoices.Length; i++) { randomValue -= considerChoices[i].Item2; if (randomValue >= 0) continue; var (index, _, targetIndex) = considerChoices[i].x; var learnedMove = pokemon.Moves[index]; var opponentSide = (byte)(pokemon.BattleData!.SideIndex == 0 ? 1 : 0); if (targetIndex == -1) targetIndex = pokemon.BattleData.Position; return new MoveChoice(pokemon, learnedMove!, opponentSide, (byte)targetIndex); } throw new InvalidOperationException("No valid move choice found. This should not happen."); } private List<(int index, int score, int targetIndex)> GetMoveScores(IPokemon user, IBattle battle) { var choices = new List<(int index, int score, int targetIndex)>(); foreach (var (learnedMove, index) in user.Moves.Select((x, i) => (x, i)).Where(x => x.x != null)) { var moveChoice = new MoveChoice(user, learnedMove!, 0, 0); if (!battle.CanUse(moveChoice)) { if (learnedMove!.CurrentPp == 0 && learnedMove.MaxPp > 0) { AILogging.LogInformation($"{user} cannot use {learnedMove} because it has no PP left."); } else { AILogging.LogInformation($"{user} cannot use {learnedMove} because it is not a valid choice."); } continue; } var moveData = learnedMove!.MoveData; var moveName = moveData.Name; moveChoice.RunScriptHook(x => x.ChangeMove(moveChoice, ref moveName)); if (moveName != learnedMove.MoveData.Name) { AILogging.LogInformation($"{user} changed {learnedMove} to {moveName}."); if (!battle.Library.StaticLibrary.Moves.TryGet(moveName, out moveData)) throw new InvalidOperationException($"Move {moveName} not found in the move library."); var secondaryEffect = moveData.SecondaryEffect; if (secondaryEffect != null) { if (moveChoice.User.Library.ScriptResolver.TryResolve(ScriptCategory.Move, secondaryEffect.Name, secondaryEffect.Parameters, out var script)) { moveChoice.Script.Set(script); script.OnAddedToParent(moveChoice); } else { moveChoice.Script.Clear(); } } else { moveChoice.Script.Clear(); } } var aiMove = new AIMoveState(user, moveData); if (CanPredictMoveFailure && PredictMoveFailure(user, battle, aiMove)) { AILogging.LogInformation($"{user} is considering {aiMove.Move.Name} but it will fail."); AddMoveToChoices(index, MoveFailScore); } var target = moveData.Target; if (target is MoveTarget.All or MoveTarget.AllAlly or MoveTarget.AllOpponent or MoveTarget.SelfUse) { var score = GetMoveScore(user, aiMove, battle); AddMoveToChoices(index, score); } else if (target is MoveTarget.AdjacentOpponent or MoveTarget.Any or MoveTarget.Adjacent or MoveTarget.AdjacentAllySelf) { // TODO: get redirected target foreach (var pokemon in battle.Sides.SelectMany(x => x.Pokemon).WhereNotNull()) { var battleData = pokemon.BattleData; if (battleData == null) continue; if (!TargetResolver.IsValidTarget(battleData.SideIndex, battleData.Position, target, user)) continue; if (target.TargetsFoe() && battleData.SideIndex == user.BattleData?.SideIndex) { continue; } var score = GetMoveScoreAgainstTarget(user, aiMove, pokemon, battle); AddMoveToChoices(index, score, battleData.Position); } } else { var targets = new List(); foreach (var pokemon in battle.Sides.SelectMany(x => x.Pokemon).WhereNotNull()) { var battleData = pokemon.BattleData; if (battleData == null) continue; if (!TargetResolver.IsValidTarget(battleData.SideIndex, battleData.Position, target, user)) continue; if (target.TargetsFoe() && battleData.SideIndex == user.BattleData?.SideIndex) { continue; } targets.Add(pokemon); } var score = GetMoveScore(user, aiMove, battle, targets); AddMoveToChoices(index, score); } } return choices; void AddMoveToChoices(int index, int score, int targetIndex = -1) { choices.Add((index, score, targetIndex)); // If the user is a wild Pokémon, doubly prefer a random move. if (battle.IsWildBattle && user.PersonalityValue % user.Moves.Count == index) { choices.Add((index, score, targetIndex)); } } } private bool PredictMoveFailure(IPokemon user, IBattle battle, AIMoveState aiMove) { if (user.HasStatus("sleep")) { // User is asleep, and will not wake up, and the move is not usable while asleep. if (user.GetStatusTurnsLeft != 0 && !aiMove.Move.HasFlag("usable_while_asleep")) { AILogging.LogInformation($"{user} is asleep and cannot use {aiMove.Move.Name}."); return true; } } // The move is only usable while asleep, but the user is not asleep else if (aiMove.Move.HasFlag("usable_while_asleep")) { return true; } // Primal weather if (battle.WeatherName == "primordial_sea" && aiMove.Move.MoveType.Name == "fire") return true; if (battle.WeatherName == "desolate_lands" && aiMove.Move.MoveType.Name == "water") return true; // Check if the move will fail based on the handlers return aiMove.Move.SecondaryEffect != null && _handlers.MoveWillFail(aiMove.Move.SecondaryEffect.Name, new MoveOption(aiMove, battle, null)); } private static readonly StringKey PsychicTerrainName = new("psychic_terrain"); private static readonly StringKey DazzlingName = new("dazzling"); private static readonly StringKey QueenlyMajestyName = new("queenly_majesty"); private static readonly StringKey PranksterName = new("prankster"); private static readonly StringKey DarkName = new("dark"); private static readonly StringKey GroundName = new("ground"); private static readonly StringKey PowderName = new("powder"); private static readonly StringKey SubstituteName = new("substitute"); private static readonly StringKey InfiltratorName = new("infiltrator"); private bool PredictMoveFailureAgainstTarget(IPokemon user, AIMoveState aiMove, IPokemon target, IBattle battle) { if (aiMove.Move.SecondaryEffect != null && _handlers.MoveWillFailAgainstTarget(aiMove.Move.SecondaryEffect.Name, new MoveOption(aiMove, battle, target))) return true; if (aiMove.Move.Priority > 0) { if (target.BattleData?.SideIndex != user.BattleData?.SideIndex) { // Psychic Terrain makes all priority moves fail if the target is affected if (battle.TerrainName == PsychicTerrainName && !target.IsFloating) { return true; } // Dazzling and Queenly Majesty prevent priority moves from being used against the Pokémon with those abilities if (target.BattleData?.BattleSide.Pokemon.WhereNotNull().Any(x => x.ActiveAbility?.Name == DazzlingName || x.ActiveAbility?.Name == QueenlyMajestyName) == true) { return true; } } } // TODO: Check immunity because of ability var moveType = aiMove.Move.MoveType; var typeEffectiveness = battle.Library.StaticLibrary.Types.GetEffectiveness(moveType, target.Types); if (aiMove.Move.Category != MoveCategory.Status && typeEffectiveness == 0) return true; if (user.ActiveAbility?.Name == PranksterName && aiMove.Move.Category == MoveCategory.Status && target.Types.Any(x => x.Name == DarkName) && target.BattleData?.SideIndex != user.BattleData?.SideIndex) return true; if (aiMove.Move.Category != MoveCategory.Status && moveType.Name == GroundName && target.IsFloating) return true; if (aiMove.Move.HasFlag(PowderName) && !AffectedByPowder(target)) return true; if (target.Volatile.Contains(SubstituteName) && aiMove.Move.Category == MoveCategory.Status && user.ActiveAbility?.Name != InfiltratorName) return true; return false; } private int GetMoveScore(IPokemon user, AIMoveState aiMove, IBattle battle, IReadOnlyList? targets = null) { var score = MoveBaseScore; if (targets != null) { score = 0; var affectedTargets = 0; foreach (var target in targets) { var targetScore = GetMoveScoreAgainstTarget(user, aiMove, target, battle); if (targetScore < 0) continue; score += targetScore; affectedTargets++; } if (affectedTargets == 0 && CanPredictMoveFailure) { return MoveFailScore; } if (affectedTargets > 0) score = (int)(score / (float)affectedTargets); } if (ScoreMoves) { if (aiMove.Move.SecondaryEffect != null) { _handlers.ApplyMoveEffectScore(aiMove.Move.SecondaryEffect.Name, new MoveOption(aiMove, battle, null), ref score); _handlers.ApplyGenerateMoveScoreModifiers(new MoveOption(aiMove, battle, null), ref score); } } if (score < 0) score = 0; return score; } private int GetMoveScoreAgainstTarget(IPokemon user, AIMoveState aiMove, IPokemon target, IBattle battle) { if (CanPredictMoveFailure && PredictMoveFailureAgainstTarget(user, aiMove, target, battle)) { AILogging.LogInformation($"{user} is considering {aiMove.Move.Name} against {target} but it will fail."); return -1; } var score = MoveBaseScore; if (ScoreMoves && aiMove.Move.SecondaryEffect != null) { var estimatedDamage = AIHelpers.CalculateDamageEstimation(aiMove.Move, user, target, battle.Library); var moveOption = new MoveOption(aiMove, battle, target, estimatedDamage); _handlers.ApplyMoveEffectAgainstTargetScore(aiMove.Move.SecondaryEffect.Name, moveOption, ref score); _handlers.ApplyGenerateMoveAgainstTargetScoreModifiers(moveOption, ref score); } if (aiMove.Move.Target.TargetsFoe() && target.BattleData?.SideIndex == user.BattleData?.SideIndex && target.BattleData?.Position != user.BattleData?.Position) { if (score == MoveUselessScore) return -1; score = (int)(1.85f * MoveBaseScore - score); } return score; } private static readonly StringKey GrassName = new("grass"); private static readonly StringKey OvercoatName = new("overcoat"); private static readonly StringKey SafetyGogglesName = new("safety_goggles"); private static bool AffectedByPowder(IPokemon pokemon) { if (pokemon.Types.Any(x => x.Name == GrassName)) return false; if (pokemon.ActiveAbility?.Name == OvercoatName) return false; if (pokemon.HasHeldItem(SafetyGogglesName)) return false; return true; } }