410 lines
17 KiB
C#
410 lines
17 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
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;
|
|
|
|
public interface IExplicitAI
|
|
{
|
|
public bool TrainerHighSkill { get; }
|
|
public bool TrainerMediumSkill { get; }
|
|
public IRandom Random { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// An explicit AI that has explicitly written logic for each Pokémon and move.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is heavily based on the AI used in <a href="https://github.com/Maruno17/pokemon-essentials">Pokémon Essentials</a>
|
|
/// </remarks>
|
|
public partial class ExplicitAI : PokemonAI, IExplicitAI
|
|
{
|
|
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 SkillFlags _skillFlags = new();
|
|
|
|
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 class SkillFlags
|
|
{
|
|
// TODO: Make these configurable
|
|
public bool CanPredictMoveFailure { get; set; } = true;
|
|
public bool ScoreMoves { get; set; } = true;
|
|
public bool ConsiderSwitching { get; set; } = true;
|
|
public bool ReserveLastPokemon { get; set; } = true;
|
|
public bool UsePokemonInOrder { get; set; } = true;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ExplicitAI(IDynamicLibrary library) : base("explicit")
|
|
{
|
|
_handlers = library.ExplicitAIHandlers;
|
|
}
|
|
|
|
public bool TrainerHighSkill => TrainerSkill >= 45;
|
|
public bool TrainerMediumSkill => TrainerSkill >= 32;
|
|
|
|
/// <inheritdoc />
|
|
public IRandom Random => _random;
|
|
|
|
/// <inheritdoc />
|
|
public override ITurnChoice GetChoice(IBattle battle, IPokemon pokemon)
|
|
{
|
|
if (battle.HasForcedTurn(pokemon, out var choice))
|
|
return choice;
|
|
if (TryChooseToSwitchOut(battle, pokemon, false, out var turnChoice))
|
|
{
|
|
AILogging.LogInformation($"{pokemon} is switching out.");
|
|
return turnChoice;
|
|
}
|
|
|
|
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);
|
|
if (TrainerHighSkill && CanSwitch(pokemon))
|
|
{
|
|
var badMoves = false;
|
|
if (maxScore <= MoveUselessScore)
|
|
{
|
|
badMoves = CanAttack(pokemon);
|
|
if (!badMoves && _random.GetInt(100) < 25)
|
|
badMoves = true;
|
|
}
|
|
else if (maxScore < MoveBaseScore * MoveScoreThreshold && pokemon.BattleData?.TurnsOnField > 2 &&
|
|
_random.GetInt(100) < 80)
|
|
{
|
|
badMoves = true;
|
|
}
|
|
if (badMoves)
|
|
{
|
|
AILogging.LogInformation($"{pokemon} has no good moves, considering switching.");
|
|
if (TryChooseToSwitchOut(battle, pokemon, badMoves, out var switchChoice))
|
|
{
|
|
return switchChoice;
|
|
}
|
|
}
|
|
}
|
|
|
|
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<IScriptChangeMove>(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 (_skillFlags.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<IPokemon>();
|
|
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(this, 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(this,
|
|
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<IPokemon>? 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 && _skillFlags.CanPredictMoveFailure)
|
|
{
|
|
return MoveFailScore;
|
|
}
|
|
if (affectedTargets > 0)
|
|
score = (int)(score / (float)affectedTargets);
|
|
}
|
|
if (_skillFlags.ScoreMoves)
|
|
{
|
|
if (aiMove.Move.SecondaryEffect != null)
|
|
{
|
|
_handlers.ApplyMoveEffectScore(this, aiMove.Move.SecondaryEffect.Name,
|
|
new MoveOption(aiMove, battle, null), ref score);
|
|
_handlers.ApplyGenerateMoveScoreModifiers(this, 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 (_skillFlags.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 (_skillFlags.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(this, aiMove.Move.SecondaryEffect.Name, moveOption, ref score);
|
|
_handlers.ApplyGenerateMoveAgainstTargetScoreModifiers(this, 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;
|
|
}
|
|
|
|
private static bool CanAttack(IPokemon pokemon)
|
|
{
|
|
if (pokemon.Volatile.Contains("requires_recharge"))
|
|
return false;
|
|
if (pokemon.HasStatus("frozen") || pokemon.HasStatus("sleep"))
|
|
return false;
|
|
if (pokemon.ActiveAbility?.Name == "truant" && pokemon.Volatile.Contains("truant_effect"))
|
|
return false;
|
|
if (pokemon.Volatile.Contains("flinch_effect"))
|
|
return false;
|
|
return true;
|
|
}
|
|
} |