Implements AI Switching
All checks were successful
Build / Build (push) Successful in 58s

This commit is contained in:
Deukhoofd 2025-07-12 13:03:00 +02:00
parent 364d4b9080
commit bf83b25238
Signed by: Deukhoofd
GPG Key ID: F63E044490819F6F
34 changed files with 903 additions and 226 deletions

View File

@ -5,6 +5,8 @@ using PkmnLib.Dynamic.AI;
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Plugin.Gen7;
using Serilog;
using Serilog.Core;
using Serilog.Events;
namespace AIRunner;
@ -12,9 +14,12 @@ internal static class Program
{
private static List<PokemonAI>? _availableAIs;
internal static LoggingLevelSwitch LogLevelSwitch { get; } = new(LogEventLevel.Information);
private static Task<int> Main(string[] args)
{
Log.Logger = new LoggerConfiguration().MinimumLevel.Information().WriteTo.Console().CreateLogger();
Log.Logger = new LoggerConfiguration().MinimumLevel.ControlledBy(LogLevelSwitch).WriteTo.Console()
.CreateLogger();
Log.Information("Starting AI Runner...");
AILogging.LogHandler = Log.Debug;
var library = DynamicLibraryImpl.Create([
@ -24,6 +29,10 @@ internal static class Program
var testCommand = new Command("test", "Run two AIs against each other")
{
new Option<bool>("--debug")
{
Description = "Enable debug logging.",
},
new Option<string>("--ai1")
{
Description = "The name of the first AI script to run against the second AI.",
@ -55,6 +64,9 @@ internal static class Program
};
testCommand.SetAction(result =>
{
var debug = result.GetValue<bool>("--debug");
LogLevelSwitch.MinimumLevel = debug ? LogEventLevel.Debug : LogEventLevel.Information;
var ai1Name = result.GetRequiredValue<string>("--ai1");
var ai2Name = result.GetRequiredValue<string>("--ai2");
var ai1 = _availableAIs!.First(a =>

View File

@ -32,6 +32,7 @@ public static class TestCommandRunner
randoms[i] = new RandomImpl(rootRandom.GetInt());
battleTasks[i] = Task.CompletedTask; // Initialize tasks to avoid null references
}
const int pokemonPerParty = 6;
// Show a progress bar if debug logging is not enabled.
// We disable this if debug logging is on, to prevent annoying console output where the progress bar is drawn in
@ -55,7 +56,7 @@ public static class TestCommandRunner
{
Log.Debug("Battle {BattleNumber}: {AI1} vs {AI2}", index + 1, ai1.Name, ai2.Name);
var random = randoms[taskIndex];
var battle = GenerateBattle(library, 3, random);
var battle = GenerateBattle(library, pokemonPerParty, random);
var timePerTurn = new List<double>(20);
while (!battle.HasEnded)
{

View File

@ -0,0 +1,153 @@
using System.Diagnostics.CodeAnalysis;
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 partial class ExplicitAI
{
private bool TryChooseToSwitchOut(IBattle battle, IPokemon pokemon, bool terribleMoves,
[NotNullWhen(true)] out ITurnChoice? choice)
{
choice = null;
if (battle.IsWildBattle)
return false;
if (TrainerHighSkill)
{
var opponentSide = battle.Sides.First(x => x != pokemon.BattleData?.BattleSide);
var foeCanAct = opponentSide.Pokemon.WhereNotNull().Any(CanAttack);
if (!foeCanAct)
return false;
}
var party = battle.Parties.FirstOrDefault(x => x.IsResponsibleForIndex(
new ResponsibleIndex(pokemon.BattleData!.SideIndex, pokemon.BattleData.Position)));
if (party is null)
return false;
var usablePokemon = party.GetUsablePokemonNotInField().ToList();
if (!terribleMoves)
{
if (!_skillFlags.ConsiderSwitching)
return false;
if (!usablePokemon.Any())
return false;
var shouldSwitch = _handlers.ShouldSwitch(this, pokemon, battle, usablePokemon);
if (shouldSwitch && TrainerMediumSkill)
{
if (_handlers.ShouldNotSwitch(this, pokemon, battle, usablePokemon))
{
shouldSwitch = false;
}
}
if (!shouldSwitch)
return false;
}
var battleSide = pokemon.BattleData!.BattleSide;
var bestReplacement =
ChooseBestReplacementPokemon(pokemon.BattleData!.Position, terribleMoves, usablePokemon, battleSide);
if (bestReplacement is null)
{
AILogging.LogInformation(
$"ExplicitAI: No suitable replacement Pokemon found for {pokemon} at position {pokemon.BattleData.Position}.");
return false;
}
choice = new SwitchChoice(pokemon, bestReplacement);
return true;
}
private IPokemon? ChooseBestReplacementPokemon(byte position, bool terribleMoves,
IReadOnlyList<IPokemon> usablePokemon, IBattleSide battleSide)
{
var options = usablePokemon.Where((pokemon, index) =>
{
if (_skillFlags.ReserveLastPokemon && index == usablePokemon.Count - 1 && usablePokemon.Count > 1)
return false; // Don't switch to the last Pokemon if there are others available.
if (_skillFlags.UsePokemonInOrder && index != 0)
return false;
return true;
}).ToList();
if (options.Count == 0)
return null;
var ratedOptions = options
.Select(pokemon => new { Pokemon = pokemon, Score = RateReplacementPokemon(pokemon, battleSide) })
.OrderBy(x => x.Score).ToList();
if (TrainerHighSkill && !terribleMoves)
{
if (ratedOptions.First().Score < 100)
return null;
}
return ratedOptions.First().Pokemon;
}
private static readonly StringKey HeavyDutyBootsName = "heavy_duty_boots";
private static readonly StringKey ToxicSpikesName = "toxic_spikes";
private static readonly StringKey StickyWebName = "sticky_web";
private int RateReplacementPokemon(IPokemon pokemon, IBattleSide battleSide)
{
var score = 0;
var types = pokemon.Types;
var entryDamage = CalculateEntryHazardDamage(pokemon, battleSide);
if (entryDamage >= pokemon.CurrentHealth)
score -= 50;
else if (entryDamage > 0)
score -= 50 * (int)Math.Round((double)entryDamage / pokemon.MaxHealth, MidpointRounding.AwayFromZero);
if (!pokemon.HasHeldItem(HeavyDutyBootsName) && !pokemon.IsFloating)
{
if (battleSide.VolatileScripts.Contains(ToxicSpikesName) && CanBePoisoned(pokemon, battleSide.Battle))
{
score -= 20;
}
if (battleSide.VolatileScripts.Contains(StickyWebName))
{
score -= 15;
}
}
var opponentSide = battleSide.Battle.Sides.First(x => x != battleSide);
foreach (var foe in opponentSide.Pokemon.WhereNotNull())
{
var lastMoveUsed = foe.BattleData?.LastMoveChoice;
if (lastMoveUsed is null || lastMoveUsed.ChosenMove.MoveData.Category == MoveCategory.Status)
continue;
var moveType = lastMoveUsed.ChosenMove.MoveData.MoveType;
var effectiveness = pokemon.Library.StaticLibrary.Types.GetEffectiveness(moveType, types);
score -= (int)(lastMoveUsed.ChosenMove.MoveData.BasePower * effectiveness / 5);
}
foreach (var learnedMove in pokemon.Moves.WhereNotNull())
{
if (learnedMove.MoveData.BasePower == 0 || learnedMove is { CurrentPp: 0, MaxPp: > 0 })
continue;
foreach (var foe in opponentSide.Pokemon.WhereNotNull())
{
if (CanAbsorbMove(foe, learnedMove.MoveData, learnedMove.MoveData.MoveType, battleSide.Battle))
continue;
var effectiveness =
pokemon.Library.StaticLibrary.Types.GetEffectiveness(learnedMove.MoveData.MoveType, foe.Types);
score += (int)(learnedMove.MoveData.BasePower * effectiveness / 10);
}
}
return score;
}
public static uint CalculateEntryHazardDamage(IPokemon pokemon, IBattleSide side)
{
var damage = 0u;
side.RunScriptHook<IAIInfoScriptExpectedEntryDamage>(x => x.ExpectedEntryDamage(pokemon, ref damage));
return damage;
}
private static bool CanSwitch(IPokemon pokemon)
{
var battleData = pokemon.BattleData;
if (battleData == null)
return false;
if (battleData.Battle.IsWildBattle)
return false;
var partyForIndex = battleData.Battle.Parties.FirstOrDefault(x =>
x.IsResponsibleForIndex(new ResponsibleIndex(battleData.SideIndex, battleData.Position)));
return partyForIndex != null && partyForIndex.HasUsablePokemonNotInField();
}
}

View File

@ -0,0 +1,89 @@
using PkmnLib.Dynamic.Models;
using PkmnLib.Static;
using PkmnLib.Static.Moves;
using PkmnLib.Static.Utils;
namespace PkmnLib.Dynamic.AI.Explicit;
public partial class ExplicitAI
{
private static readonly StringKey MistyTerrainName = "misty_terrain";
private static readonly StringKey PoisonName = "poison";
private static readonly StringKey SteelName = "steel";
private static readonly StringKey ImmunityName = "immunity";
private static readonly StringKey PastelVeilName = "pastel_veil";
private static readonly StringKey FlowerVeilName = "flower_veil";
private static readonly StringKey LeafGuardName = "leaf_guard";
private static readonly StringKey ComatoseName = "comatose";
private static readonly StringKey ShieldsDownName = "shields_down";
private static readonly StringKey HarshSunlightName = "harsh_sunlight";
private static readonly StringKey DesolateLandsName = "desolate_lands";
private static readonly StringKey BulletproofName = "bulletproof";
private static readonly StringKey FlashFireName = "flash_fire";
private static readonly StringKey LightningRodName = "lightning_rod";
private static readonly StringKey MotorDriveName = "motor_drive";
private static readonly StringKey VoltAbsorbName = "volt_absorb";
private static readonly StringKey SapSipperName = "sap_sipper";
private static readonly StringKey SoundproofName = "soundproof";
private static readonly StringKey StormDrainName = "storm_drain";
private static readonly StringKey WaterAbsorbName = "water_absorb";
private static readonly StringKey DrySkinName = "dry_skin";
private static readonly StringKey TelepathyName = "telepathy";
private static readonly StringKey WonderGuardName = "wonder_guard";
private static readonly StringKey FireName = "fire";
private static readonly StringKey ElectricName = "electric";
private static readonly StringKey WaterName = "water";
private static bool CanBePoisoned(IPokemon pokemon, IBattle battle)
{
if (battle.TerrainName == MistyTerrainName)
return false;
if (pokemon.Types.Any(x => x.Name == PoisonName || x.Name == SteelName))
return false;
if (pokemon.ActiveAbility?.Name == ImmunityName)
return false;
if (pokemon.ActiveAbility?.Name == PastelVeilName)
return false;
if (pokemon.ActiveAbility?.Name == FlowerVeilName && pokemon.Types.Any(x => x.Name == GrassName))
return false;
if ((pokemon.ActiveAbility?.Name == LeafGuardName && battle.WeatherName == HarshSunlightName) ||
battle.WeatherName == DesolateLandsName)
return false;
if (pokemon.ActiveAbility?.Name == ComatoseName && pokemon.Species.Name == "komala")
return false;
if (pokemon.ActiveAbility?.Name == ShieldsDownName && pokemon.Species.Name == "minior" &&
pokemon.Form.Name.Contains("-meteor"))
return false;
return true;
}
private static bool CanAbsorbMove(IPokemon pokemon, IMoveData move, TypeIdentifier moveType, IBattle battle)
{
if (pokemon.ActiveAbility == null)
return false;
var abilityName = pokemon.ActiveAbility.Name;
if (abilityName == BulletproofName)
return move.HasFlag("bomb");
if (abilityName == FlashFireName)
return moveType.Name == FireName;
if (abilityName == LightningRodName || abilityName == MotorDriveName || abilityName == VoltAbsorbName)
return moveType.Name == ElectricName;
if (abilityName == SapSipperName)
return moveType.Name == GrassName;
if (abilityName == SoundproofName)
return move.HasFlag("sound");
if (abilityName == StormDrainName || abilityName == WaterAbsorbName || abilityName == DrySkinName)
return moveType.Name == WaterName;
if (abilityName == TelepathyName)
return false;
if (abilityName == WonderGuardName)
{
var effectiveness = battle.Library.StaticLibrary.Types.GetEffectiveness(moveType, pokemon.Types);
return effectiveness <= 1.0f;
}
return false;
}
}

View File

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using PkmnLib.Dynamic.BattleFlow;
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models;
@ -8,21 +9,27 @@ 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 class ExplicitAI : PokemonAI
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 bool CanPredictMoveFailure => true; // TODO: This should be configurable
private bool ScoreMoves => true; // TODO: This should be configurable
private SkillFlags _skillFlags = new();
private float MoveScoreThreshold => (float)(0.6f + 0.35f * Math.Sqrt(Math.Min(TrainerSkill, 100) / 100f));
@ -30,17 +37,39 @@ public class ExplicitAI : PokemonAI
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)
{
@ -48,7 +77,29 @@ public class ExplicitAI : PokemonAI
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
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();
@ -122,7 +173,7 @@ public class ExplicitAI : PokemonAI
}
}
var aiMove = new AIMoveState(user, moveData);
if (CanPredictMoveFailure && PredictMoveFailure(user, battle, aiMove))
if (_skillFlags.CanPredictMoveFailure && PredictMoveFailure(user, battle, aiMove))
{
AILogging.LogInformation($"{user} is considering {aiMove.Move.Name} but it will fail.");
AddMoveToChoices(index, MoveFailScore);
@ -210,8 +261,8 @@ public class ExplicitAI : PokemonAI
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));
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");
@ -226,8 +277,8 @@ public class ExplicitAI : PokemonAI
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)))
if (aiMove.Move.SecondaryEffect != null && _handlers.MoveWillFailAgainstTarget(this,
aiMove.Move.SecondaryEffect.Name, new MoveOption(aiMove, battle, target)))
return true;
if (aiMove.Move.Priority > 0)
{
@ -281,20 +332,20 @@ public class ExplicitAI : PokemonAI
score += targetScore;
affectedTargets++;
}
if (affectedTargets == 0 && CanPredictMoveFailure)
if (affectedTargets == 0 && _skillFlags.CanPredictMoveFailure)
{
return MoveFailScore;
}
if (affectedTargets > 0)
score = (int)(score / (float)affectedTargets);
}
if (ScoreMoves)
if (_skillFlags.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);
_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)
@ -304,18 +355,18 @@ public class ExplicitAI : PokemonAI
private int GetMoveScoreAgainstTarget(IPokemon user, AIMoveState aiMove, IPokemon target, IBattle battle)
{
if (CanPredictMoveFailure && PredictMoveFailureAgainstTarget(user, aiMove, target, 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 (ScoreMoves && aiMove.Move.SecondaryEffect != null)
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(aiMove.Move.SecondaryEffect.Name, moveOption, ref score);
_handlers.ApplyGenerateMoveAgainstTargetScoreModifiers(moveOption, ref score);
_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 &&
@ -343,4 +394,17 @@ public class ExplicitAI : PokemonAI
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;
}
}

View File

@ -5,11 +5,14 @@ namespace PkmnLib.Dynamic.AI.Explicit;
public record struct MoveOption(AIMoveState Move, IBattle Battle, IPokemon? Target, uint EstimatedDamage = 0);
public delegate bool AIBoolHandler(MoveOption option);
public delegate bool AIBoolHandler(IExplicitAI ai, MoveOption option);
public delegate void AIMoveBasePowerHandler(MoveOption option, ref int score);
public delegate bool AISwitchBoolHandler(IExplicitAI ai, IPokemon pokemon, IBattle battle,
IReadOnlyList<IPokemon> reserves);
public delegate void AIScoreMoveHandler(MoveOption option, ref int score);
public delegate void AIMoveBasePowerHandler(IExplicitAI ai, MoveOption option, ref int score);
public delegate void AIScoreMoveHandler(IExplicitAI ai, MoveOption option, ref int score);
public interface IReadOnlyExplicitAIHandlers
{
@ -21,7 +24,7 @@ public interface IReadOnlyExplicitAIHandlers
/// <summary>
/// Checks if a move will fail based on the provided function code and options.
/// </summary>
bool MoveWillFail(StringKey functionCode, MoveOption option);
bool MoveWillFail(IExplicitAI ai, StringKey functionCode, MoveOption option);
/// <summary>
/// A list of checks to determine if a move will fail against a target.
@ -31,7 +34,7 @@ public interface IReadOnlyExplicitAIHandlers
/// <summary>
/// Checks if a move will fail against a target based on the provided function code and options.
/// </summary>
bool MoveWillFailAgainstTarget(StringKey functionCode, MoveOption option);
bool MoveWillFailAgainstTarget(IExplicitAI ai, StringKey functionCode, MoveOption option);
/// <summary>
/// A list of handlers to apply scores for move effects.
@ -41,7 +44,7 @@ public interface IReadOnlyExplicitAIHandlers
/// <summary>
/// Applies the score for a move effect based on the provided name and options.
/// </summary>
void ApplyMoveEffectScore(StringKey name, MoveOption option, ref int score);
void ApplyMoveEffectScore(IExplicitAI ai, StringKey name, MoveOption option, ref int score);
/// <summary>
/// A list of handlers to apply scores for move effects against a target.
@ -51,7 +54,7 @@ public interface IReadOnlyExplicitAIHandlers
/// <summary>
/// Applies the score for a move effect against a target based on the provided name and options.
/// </summary>
void ApplyMoveEffectAgainstTargetScore(StringKey name, MoveOption option, ref int score);
void ApplyMoveEffectAgainstTargetScore(IExplicitAI ai, StringKey name, MoveOption option, ref int score);
/// <summary>
/// A list of handlers to determine the base power of a move.
@ -61,7 +64,7 @@ public interface IReadOnlyExplicitAIHandlers
/// <summary>
/// Applies the base power for a move based on the provided name and options.
/// </summary>
void GetBasePower(StringKey name, MoveOption option, ref int power);
void GetBasePower(IExplicitAI ai, StringKey name, MoveOption option, ref int power);
/// <summary>
/// A list of handlers to apply scores for general move effectiveness.
@ -71,10 +74,7 @@ public interface IReadOnlyExplicitAIHandlers
/// <summary>
/// Applies the score for a general move based on the provided option.
/// </summary>
/// <param name="option"></param>
/// <param name="score"></param>
/// <returns></returns>
void ApplyGenerateMoveScoreModifiers(MoveOption option, ref int score);
void ApplyGenerateMoveScoreModifiers(IExplicitAI ai, MoveOption option, ref int score);
/// <summary>
/// A list of handlers to apply scores for general move effectiveness against a target.
@ -84,10 +84,16 @@ public interface IReadOnlyExplicitAIHandlers
/// <summary>
/// Applies the score for a general move against a target based on the provided option.
/// </summary>
void ApplyGenerateMoveAgainstTargetScoreModifiers(MoveOption option, ref int score);
void ApplyGenerateMoveAgainstTargetScoreModifiers(IExplicitAI ai, MoveOption option, ref int score);
IReadOnlyDictionary<StringKey, AISwitchBoolHandler> ShouldSwitchFunctions { get; }
bool ShouldSwitch(IExplicitAI ai, IPokemon pokemon, IBattle battle, IReadOnlyList<IPokemon> reserves);
IReadOnlyDictionary<StringKey, AISwitchBoolHandler> ShouldNotSwitchFunctions { get; }
bool ShouldNotSwitch(IExplicitAI ai, IPokemon pokemon, IBattle battle, IReadOnlyList<IPokemon> reserves);
IReadOnlyDictionary<StringKey, AIBoolHandler> ShouldSwitch { get; }
IReadOnlyDictionary<StringKey, AIBoolHandler> ShouldNotSwitch { get; }
IReadOnlyDictionary<StringKey, AIScoreMoveHandler> AbilityRanking { get; }
}
@ -99,8 +105,8 @@ public class ExplicitAIHandlers : IReadOnlyExplicitAIHandlers
public FunctionHandlerDictionary<AIBoolHandler> MoveFailureCheck { get; } = new();
/// <inheritdoc />
public bool MoveWillFail(StringKey functionCode, MoveOption option) =>
MoveFailureCheck.TryGetValue(functionCode, out var handler) && handler(option);
public bool MoveWillFail(IExplicitAI ai, StringKey functionCode, MoveOption option) =>
MoveFailureCheck.TryGetValue(functionCode, out var handler) && handler(ai, option);
public FunctionHandlerDictionary<AIBoolHandler> MoveFailureAgainstTargetCheck { get; } = new();
@ -109,8 +115,8 @@ public class ExplicitAIHandlers : IReadOnlyExplicitAIHandlers
MoveFailureAgainstTargetCheck;
/// <inheritdoc />
public bool MoveWillFailAgainstTarget(StringKey functionCode, MoveOption option) =>
MoveFailureAgainstTargetCheck.TryGetValue(functionCode, out var handler) && handler(option);
public bool MoveWillFailAgainstTarget(IExplicitAI ai, StringKey functionCode, MoveOption option) =>
MoveFailureAgainstTargetCheck.TryGetValue(functionCode, out var handler) && handler(ai, option);
public FunctionHandlerDictionary<AIScoreMoveHandler> MoveEffectScore { get; } = [];
@ -121,10 +127,10 @@ public class ExplicitAIHandlers : IReadOnlyExplicitAIHandlers
public FunctionHandlerDictionary<AIScoreMoveHandler> MoveEffectAgainstTargetScore = [];
/// <inheritdoc />
public void ApplyMoveEffectScore(StringKey name, MoveOption option, ref int score)
public void ApplyMoveEffectScore(IExplicitAI ai, StringKey name, MoveOption option, ref int score)
{
if (MoveEffectScore.TryGetValue(name, out var handler))
handler(option, ref score);
handler(ai, option, ref score);
}
/// <inheritdoc />
@ -134,10 +140,10 @@ public class ExplicitAIHandlers : IReadOnlyExplicitAIHandlers
public FunctionHandlerDictionary<AIMoveBasePowerHandler> MoveBasePower = [];
/// <inheritdoc />
public void ApplyMoveEffectAgainstTargetScore(StringKey name, MoveOption option, ref int score)
public void ApplyMoveEffectAgainstTargetScore(IExplicitAI ai, StringKey name, MoveOption option, ref int score)
{
if (MoveEffectAgainstTargetScore.TryGetValue(name, out var handler))
handler(option, ref score);
handler(ai, option, ref score);
}
/// <inheritdoc />
@ -147,10 +153,10 @@ public class ExplicitAIHandlers : IReadOnlyExplicitAIHandlers
public FunctionHandlerDictionary<AIScoreMoveHandler> GeneralMoveScore = [];
/// <inheritdoc />
public void GetBasePower(StringKey name, MoveOption option, ref int power)
public void GetBasePower(IExplicitAI ai, StringKey name, MoveOption option, ref int power)
{
if (MoveBasePower.TryGetValue(name, out var handler))
handler(option, ref power);
handler(ai, option, ref power);
}
/// <inheritdoc />
@ -160,11 +166,11 @@ public class ExplicitAIHandlers : IReadOnlyExplicitAIHandlers
public FunctionHandlerDictionary<AIScoreMoveHandler> GeneralMoveAgainstTargetScore = [];
/// <inheritdoc />
public void ApplyGenerateMoveScoreModifiers(MoveOption option, ref int score)
public void ApplyGenerateMoveScoreModifiers(IExplicitAI ai, MoveOption option, ref int score)
{
foreach (var (_, handler) in GeneralMoveScore)
{
handler(option, ref score);
handler(ai, option, ref score);
}
}
@ -172,27 +178,51 @@ public class ExplicitAIHandlers : IReadOnlyExplicitAIHandlers
IReadOnlyDictionary<StringKey, AIScoreMoveHandler> IReadOnlyExplicitAIHandlers.GeneralMoveAgainstTargetScore =>
GeneralMoveAgainstTargetScore;
public FunctionHandlerDictionary<AIBoolHandler> ShouldSwitch = [];
/// <inheritdoc />
public void ApplyGenerateMoveAgainstTargetScoreModifiers(MoveOption option, ref int score)
public void ApplyGenerateMoveAgainstTargetScoreModifiers(IExplicitAI ai, MoveOption option, ref int score)
{
foreach (var (_, handler) in GeneralMoveAgainstTargetScore)
{
handler(option, ref score);
handler(ai, option, ref score);
}
}
/// <inheritdoc />
IReadOnlyDictionary<StringKey, AIBoolHandler> IReadOnlyExplicitAIHandlers.ShouldSwitch => ShouldSwitch;
public FunctionHandlerDictionary<AIBoolHandler> ShouldNotSwitch = [];
public FunctionHandlerDictionary<AISwitchBoolHandler> ShouldSwitchFunctions = [];
/// <inheritdoc />
IReadOnlyDictionary<StringKey, AIBoolHandler> IReadOnlyExplicitAIHandlers.ShouldNotSwitch => ShouldNotSwitch;
IReadOnlyDictionary<StringKey, AISwitchBoolHandler> IReadOnlyExplicitAIHandlers.ShouldSwitchFunctions =>
ShouldSwitchFunctions;
public bool ShouldSwitch(IExplicitAI ai, IPokemon pokemon, IBattle battle, IReadOnlyList<IPokemon> reserves)
{
var shouldSwitch = false;
foreach (var (_, handler) in ShouldSwitchFunctions)
{
shouldSwitch |= handler(ai, pokemon, battle, reserves);
}
return shouldSwitch;
}
public FunctionHandlerDictionary<AISwitchBoolHandler> ShouldNotSwitchFunctions = [];
/// <inheritdoc />
/// <inheritdoc />
IReadOnlyDictionary<StringKey, AISwitchBoolHandler> IReadOnlyExplicitAIHandlers.ShouldNotSwitchFunctions =>
ShouldNotSwitchFunctions;
public FunctionHandlerDictionary<AIScoreMoveHandler> AbilityRanking = [];
/// <inheritdoc />
public bool ShouldNotSwitch(IExplicitAI ai, IPokemon pokemon, IBattle battle, IReadOnlyList<IPokemon> reserves)
{
var shouldNotSwitch = false;
foreach (var (_, handler) in ShouldNotSwitchFunctions)
{
shouldNotSwitch |= handler(ai, pokemon, battle, reserves);
}
return shouldNotSwitch;
}
/// <inheritdoc />
IReadOnlyDictionary<StringKey, AIScoreMoveHandler> IReadOnlyExplicitAIHandlers.AbilityRanking =>
AbilityRanking;

View File

@ -15,6 +15,7 @@ public static class MoveTurnExecutor
{
internal static void ExecuteMoveChoice(IBattle battle, IMoveChoice moveChoice)
{
moveChoice.User.BattleData!.LastMoveChoice = moveChoice;
var chosenMove = moveChoice.ChosenMove;
var useMove = chosenMove.MoveData;

View File

@ -252,7 +252,7 @@ public class BattleImpl : ScriptSource, IBattle
/// <inheritdoc />
public bool CanSlotBeFilled(byte side, byte position) => Parties.Any(x =>
x.IsResponsibleForIndex(new ResponsibleIndex(side, position)) && x.HasPokemonNotInField());
x.IsResponsibleForIndex(new ResponsibleIndex(side, position)) && x.HasUsablePokemonNotInField());
/// <inheritdoc />
public void ValidateBattleState()

View File

@ -21,7 +21,12 @@ public interface IBattleParty : IDeepCloneable
/// <summary>
/// Whether the party has a living Pokemon left that is not in the field.
/// </summary>
bool HasPokemonNotInField();
bool HasUsablePokemonNotInField();
/// <summary>
/// Gets all usable Pokemon that are not currently in the field.
/// </summary>
IEnumerable<IPokemon> GetUsablePokemonNotInField();
}
/// <summary>
@ -49,6 +54,10 @@ public class BattlePartyImpl : IBattleParty
public bool IsResponsibleForIndex(ResponsibleIndex index) => _responsibleIndices.Contains(index);
/// <inheritdoc />
public bool HasPokemonNotInField() =>
public bool HasUsablePokemonNotInField() =>
Party.WhereNotNull().Any(x => x.IsUsable && x.BattleData?.IsOnBattlefield != true);
/// <inheritdoc />
public IEnumerable<IPokemon> GetUsablePokemonNotInField() =>
Party.WhereNotNull().Where(x => x.IsUsable && x.BattleData?.IsOnBattlefield != true);
}

View File

@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using PkmnLib.Dynamic.Events;
using PkmnLib.Dynamic.Libraries;
using PkmnLib.Dynamic.Models.Choices;
using PkmnLib.Dynamic.Models.Serialized;
using PkmnLib.Dynamic.ScriptHandling;
using PkmnLib.Static;
@ -489,6 +490,8 @@ public interface IPokemonBattleData : IDeepCloneable
/// </summary>
uint SwitchInTurn { get; internal set; }
uint TurnsOnField { get; }
/// <summary>
/// The side the Pokémon is on.
/// </summary>
@ -503,6 +506,11 @@ public interface IPokemonBattleData : IDeepCloneable
/// The form of the Pokémon at the time it was sent out.
/// </summary>
IForm OriginalForm { get; }
/// <summary>
/// The last move choice executed by the Pokémon.
/// </summary>
IMoveChoice? LastMoveChoice { get; internal set; }
}
/// <inheritdoc cref="IPokemon"/>
@ -1490,6 +1498,9 @@ public class PokemonBattleDataImpl : IPokemonBattleData
/// <inheritdoc />
public uint SwitchInTurn { get; set; }
/// <inheritdoc />
public uint TurnsOnField => Battle.CurrentTurnNumber - SwitchInTurn;
/// <inheritdoc />
public IBattleSide BattleSide => Battle.Sides[SideIndex];
@ -1498,4 +1509,7 @@ public class PokemonBattleDataImpl : IPokemonBattleData
/// <inheritdoc />
public IForm OriginalForm { get; }
/// <inheritdoc />
public IMoveChoice? LastMoveChoice { get; set; }
}

View File

@ -12,5 +12,10 @@
<ItemGroup>
<ProjectReference Include="..\PkmnLib.Static\PkmnLib.Static.csproj"/>
</ItemGroup>
<ItemGroup>
<Compile Update="AI\Explicit\ExplicitAI.*.cs">
<DependentUpon>ExplicitAI.cs</DependentUpon>
</Compile>
</ItemGroup>
</Project>

View File

@ -26,4 +26,16 @@ public interface IAIInfoScriptExpectedEndOfTurnDamage
/// have an end of turn effect, such as Poison or Burn.
/// </summary>
void ExpectedEndOfTurnDamage(IPokemon pokemon, ref int damage);
}
/// <summary>
/// Script for getting the expected entry damage by the AI.
/// </summary>
public interface IAIInfoScriptExpectedEntryDamage
{
/// <summary>
/// This function returns the expected entry damage for the script. This is used for scripts that have
/// an entry hazard effect, such as Spikes or Stealth Rock.
/// </summary>
void ExpectedEntryDamage(IPokemon pokemon, ref uint damage);
}

View File

@ -0,0 +1,36 @@
using PkmnLib.Dynamic.AI.Explicit;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Moves;
namespace PkmnLib.Plugin.Gen7.AI;
public static class AIDamageFunctions
{
internal static void PredictedDamageScore(IExplicitAI ai, MoveOption option, ref int score)
{
var target = option.Target;
if (target == null)
return;
if (option.Move.Move.Category == MoveCategory.Status)
return;
var damage = option.EstimatedDamage;
if (target.Volatile.TryGet<SubstituteEffect>(out var substitute))
{
var health = substitute.Health;
score += (int)Math.Min(15.0f * damage / health, 20);
return;
}
score += (int)Math.Min(15.0f * damage / target.CurrentHealth, 30);
if (damage > target.CurrentHealth * 1.1f)
{
score += 10;
if ((option.Move.Move.HasFlag("multi_hit") && target.CurrentHealth == target.MaxHealth &&
target.ActiveAbility?.Name == "sturdy") || target.HasHeldItem("focus_sash"))
{
score += 8;
}
}
}
}

View File

@ -1,6 +1,7 @@
using PkmnLib.Dynamic.AI.Explicit;
using PkmnLib.Plugin.Gen7.Libraries.Battling;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Plugin.Gen7.Scripts.Status;
using PkmnLib.Static.Moves;
namespace PkmnLib.Plugin.Gen7.AI;
@ -157,6 +158,8 @@ public static class AIHelperFunctions
return true;
}
private static readonly StringKey FoulPlayAbilityName = "foul_play";
private static void GetTargetStatRaiseScoreOne(ref int score, IPokemon target, Statistic stat, sbyte increment,
AIMoveState move, float desireMult = 1)
{
@ -200,7 +203,7 @@ public static class AIHelperFunctions
{
var hasPhysicalMoves = target.Moves.WhereNotNull().Any(x =>
x.MoveData.Category == MoveCategory.Physical &&
x.MoveData.SecondaryEffect?.Name != "foul_play");
x.MoveData.SecondaryEffect?.Name != FoulPlayAbilityName);
var inc = hasPhysicalMoves ? 8 : 12;
score += (int)(inc * incMult);
}
@ -334,4 +337,55 @@ public static class AIHelperFunctions
private static bool Opposes(this IPokemon pokemon, IPokemon target) =>
pokemon.BattleData?.BattleSide != target.BattleData?.BattleSide;
public static bool WantsStatusProblem(IPokemon pokemon, StringKey? status)
{
if (status is null)
return true;
if (pokemon.ActiveAbility != null)
{
if (pokemon.ActiveAbility.Name == "guts" && status != ScriptUtils.ResolveName<Sleep>() &&
status != ScriptUtils.ResolveName<Frozen>() &&
IsStatRaiseWorthwhile(pokemon, Statistic.Attack, 1, true))
{
return true;
}
if (pokemon.ActiveAbility.Name == "marvel_scale" &&
IsStatRaiseWorthwhile(pokemon, Statistic.Defense, 1, true))
{
return true;
}
if (pokemon.ActiveAbility.Name == "quick_feet" && status != ScriptUtils.ResolveName<Sleep>() &&
status != ScriptUtils.ResolveName<Frozen>() && IsStatRaiseWorthwhile(pokemon, Statistic.Speed, 1, true))
{
return true;
}
if (pokemon.ActiveAbility.Name == "flare_boost" && status == ScriptUtils.ResolveName<Burned>() &&
IsStatRaiseWorthwhile(pokemon, Statistic.SpecialAttack, 1, true))
{
return true;
}
if (pokemon.ActiveAbility.Name == "toxic_boost" &&
(status == ScriptUtils.ResolveName<Poisoned>() || status == ScriptUtils.ResolveName<BadlyPoisoned>()) &&
IsStatRaiseWorthwhile(pokemon, Statistic.Attack, 1, true))
{
return true;
}
if (pokemon.ActiveAbility.Name == "poison_heal" && status == ScriptUtils.ResolveName<Poisoned>())
{
return true;
}
if (pokemon.ActiveAbility.Name == "magic_guard")
{
if (status != ScriptUtils.ResolveName<Poisoned>() &&
status != ScriptUtils.ResolveName<BadlyPoisoned>() && status != ScriptUtils.ResolveName<Burned>())
return false;
if (IsStatRaiseWorthwhile(pokemon, Statistic.Attack, 1, true))
{
return true;
}
}
}
return false;
}
}

View File

@ -0,0 +1,208 @@
using PkmnLib.Dynamic.AI.Explicit;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Plugin.Gen7.Scripts.Side;
using PkmnLib.Plugin.Gen7.Scripts.Status;
using PkmnLib.Static.Moves;
namespace PkmnLib.Plugin.Gen7.AI;
public static class AISwitchFunctions
{
internal static void RegisterAISwitchFunctions(ExplicitAIHandlers handlers)
{
handlers.ShouldSwitchFunctions.Add("perish_song", PerishSong);
handlers.ShouldSwitchFunctions.Add("significant_end_of_turn_damage", SignificantEndOfTurnDamage);
handlers.ShouldSwitchFunctions.Add("high_damage_from_foe", HighDamageFromFoe);
handlers.ShouldSwitchFunctions.Add("cure_status_problem_by_switching_out", CureStatusProblemBySwitchingOut);
}
/// <summary>
/// Switch out if the Perish Song effect is about to cause the Pokémon to faint.
/// </summary>
private static bool PerishSong(IExplicitAI ai, IPokemon pokemon, IBattle battle, IReadOnlyList<IPokemon> reserves)
{
if (!pokemon.Volatile.TryGet<PerishSongEffect>(out var effect))
return false;
return effect.Turns <= 1;
}
private static readonly StringKey PoisonHealAbilityName = "poison_heal";
/// <summary>
/// Switch out if the Pokémon is expected to take significant end-of-turn damage.
/// </summary>
private static bool SignificantEndOfTurnDamage(IExplicitAI ai, IPokemon pokemon, IBattle battle,
IReadOnlyList<IPokemon> reserves)
{
var eorDamage = 0;
pokemon.RunScriptHook<IAIInfoScriptExpectedEndOfTurnDamage>(x =>
x.ExpectedEndOfTurnDamage(pokemon, ref eorDamage));
if (eorDamage >= pokemon.CurrentHealth / 2 || eorDamage >= pokemon.MaxHealth / 4)
return true;
if (!ai.TrainerHighSkill || eorDamage <= 0)
return false;
if (pokemon.Volatile.Contains<LeechSeedEffect>() && ai.Random.GetBool())
return true;
if (pokemon.Volatile.Contains<NightmareEffect>())
return true;
if (pokemon.Volatile.Contains<GhostCurseEffect>())
return true;
var statusScript = pokemon.StatusScript.Script;
if (statusScript is BadlyPoisoned { Turns: > 0 } badlyPoisoned &&
pokemon.ActiveAbility?.Name != PoisonHealAbilityName)
{
var poisonDamage = pokemon.MaxHealth / 8;
var nextToxicDamage = pokemon.MaxHealth * badlyPoisoned.GetPoisonMultiplier();
if ((pokemon.CurrentHealth <= nextToxicDamage && pokemon.CurrentHealth > poisonDamage) ||
nextToxicDamage > poisonDamage * 2)
{
return true;
}
}
return false;
}
private static bool HighDamageFromFoe(IExplicitAI ai, IPokemon pokemon, IBattle battle,
IReadOnlyList<IPokemon> reserves)
{
if (!ai.TrainerHighSkill)
return false;
if (pokemon.CurrentHealth >= pokemon.MaxHealth / 2)
return false;
var bigThreat = false;
var opponents = battle.Sides.Where(x => x != pokemon.BattleData?.BattleSide)
.SelectMany(x => x.Pokemon.WhereNotNull());
foreach (var opponent in opponents)
{
if (Math.Abs(opponent.Level - pokemon.Level) > 5)
continue;
var lastMoveUsed = opponent.BattleData?.LastMoveChoice;
if (lastMoveUsed is null)
continue;
var moveData = lastMoveUsed.ChosenMove.MoveData;
if (moveData.Category == MoveCategory.Status)
continue;
var effectiveness = pokemon.Library.StaticLibrary.Types.GetEffectiveness(moveData.MoveType, pokemon.Types);
if (effectiveness <= 1 || moveData.BasePower < 70)
continue;
var switchChange = moveData.BasePower > 90 ? 50 : 25;
bigThreat = ai.Random.GetInt(100) < switchChange;
}
return bigThreat;
}
private static readonly StringKey ImmunityAbilityName = "immunity";
private static readonly StringKey InsomniaAbilityName = "insomnia";
private static readonly StringKey LimberAbilityName = "limber";
private static readonly StringKey MagmaArmorAbilityName = "magma_armor";
private static readonly StringKey VitalSpiritAbilityName = "vital_spirit";
private static readonly StringKey WaterBubbleAbilityName = "water_bubble";
private static readonly StringKey WaterVeilAbilityName = "water_veil";
private static readonly StringKey NaturalCureAbilityName = "natural_cure";
private static readonly StringKey RegeneratorAbilityName = "regenerator";
private static readonly Dictionary<StringKey, List<StringKey>> StatusCureAbilities = new()
{
{
ImmunityAbilityName,
[ScriptUtils.ResolveName<Poisoned>(), ScriptUtils.ResolveName<BadlyPoisoned>()]
},
{ InsomniaAbilityName, [ScriptUtils.ResolveName<Sleep>()] },
{ LimberAbilityName, [ScriptUtils.ResolveName<Paralyzed>()] },
{ MagmaArmorAbilityName, [ScriptUtils.ResolveName<Frozen>()] },
{ VitalSpiritAbilityName, [ScriptUtils.ResolveName<Sleep>()] },
{ WaterBubbleAbilityName, [ScriptUtils.ResolveName<Burned>()] },
{ WaterVeilAbilityName, [ScriptUtils.ResolveName<Burned>()] },
};
/// <summary>
/// Switch out to cure a status problem or heal HP with abilities like Natural Cure or Regenerator.
/// </summary>
private static bool CureStatusProblemBySwitchingOut(IExplicitAI ai, IPokemon pokemon, IBattle battle,
IReadOnlyList<IPokemon> reserves)
{
if (pokemon.ActiveAbility == null)
return false;
// Don't try to cure a status problem/heal a bit of HP if entry hazards will
// KO the battler if it switches back in
var entryHazardDamage = ExplicitAI.CalculateEntryHazardDamage(pokemon, pokemon.BattleData!.BattleSide);
if (entryHazardDamage >= pokemon.CurrentHealth)
return false;
if (pokemon.StatusScript.Script is null)
return false;
var abilityName = pokemon.ActiveAbility.Name;
var statusName = pokemon.StatusScript.Script.Name;
// Check abilities that cure specific status conditions
var canCureStatus = false;
if (abilityName == NaturalCureAbilityName)
{
canCureStatus = true;
}
else if (StatusCureAbilities.TryGetValue(abilityName, out var statusList))
{
canCureStatus = statusList.Any(status => status == statusName);
}
if (canCureStatus)
{
if (AIHelperFunctions.WantsStatusProblem(pokemon, statusName))
return false;
// Don't bother if the status will cure itself soon
if (pokemon.StatusScript.Script is Sleep { Turns: 1 })
return false;
if (entryHazardDamage >= pokemon.MaxHealth / 4)
return false;
// Don't bother curing a poisoning if Toxic Spikes will just re-poison
if (pokemon.StatusScript.Script is Poisoned or BadlyPoisoned &&
!reserves.Any(p => p.Types.Any(t => t.Name == "poison")))
{
if (pokemon.BattleData!.BattleSide.VolatileScripts.TryGet<ToxicSpikesEffect>(out _))
{
return false;
}
}
// Not worth curing status problems that still allow actions if at high HP
var isImmobilizing = pokemon.StatusScript.Script is Sleep or Frozen;
if (pokemon.CurrentHealth >= pokemon.MaxHealth / 2 && !isImmobilizing)
return false;
if (ai.Random.GetInt(100) < 70)
return true;
}
else if (abilityName == RegeneratorAbilityName)
{
// Not worth healing if battler would lose more HP from switching back in later
if (entryHazardDamage >= pokemon.MaxHealth / 3)
return false;
// Not worth healing HP if already at high HP
if (pokemon.CurrentHealth >= pokemon.MaxHealth / 2)
return false;
// Don't bother if a foe is at low HP and could be knocked out instead
var hasDamagingMove = pokemon.Moves.Any(m => m?.MoveData.Category != MoveCategory.Status);
if (hasDamagingMove)
{
var opponents = battle.Sides.Where(x => x != pokemon.BattleData?.BattleSide)
.SelectMany(x => x.Pokemon.WhereNotNull());
var weakFoe = opponents.Any(opponent => opponent.CurrentHealth < opponent.MaxHealth / 3);
if (weakFoe)
return false;
}
if (ai.Random.GetInt(100) < 70)
return true;
}
return false;
}
}

View File

@ -0,0 +1,77 @@
using System.Linq.Expressions;
using System.Reflection;
using PkmnLib.Dynamic.AI.Explicit;
namespace PkmnLib.Plugin.Gen7.AI;
public static class ExplicitAIFunctionRegistration
{
public static void RegisterAIFunctions(ExplicitAIHandlers handlers)
{
var baseType = typeof(Script);
foreach (var type in typeof(ExplicitAIFunctionRegistration).Assembly.GetTypes()
.Where(t => baseType.IsAssignableFrom(t)))
{
var attribute = type.GetCustomAttribute<ScriptAttribute>();
if (attribute == null)
continue;
if (attribute.Category == ScriptCategory.Move)
{
InitializeMoveFailFunction(handlers, type, attribute);
InitializeMoveScoreFunction(handlers, type, attribute);
}
}
handlers.GeneralMoveAgainstTargetScore.Add("predicted_damage", AIDamageFunctions.PredictedDamageScore);
AISwitchFunctions.RegisterAISwitchFunctions(handlers);
}
#region Reflection based function initialization
private static void InitializeMoveFailFunction(ExplicitAIHandlers handlers, Type type, ScriptAttribute attribute)
{
var failureMethod = type.GetMethods(BindingFlags.Public | BindingFlags.Static).FirstOrDefault(m =>
m.GetCustomAttribute<AIMoveFailureFunctionAttribute>() != null);
if (failureMethod == null)
return;
if (failureMethod.ReturnType != typeof(bool) || failureMethod.GetParameters().Length != 2 ||
failureMethod.GetParameters()[0].ParameterType != typeof(IExplicitAI) ||
failureMethod.GetParameters()[1].ParameterType != typeof(MoveOption))
{
throw new InvalidOperationException(
$"Method {failureMethod.Name} in {type.Name} must return bool and take an IExplicitAI and a MoveOption as parameters.");
}
var aiParam = Expression.Parameter(typeof(IExplicitAI), "ai");
var optionParam = Expression.Parameter(typeof(MoveOption), "option");
var functionExpression = Expression.Lambda<AIBoolHandler>(
Expression.Call(null, failureMethod, aiParam, optionParam), aiParam, optionParam).Compile();
handlers.MoveFailureCheck.Add(attribute.Name, functionExpression);
}
private static void InitializeMoveScoreFunction(ExplicitAIHandlers handlers, Type type, ScriptAttribute attribute)
{
var scoreMethod = type.GetMethods(BindingFlags.Public | BindingFlags.Static).FirstOrDefault(m =>
m.GetCustomAttribute<AIMoveScoreFunctionAttribute>() != null);
if (scoreMethod == null)
return;
if (scoreMethod.ReturnType != typeof(void) || scoreMethod.GetParameters().Length != 3 ||
scoreMethod.GetParameters()[0].ParameterType != typeof(IExplicitAI) ||
scoreMethod.GetParameters()[1].ParameterType != typeof(MoveOption) ||
scoreMethod.GetParameters()[2].ParameterType != typeof(int).MakeByRefType())
{
throw new InvalidOperationException(
$"Method {scoreMethod.Name} in {type.Name} must return void and take an IExplicitAI, a MoveOption, and a ref int as parameters.");
}
var aiParam = Expression.Parameter(typeof(IExplicitAI), "ai");
var optionParam = Expression.Parameter(typeof(MoveOption), "option");
var scoreParam = Expression.Parameter(typeof(int).MakeByRefType(), "score");
var functionExpression = Expression.Lambda<AIScoreMoveHandler>(
Expression.Call(null, scoreMethod, aiParam, optionParam, scoreParam), aiParam, optionParam, scoreParam)
.Compile();
handlers.MoveEffectScore.Add(attribute.Name, functionExpression);
}
#endregion
}

View File

@ -1,89 +0,0 @@
using System.Linq.Expressions;
using System.Reflection;
using PkmnLib.Dynamic.AI.Explicit;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
using PkmnLib.Static.Moves;
namespace PkmnLib.Plugin.Gen7.AI;
public static class ExplicitAIFunctions
{
public static void RegisterAIFunctions(ExplicitAIHandlers handlers)
{
var baseType = typeof(Script);
foreach (var type in typeof(ExplicitAIFunctions).Assembly.GetTypes().Where(t => baseType.IsAssignableFrom(t)))
{
var attribute = type.GetCustomAttribute<ScriptAttribute>();
if (attribute == null)
continue;
if (attribute.Category == ScriptCategory.Move)
{
var failureMethod = type.GetMethods(BindingFlags.Public | BindingFlags.Static).FirstOrDefault(m =>
m.GetCustomAttribute<AIMoveFailureFunctionAttribute>() != null);
if (failureMethod != null)
{
if (failureMethod.ReturnType != typeof(bool) || failureMethod.GetParameters().Length != 1 ||
failureMethod.GetParameters()[0].ParameterType != typeof(MoveOption))
{
throw new InvalidOperationException(
$"Method {failureMethod.Name} in {type.Name} must return bool and take a single MoveOption parameter.");
}
var optionParam = Expression.Parameter(typeof(MoveOption), "option");
var functionExpression = Expression.Lambda<AIBoolHandler>(
Expression.Call(null, failureMethod, optionParam), optionParam).Compile();
handlers.MoveFailureCheck.Add(attribute.Name, functionExpression);
}
var scoreMethod = type.GetMethods(BindingFlags.Public | BindingFlags.Static).FirstOrDefault(m =>
m.GetCustomAttribute<AIMoveScoreFunctionAttribute>() != null);
if (scoreMethod != null)
{
if (scoreMethod.ReturnType != typeof(void) || scoreMethod.GetParameters().Length != 2 ||
scoreMethod.GetParameters()[0].ParameterType != typeof(MoveOption) ||
scoreMethod.GetParameters()[1].ParameterType != typeof(int).MakeByRefType())
{
throw new InvalidOperationException(
$"Method {scoreMethod.Name} in {type.Name} must return void and take a MoveOption and an int by reference parameter.");
}
var optionParam = Expression.Parameter(typeof(MoveOption), "option");
var scoreParam = Expression.Parameter(typeof(int).MakeByRefType(), "score");
var functionExpression = Expression.Lambda<AIScoreMoveHandler>(
Expression.Call(null, scoreMethod, optionParam, scoreParam), optionParam, scoreParam).Compile();
handlers.MoveEffectScore.Add(attribute.Name, functionExpression);
}
}
}
handlers.GeneralMoveAgainstTargetScore.Add("predicated_damage", PredictedDamageScore);
}
private static void PredictedDamageScore(MoveOption option, ref int score)
{
var target = option.Target;
if (target == null)
return;
if (option.Move.Move.Category == MoveCategory.Status)
return;
var damage = option.EstimatedDamage;
if (target.Volatile.TryGet<SubstituteEffect>(out var substitute))
{
var health = substitute.Health;
score += (int)Math.Min(15.0f * damage / health, 20);
return;
}
score += (int)Math.Min(15.0f * damage / target.CurrentHealth, 30);
if (damage > target.CurrentHealth * 1.1f)
{
score += 10;
if ((option.Move.Move.HasFlag("multi_hit") && target.CurrentHealth == target.MaxHealth &&
target.ActiveAbility?.Name == "sturdy") || target.HasHeldItem("focus_sash"))
{
score += 8;
}
}
}
}

View File

@ -50,7 +50,7 @@ public class Gen7Plugin : Plugin<Gen7PluginConfiguration>, IResourceProvider
registry.RegisterMiscLibrary(new Gen7MiscLibrary());
registry.RegisterCaptureLibrary(new Gen7CaptureLibrary(Configuration));
ExplicitAIFunctions.RegisterAIFunctions(registry.ExplicitAIHandlers);
ExplicitAIFunctionRegistration.RegisterAIFunctions(registry.ExplicitAIHandlers);
}
/// <inheritdoc />

View File

@ -60,10 +60,10 @@ public class ChangeUserAttack : ChangeUserStats
}
[AIMoveFailureFunction]
public static bool AIMoveWillFail(MoveOption option) => WouldMoveFail(option, Statistic.Attack);
public static bool AIMoveWillFail(IExplicitAI ai, MoveOption option) => WouldMoveFail(option, Statistic.Attack);
[AIMoveScoreFunction]
public static void AIMoveEffectScore(MoveOption option, ref int score) =>
public static void AIMoveEffectScore(IExplicitAI ai, MoveOption option, ref int score) =>
GetMoveEffectScore(option, Statistic.Attack, ref score);
}
@ -75,10 +75,10 @@ public class ChangeUserDefense : ChangeUserStats
}
[AIMoveFailureFunction]
public static bool AIMoveWillFail(MoveOption option) => WouldMoveFail(option, Statistic.Defense);
public static bool AIMoveWillFail(IExplicitAI ai, MoveOption option) => WouldMoveFail(option, Statistic.Defense);
[AIMoveScoreFunction]
public static void AIMoveEffectScore(MoveOption option, ref int score) =>
public static void AIMoveEffectScore(IExplicitAI ai, MoveOption option, ref int score) =>
GetMoveEffectScore(option, Statistic.Defense, ref score);
}
@ -90,10 +90,11 @@ public class ChangeUserSpecialAttack : ChangeUserStats
}
[AIMoveFailureFunction]
public static bool AIMoveWillFail(MoveOption option) => WouldMoveFail(option, Statistic.SpecialAttack);
public static bool AIMoveWillFail(IExplicitAI ai, MoveOption option) =>
WouldMoveFail(option, Statistic.SpecialAttack);
[AIMoveScoreFunction]
public static void AIMoveEffectScore(MoveOption option, ref int score) =>
public static void AIMoveEffectScore(IExplicitAI ai, MoveOption option, ref int score) =>
GetMoveEffectScore(option, Statistic.SpecialAttack, ref score);
}
@ -105,10 +106,11 @@ public class ChangeUserSpecialDefense : ChangeUserStats
}
[AIMoveFailureFunction]
public static bool AIMoveWillFail(MoveOption option) => WouldMoveFail(option, Statistic.SpecialDefense);
public static bool AIMoveWillFail(IExplicitAI ai, MoveOption option) =>
WouldMoveFail(option, Statistic.SpecialDefense);
[AIMoveScoreFunction]
public static void AIMoveEffectScore(MoveOption option, ref int score) =>
public static void AIMoveEffectScore(IExplicitAI ai, MoveOption option, ref int score) =>
GetMoveEffectScore(option, Statistic.SpecialDefense, ref score);
}
@ -120,10 +122,10 @@ public class ChangeUserSpeed : ChangeUserStats
}
[AIMoveFailureFunction]
public static bool AIMoveWillFail(MoveOption option) => WouldMoveFail(option, Statistic.Speed);
public static bool AIMoveWillFail(IExplicitAI ai, MoveOption option) => WouldMoveFail(option, Statistic.Speed);
[AIMoveScoreFunction]
public static void AIMoveEffectScore(MoveOption option, ref int score) =>
public static void AIMoveEffectScore(IExplicitAI ai, MoveOption option, ref int score) =>
GetMoveEffectScore(option, Statistic.Speed, ref score);
}
@ -135,10 +137,10 @@ public class ChangeUserAccuracy : ChangeUserStats
}
[AIMoveFailureFunction]
public static bool AIMoveWillFail(MoveOption option) => WouldMoveFail(option, Statistic.Accuracy);
public static bool AIMoveWillFail(IExplicitAI ai, MoveOption option) => WouldMoveFail(option, Statistic.Accuracy);
[AIMoveScoreFunction]
public static void AIMoveEffectScore(MoveOption option, ref int score) =>
public static void AIMoveEffectScore(IExplicitAI ai, MoveOption option, ref int score) =>
GetMoveEffectScore(option, Statistic.Accuracy, ref score);
}
@ -150,9 +152,9 @@ public class ChangeUserEvasion : ChangeUserStats
}
[AIMoveFailureFunction]
public static bool AIMoveWillFail(MoveOption option) => WouldMoveFail(option, Statistic.Evasion);
public static bool AIMoveWillFail(IExplicitAI ai, MoveOption option) => WouldMoveFail(option, Statistic.Evasion);
[AIMoveScoreFunction]
public static void AIMoveEffectScore(MoveOption option, ref int score) =>
public static void AIMoveEffectScore(IExplicitAI ai, MoveOption option, ref int score) =>
GetMoveEffectScore(option, Statistic.Evasion, ref score);
}

View File

@ -8,25 +8,7 @@ public class Conversion2 : Script, IScriptOnSecondaryEffect
{
var previousTurnChoices = target.BattleData?.Battle.PreviousTurnChoices;
var nextExecutingChoice = target.BattleData?.Battle.ChoiceQueue?.Peek();
var lastMoveByTarget = previousTurnChoices?
// The previous turn choices include the choices of the current turn, so we need to have special handling for
// the current turn
.Select((x, index) =>
{
// All choices before the current turn are valid
if (index < previousTurnChoices.Count - 1)
return x;
// If there is no next choice, we're at the end of the list, so we can just return the whole list
if (nextExecutingChoice == null)
return x;
// Otherwise we determine where the next choice is and return everything before that
var indexOfNext = x.IndexOf(nextExecutingChoice);
if (indexOfNext == -1)
return x;
return x.Take(indexOfNext);
}).SelectMany(x => x)
// We only want the last move choice by the target
.OfType<IMoveChoice>().FirstOrDefault(x => x.User == target);
var lastMoveByTarget = target.BattleData?.LastMoveChoice;
if (lastMoveByTarget == null)
{
move.GetHitData(target, hit).Fail();

View File

@ -8,8 +8,7 @@ public class Copycat : Script, IScriptChangeMove
/// <inheritdoc />
public void ChangeMove(IMoveChoice choice, ref StringKey moveName)
{
var lastMove = choice.User.BattleData?.Battle.PreviousTurnChoices.SelectMany(x => x).OfType<IMoveChoice>()
.LastOrDefault();
var lastMove = choice.User.BattleData?.LastMoveChoice;
if (lastMove == null || !lastMove.ChosenMove.MoveData.CanCopyMove())
{
choice.Fail();

View File

@ -11,8 +11,7 @@ public class Disable : Script, IScriptOnSecondaryEffect
var battleData = move.User.BattleData;
if (battleData == null)
return;
var choiceQueue = battleData.Battle.PreviousTurnChoices;
var lastMove = choiceQueue.SelectMany(x => x).OfType<IMoveChoice>().LastOrDefault(x => x.User == target);
var lastMove = target.BattleData?.LastMoveChoice;
if (lastMove == null)
{
move.GetHitData(target, hit).Fail();

View File

@ -12,9 +12,7 @@ public class Encore : Script, IScriptOnSecondaryEffect
if (battle == null)
return;
var currentTurn = battle.ChoiceQueue!.LastRanChoice;
var lastMove = battle.PreviousTurnChoices.SelectMany(x => x).OfType<IMoveChoice>()
.TakeWhile(x => !Equals(x, currentTurn)).LastOrDefault(x => x.User == target);
var lastMove = target.BattleData?.LastMoveChoice;
if (lastMove == null || battle.Library.MiscLibrary.IsReplacementChoice(lastMove))
{
move.GetHitData(target, hit).Fail();

View File

@ -11,7 +11,7 @@ public class FusionBolt : Script, IScriptChangeDamageModifier
return;
// Grab the choices for the current turn, that have been executed before this move.
var choice = battleData.Battle.PreviousTurnChoices.Last().TakeWhile(x => x != move.MoveChoice)
var choice = battleData.Battle.PreviousTurnChoices.Last().TakeWhile(x => !Equals(x, move.MoveChoice))
// Of these, find the move choice that used Fusion Flare.
.OfType<MoveChoice>().FirstOrDefault(x => x.ChosenMove.MoveData.Name == "fusion_flare");

View File

@ -11,7 +11,7 @@ public class FusionFlare : Script, IScriptChangeDamageModifier
return;
// Grab the choices for the current turn, that have been executed before this move.
var choice = battleData.Battle.PreviousTurnChoices.Last().TakeWhile(x => x != move.MoveChoice)
var choice = battleData.Battle.PreviousTurnChoices.Last().TakeWhile(x => !Equals(x, move.MoveChoice))
// Of these, find the move choice that used Fusion Bolt.
.OfType<MoveChoice>().FirstOrDefault(x => x.ChosenMove.MoveData.Name == "fusion_bolt");

View File

@ -14,8 +14,7 @@ public class Instruct : Script, IScriptOnSecondaryEffect
if (battleData == null)
return;
var lastMoveChoiceByTarget = battleData.Battle.PreviousTurnChoices.SelectMany(x => x).Reverse()
.SkipWhile(x => x != move.MoveChoice).OfType<MoveChoice>().FirstOrDefault(x => x.User == target);
var lastMoveChoiceByTarget = target.BattleData?.LastMoveChoice;
if (lastMoveChoiceByTarget == null || !battleData.Battle.CanUse(lastMoveChoiceByTarget))
{

View File

@ -21,8 +21,7 @@ public class Sketch : Script, IScriptOnSecondaryEffect
return;
}
var choiceQueue = move.Battle.PreviousTurnChoices;
var lastMove = choiceQueue.SelectMany(x => x).OfType<IMoveChoice>().LastOrDefault(x => x.User == target);
var lastMove = target.BattleData?.LastMoveChoice;
if (lastMove == null || lastMove.ChosenMove.MoveData.HasFlag("not_sketchable"))
{
move.GetHitData(target, hit).Fail();

View File

@ -6,8 +6,7 @@ public class Spite : Script, IScriptOnSecondaryEffect
/// <inheritdoc />
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
{
var lastMoveChoiceByTarget = move.Battle.PreviousTurnChoices.SelectMany(x => x).Reverse()
.SkipWhile(x => x != move.MoveChoice).OfType<MoveChoice>().FirstOrDefault(x => x.User == target);
var lastMoveChoiceByTarget = target.BattleData?.LastMoveChoice;
if (lastMoveChoiceByTarget == null || lastMoveChoiceByTarget.HasFailed)
{
move.GetHitData(target, hit).Fail();

View File

@ -6,8 +6,7 @@ public class StompingTantrum : Script, IScriptChangeBasePower
/// <inheritdoc />
public void ChangeBasePower(IExecutingMove move, IPokemon target, byte hit, ref ushort basePower)
{
var lastMoveChoice = move.Battle.PreviousTurnChoices.Reverse().Skip(1).SelectMany(x => x.Reverse())
.OfType<IMoveChoice>().FirstOrDefault(x => x.User == move.User);
var lastMoveChoice = move.User.BattleData?.LastMoveChoice;
if (lastMoveChoice is { HasFailed: true })
{
basePower = basePower.MultiplyOrMax(2);

View File

@ -6,8 +6,7 @@ public class Torment : Script, IScriptOnSecondaryEffect
/// <inheritdoc />
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
{
var lastTargetChoice = move.Battle.PreviousTurnChoices.SelectMany(x => x).Reverse().OfType<IMoveChoice>()
.FirstOrDefault(x => x.User == target);
var lastTargetChoice = target.BattleData?.LastMoveChoice;
target.Volatile.Add(new Pokemon.TormentEffect(lastTargetChoice));
}
}

View File

@ -3,20 +3,20 @@ namespace PkmnLib.Plugin.Gen7.Scripts.Pokemon;
[Script(ScriptCategory.Pokemon, "perish_song")]
public class PerishSongEffect : Script, IScriptOnEndTurn
{
private int _turns;
internal int Turns { get; private set; }
private IPokemon _owner;
public PerishSongEffect(IPokemon owner, int turns = 3)
{
_owner = owner;
_turns = turns;
Turns = turns;
}
/// <inheritdoc />
public void OnEndTurn(IScriptSource owner, IBattle battle)
{
_turns--;
if (_turns <= 0)
Turns--;
if (Turns <= 0)
{
RemoveSelf();
_owner.Faint(DamageSource.Misc);

View File

@ -1,7 +1,7 @@
namespace PkmnLib.Plugin.Gen7.Scripts.Side;
[Script(ScriptCategory.Side, "spikes")]
public class SpikesEffect : Script, IScriptOnSwitchIn, IScriptStack
public class SpikesEffect : Script, IScriptOnSwitchIn, IScriptStack, IAIInfoScriptExpectedEntryDamage
{
private int _layers = 1;
@ -18,14 +18,7 @@ public class SpikesEffect : Script, IScriptOnSwitchIn, IScriptStack
if (pokemon.IsFloating)
return;
var modifier = _layers switch
{
1 => 1 / 16f,
2 => 3 / 16f,
3 => 1 / 4f,
_ => throw new ArgumentOutOfRangeException(),
};
var damage = (uint)(pokemon.MaxHealth * modifier);
var damage = CalculateDamage(pokemon);
EventBatchId eventBatch = new();
pokemon.Damage(damage, DamageSource.Misc, eventBatch);
@ -37,4 +30,26 @@ public class SpikesEffect : Script, IScriptOnSwitchIn, IScriptStack
BatchId = eventBatch,
});
}
private uint CalculateDamage(IPokemon pokemon)
{
var modifier = _layers switch
{
1 => 1 / 16f,
2 => 3 / 16f,
3 => 1 / 4f,
_ => throw new ArgumentOutOfRangeException(),
};
var damage = (uint)(pokemon.MaxHealth * modifier);
return damage;
}
/// <inheritdoc />
public void ExpectedEntryDamage(IPokemon pokemon, ref uint damage)
{
if (pokemon.IsFloating)
return;
damage += CalculateDamage(pokemon);
}
}

View File

@ -1,18 +1,12 @@
namespace PkmnLib.Plugin.Gen7.Scripts.Side;
[Script(ScriptCategory.Side, "stealth_rock")]
public class StealthRockEffect : Script, IScriptOnSwitchIn
public class StealthRockEffect : Script, IScriptOnSwitchIn, IAIInfoScriptExpectedEntryDamage
{
/// <inheritdoc />
public void OnSwitchIn(IPokemon pokemon, byte position)
{
var typeLibrary = pokemon.Library.StaticLibrary.Types;
var effectiveness = 1.0f;
if (typeLibrary.TryGetTypeIdentifier("rock", out var rockType))
{
effectiveness = typeLibrary.GetEffectiveness(rockType, pokemon.Types);
}
var damage = (uint)(pokemon.MaxHealth / 8f * effectiveness);
var damage = CalculateStealthRockDamage(pokemon);
EventBatchId batchId = new();
pokemon.Damage(damage, DamageSource.Misc, batchId);
pokemon.BattleData?.Battle.EventHook.Invoke(new DialogEvent("stealth_rock_damage",
@ -21,4 +15,21 @@ public class StealthRockEffect : Script, IScriptOnSwitchIn
{ "pokemon", pokemon },
}));
}
private static uint CalculateStealthRockDamage(IPokemon pokemon)
{
var typeLibrary = pokemon.Library.StaticLibrary.Types;
var effectiveness = 1.0f;
if (typeLibrary.TryGetTypeIdentifier("rock", out var rockType))
{
effectiveness = typeLibrary.GetEffectiveness(rockType, pokemon.Types);
}
return (uint)(pokemon.MaxHealth / 8f * effectiveness);
}
/// <inheritdoc />
public void ExpectedEntryDamage(IPokemon pokemon, ref uint damage)
{
damage += CalculateStealthRockDamage(pokemon);
}
}

View File

@ -3,15 +3,15 @@ namespace PkmnLib.Plugin.Gen7.Scripts.Status;
[Script(ScriptCategory.Status, "badly_poisoned")]
public class BadlyPoisoned : Poisoned
{
private int _turns = 1;
internal int Turns { get; private set; } = 1;
/// <inheritdoc />
public override float GetPoisonMultiplier() => 1f / (16f * _turns);
public override float GetPoisonMultiplier() => 1f / (16f * Turns);
/// <inheritdoc />
public override void OnEndTurn(IScriptSource owner, IBattle battle)
{
base.OnEndTurn(owner, battle);
_turns = Math.Min(_turns + 1, 15);
Turns = Math.Min(Turns + 1, 15);
}
}