diff --git a/AI/AIRunner/AIRunner.csproj b/AI/AIRunner/AIRunner.csproj new file mode 100644 index 0000000..36b3bc9 --- /dev/null +++ b/AI/AIRunner/AIRunner.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + diff --git a/AI/AIRunner/Program.cs b/AI/AIRunner/Program.cs new file mode 100644 index 0000000..034a302 --- /dev/null +++ b/AI/AIRunner/Program.cs @@ -0,0 +1,89 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Reflection; +using PkmnLib.Dynamic.AI; +using Serilog; + +namespace AIRunner; + +internal static class Program +{ + private static List? _availableAIs; + + private static Task Main(string[] args) + { + Log.Logger = new LoggerConfiguration().MinimumLevel.Debug().WriteTo.Console().CreateLogger(); + Log.Information("Starting AI Runner..."); + _availableAIs = AppDomain.CurrentDomain.GetAssemblies().SelectMany(assembly => assembly.GetTypes()) + .Where(type => type.IsSubclassOf(typeof(PokemonAI)) && !type.IsAbstract).Select(Activator.CreateInstance) + .Cast().ToList(); + + var testCommand = new Command("test", "Run two AIs against each other") + { + new Option("--ai1") + { + Description = "The name of the first AI script to run against the second AI.", + Required = true, + Validators = { ValidateAI }, + }, + new Option("--ai2") + { + Description = "The name of the second AI script to run against the first AI.", + Required = true, + Validators = { ValidateAI }, + }, + new Option("--battles") + { + Description = "The number of battles to run between the two AIs.", + Required = false, + DefaultValueFactory = _ => 100, + Validators = + { + result => + { + if (result.GetValueOrDefault() <= 0) + { + result.AddError("--battles must be a positive integer."); + } + }, + }, + }, + }; + testCommand.SetAction(result => + { + var ai1Name = result.GetRequiredValue("--ai1"); + var ai2Name = result.GetRequiredValue("--ai2"); + var ai1 = _availableAIs!.First(a => + string.Equals(a.Name, ai1Name, StringComparison.InvariantCultureIgnoreCase)); + var ai2 = _availableAIs!.First(a => + string.Equals(a.Name, ai2Name, StringComparison.InvariantCultureIgnoreCase)); + + return TestCommandRunner.RunTestCommand(ai1, ai2, result.GetRequiredValue("--battles")); + }); + + var rootCommand = new RootCommand("PkmnLib.NET AI Runner") + { + testCommand, + }; + rootCommand.Description = "A tool to run AI scripts against each other in Pokémon battles."; + var parseResult = rootCommand.Parse(args); + return parseResult.InvokeAsync(); + + static void ValidateAI(OptionResult result) + { + var aiName = result.GetValueOrDefault(); + if (string.IsNullOrWhiteSpace(aiName)) + { + result.AddError("must be a non-empty string."); + return; + } + + var ai = _availableAIs!.FirstOrDefault(a => a.Name == aiName); + if (ai == null) + { + result.AddError( + $"AI '{aiName}' not found. Available AIs: {string.Join(", ", _availableAIs!.Select(a => a.Name))}"); + } + } + } +} \ No newline at end of file diff --git a/AI/AIRunner/TestCommandRunner.cs b/AI/AIRunner/TestCommandRunner.cs new file mode 100644 index 0000000..7419486 --- /dev/null +++ b/AI/AIRunner/TestCommandRunner.cs @@ -0,0 +1,144 @@ +using PkmnLib.Dynamic.AI; +using PkmnLib.Dynamic.Libraries; +using PkmnLib.Dynamic.Models; +using PkmnLib.Plugin.Gen7; +using PkmnLib.Static.Species; +using PkmnLib.Static.Utils; +using Serilog; + +namespace AIRunner; + +public static class TestCommandRunner +{ + internal static async Task RunTestCommand(PokemonAI ai1, PokemonAI ai2, int battles) + { + var library = DynamicLibraryImpl.Create([ + new Gen7Plugin(), + ]); + + Log.Information("Running {Battles} battles between {AI1} and {AI2}", battles, ai1.Name, ai2.Name); + var averageTimePerTurnPerBattle = new List(battles); + var results = new List(battles); + var random = new RandomImpl(); + // Here you would implement the logic to run the AI scripts against each other. + // This is a placeholder for demonstration purposes. + for (var i = 0; i < battles; i++) + { + Log.Information("Battle {BattleNumber}: {AI1} vs {AI2}", i + 1, ai1.Name, ai2.Name); + var battle = GenerateBattle(library, 3, random); + var timePerTurn = new List(20); + while (!battle.HasEnded) + { + var res = await GetAndSetChoices(battle, ai1, ai2); + timePerTurn.Add(res.MsPerTurn); + } + var result = battle.Result; + Log.Information("Battle {BattleNumber} ended with result: {Result}", i + 1, result); + averageTimePerTurnPerBattle.Add(timePerTurn.Average()); + results.Add(result.Value); + } + Log.Information("All battles completed"); + var averageTimePerTurn = averageTimePerTurnPerBattle.Average(); + Log.Information("Average time per turn: {AverageTimePerTurn} ms", averageTimePerTurn); + + var winCount1 = results.Count(x => x.WinningSide == 0); + var winCount2 = results.Count(x => x.WinningSide == 1); + var drawCount = results.Count(x => x.WinningSide == null); + + Log.Information("Results: {AI1} wins: {WinCount1}, {AI2} wins: {WinCount2}, Draws: {DrawCount}", ai1.Name, + winCount1, ai2.Name, winCount2, drawCount); + } + + private static PokemonPartyImpl GenerateParty(IDynamicLibrary library, int length, IRandom random) + { + var party = new PokemonPartyImpl(6); + for (var i = 0; i < length; i++) + { + var species = library.StaticLibrary.Species.GetRandom(random); + var nature = library.StaticLibrary.Natures.GetRandom(random); + const byte level = 50; + var defaultForm = species.GetDefaultForm(); + var abilityIndex = (byte)random.GetInt(0, defaultForm.Abilities.Count); + var mon = new PokemonImpl(library, species, species.GetDefaultForm(), new AbilityIndex + { + IsHidden = false, + Index = abilityIndex, + }, level, 0, species.GetRandomGender(random), 0, nature.Name); + var moves = defaultForm.Moves.GetDistinctLevelMoves().OrderBy(x => random.GetInt()).Take(4); + foreach (var move in moves) + mon.LearnMove(move, MoveLearnMethod.LevelUp, 255); + + party.SwapInto(mon, i); + } + Log.Debug("Generated party: {Party}", party); + return party; + } + + private static BattleImpl GenerateBattle(IDynamicLibrary library, int partyLength, IRandom random) + { + var parties = new[] + { + new BattlePartyImpl(GenerateParty(library, partyLength, random), [ + new ResponsibleIndex(0, 0), + ]), + new BattlePartyImpl(GenerateParty(library, partyLength, random), [ + new ResponsibleIndex(1, 0), + ]), + }; + return new BattleImpl(library, parties, false, 2, 1, false, "test"); + } + + private record struct GetAndSetChoicesResult(double MsPerTurn); + + private static async Task GetAndSetChoices(BattleImpl battle, PokemonAI ai1, PokemonAI ai2) + { + var pokemon1 = battle.Sides[0].Pokemon[0]; + if (pokemon1 is null) + { + pokemon1 = battle.Parties[0].Party.WhereNotNull().FirstOrDefault(x => x.IsUsable); + if (pokemon1 is null) + throw new InvalidOperationException("No usable Pokémon found in party 1."); + battle.Sides[0].SwapPokemon(0, pokemon1); + } + var pokemon2 = battle.Sides[1].Pokemon[0]; + if (pokemon2 is null) + { + pokemon2 = battle.Parties[1].Party.WhereNotNull().FirstOrDefault(x => x.IsUsable); + if (pokemon2 is null) + throw new InvalidOperationException("No usable Pokémon found in party 2."); + battle.Sides[1].SwapPokemon(0, pokemon2); + } + + var taskAiOne = !battle.HasForcedTurn(pokemon1, out var choice1) + ? Task.Run(() => ai1.GetChoice(battle, pokemon1)) + : Task.FromResult(choice1); + var taskAiTwo = !battle.HasForcedTurn(pokemon2, out var choice2) + ? Task.Run(() => ai2.GetChoice(battle, pokemon2)) + : Task.FromResult(choice2); + await Task.WhenAll(taskAiOne, taskAiTwo); + choice1 = taskAiOne.Result; + choice2 = taskAiTwo.Result; + Log.Debug("Turn {Turn}: AI {AI1} choice: {Choice1}, AI {AI2} choice: {Choice2}", battle.CurrentTurnNumber, + ai1.Name, choice1, ai2.Name, choice2); + var startTime = DateTime.UtcNow; + if (!battle.TrySetChoice(choice1)) + { + var replacementChoice = battle.Library.MiscLibrary.ReplacementChoice(pokemon1, 1, 0); + if (!battle.TrySetChoice(replacementChoice)) + { + throw new InvalidOperationException($"AI {ai1.Name} failed to set a valid choice: {choice1}"); + } + } + if (!battle.TrySetChoice(choice2)) + { + var replacementChoice = battle.Library.MiscLibrary.ReplacementChoice(pokemon2, 0, 0); + if (!battle.TrySetChoice(replacementChoice)) + { + throw new InvalidOperationException($"AI {ai2.Name} failed to set a valid choice: {choice2}"); + } + } + var endTime = DateTime.UtcNow; + var msPerTurn = (endTime - startTime).TotalMilliseconds; + return new GetAndSetChoicesResult(msPerTurn); + } +} \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index be140aa..582e4fe 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,8 +3,12 @@ true + + + + diff --git a/PkmnLib.Dynamic/AI/RandomAI.cs b/PkmnLib.Dynamic/AI/RandomAI.cs index f971683..1d9fac5 100644 --- a/PkmnLib.Dynamic/AI/RandomAI.cs +++ b/PkmnLib.Dynamic/AI/RandomAI.cs @@ -43,6 +43,7 @@ public class RandomAI : PokemonAI } moves.Remove(move); } - return new PassChoice(pokemon); + return battle.Library.MiscLibrary.ReplacementChoice(pokemon, + pokemon.BattleData!.SideIndex == 0 ? (byte)1 : (byte)0, pokemon.BattleData.Position); } } \ No newline at end of file diff --git a/PkmnLib.Dynamic/Libraries/MiscLibrary.cs b/PkmnLib.Dynamic/Libraries/MiscLibrary.cs index aa433f1..8178190 100644 --- a/PkmnLib.Dynamic/Libraries/MiscLibrary.cs +++ b/PkmnLib.Dynamic/Libraries/MiscLibrary.cs @@ -15,6 +15,12 @@ public interface IMiscLibrary /// ITurnChoice ReplacementChoice(IPokemon user, byte targetSide, byte targetPosition); + /// + /// Returns whether the given choice is the choice that is used when the user is unable to make a move choice. + /// (usually struggle). + /// + bool IsReplacementChoice(ITurnChoice choice); + /// /// Gets the current time of day for the battle. /// diff --git a/PkmnLib.Dynamic/Models/Battle.cs b/PkmnLib.Dynamic/Models/Battle.cs index 561158f..977d938 100644 --- a/PkmnLib.Dynamic/Models/Battle.cs +++ b/PkmnLib.Dynamic/Models/Battle.cs @@ -66,6 +66,7 @@ public interface IBattle : IScriptSource, IDeepCloneable, IDisposable /// /// Whether the battle has ended. /// + [MemberNotNull(nameof(Result))] bool HasEnded { get; } /// @@ -231,6 +232,7 @@ public class BattleImpl : ScriptSource, IBattle public IBattleRandom Random { get; } /// + [MemberNotNull(nameof(Result))] public bool HasEnded { get; private set; } /// @@ -320,7 +322,10 @@ public class BattleImpl : ScriptSource, IBattle { if (!choice.User.IsUsable) return false; - if (HasForcedTurn(choice.User, out var forcedChoice) && choice != forcedChoice) + // Always allow moves such as Struggle. If we block this, we can run into an infinite loop + if (Library.MiscLibrary.IsReplacementChoice(choice)) + return true; + if (HasForcedTurn(choice.User, out var forcedChoice) && !Equals(choice, forcedChoice)) return false; if (choice is IMoveChoice moveChoice) diff --git a/PkmnLib.Dynamic/Models/BattleParty.cs b/PkmnLib.Dynamic/Models/BattleParty.cs index 93f2701..b7ee89b 100644 --- a/PkmnLib.Dynamic/Models/BattleParty.cs +++ b/PkmnLib.Dynamic/Models/BattleParty.cs @@ -49,5 +49,6 @@ public class BattlePartyImpl : IBattleParty public bool IsResponsibleForIndex(ResponsibleIndex index) => _responsibleIndices.Contains(index); /// - public bool HasPokemonNotInField() => Party.Any(x => x is { IsUsable: true, BattleData.IsOnBattlefield: false }); + public bool HasPokemonNotInField() => + Party.WhereNotNull().Any(x => x.IsUsable && x.BattleData?.IsOnBattlefield != true); } \ No newline at end of file diff --git a/PkmnLib.Dynamic/Models/Choices/FleeChoice.cs b/PkmnLib.Dynamic/Models/Choices/FleeChoice.cs index 30d8e09..6562ae7 100644 --- a/PkmnLib.Dynamic/Models/Choices/FleeChoice.cs +++ b/PkmnLib.Dynamic/Models/Choices/FleeChoice.cs @@ -27,4 +27,25 @@ public class FleeTurnChoice : TurnChoice, IFleeChoice /// public override void CollectScripts(List> scripts) => User.CollectScripts(scripts); + + /// + public override string ToString() => $"FleeChoice: {User}"; + + protected bool Equals(FleeTurnChoice other) => other.User == User; + + /// + public override bool Equals(object? obj) + { + if (obj is null) + return false; + if (ReferenceEquals(this, obj)) + return true; + if (obj is not FleeTurnChoice other) + return false; + return Equals(other); + } + + /// + public override int GetHashCode() => + User?.GetHashCode() ?? 0; } \ No newline at end of file diff --git a/PkmnLib.Dynamic/Models/Choices/ItemChoice.cs b/PkmnLib.Dynamic/Models/Choices/ItemChoice.cs index e4ff6da..e166833 100644 --- a/PkmnLib.Dynamic/Models/Choices/ItemChoice.cs +++ b/PkmnLib.Dynamic/Models/Choices/ItemChoice.cs @@ -59,4 +59,28 @@ public class ItemChoice : TurnChoice, IItemChoice { User.CollectScripts(scripts); } + + /// + public override string ToString() => + $"ItemChoice: User: {User}, Item: {Item.Name}, TargetSide: {TargetSide}, TargetPosition: {TargetPosition}"; + + protected bool Equals(ItemChoice other) => + other.User == User && other.Item.Equals(Item) && other.TargetSide == TargetSide && + other.TargetPosition == TargetPosition; + + /// + public override bool Equals(object? obj) + { + if (obj is null) + return false; + if (ReferenceEquals(this, obj)) + return true; + if (obj is not ItemChoice other) + return false; + return Equals(other); + } + + /// + public override int GetHashCode() => + HashCode.Combine(User, Item, TargetSide, TargetPosition); } \ No newline at end of file diff --git a/PkmnLib.Dynamic/Models/Choices/MoveChoice.cs b/PkmnLib.Dynamic/Models/Choices/MoveChoice.cs index 28aba15..431a691 100644 --- a/PkmnLib.Dynamic/Models/Choices/MoveChoice.cs +++ b/PkmnLib.Dynamic/Models/Choices/MoveChoice.cs @@ -105,4 +105,28 @@ public class MoveChoice : TurnChoice, IMoveChoice GetOwnScripts(scripts); User.CollectScripts(scripts); } + + /// + public override string ToString() => + $"MoveChoice: {ChosenMove.MoveData.Name}, Target: {TargetSide}:{TargetPosition}, User: {User}"; + + protected bool Equals(MoveChoice other) => + other.User == User && other.ChosenMove.Equals(ChosenMove) && other.TargetSide == TargetSide && + other.TargetPosition == TargetPosition; + + /// + public override bool Equals(object? obj) + { + if (obj is null) + return false; + if (ReferenceEquals(this, obj)) + return true; + if (obj is not MoveChoice other) + return false; + return Equals(other); + } + + /// + public override int GetHashCode() => + HashCode.Combine(User, ChosenMove, TargetSide, TargetPosition); } \ No newline at end of file diff --git a/PkmnLib.Dynamic/Models/Choices/PassChoice.cs b/PkmnLib.Dynamic/Models/Choices/PassChoice.cs index 5290fd3..4879e71 100644 --- a/PkmnLib.Dynamic/Models/Choices/PassChoice.cs +++ b/PkmnLib.Dynamic/Models/Choices/PassChoice.cs @@ -30,4 +30,26 @@ public class PassChoice : TurnChoice, IPassChoice { User.CollectScripts(scripts); } + + /// + public override string ToString() => + $"PassChoice: {User}"; + + protected bool Equals(PassChoice other) => other.User == User; + + /// + public override bool Equals(object? obj) + { + if (obj is null) + return false; + if (ReferenceEquals(this, obj)) + return true; + if (obj is not PassChoice other) + return false; + return Equals(other); + } + + /// + public override int GetHashCode() => + User?.GetHashCode() ?? 0; } \ No newline at end of file diff --git a/PkmnLib.Dynamic/Models/Choices/SwitchChoice.cs b/PkmnLib.Dynamic/Models/Choices/SwitchChoice.cs index 538aaf0..b2acd2d 100644 --- a/PkmnLib.Dynamic/Models/Choices/SwitchChoice.cs +++ b/PkmnLib.Dynamic/Models/Choices/SwitchChoice.cs @@ -38,4 +38,27 @@ public class SwitchChoice : TurnChoice, ISwitchChoice { User.CollectScripts(scripts); } + + /// + public override string ToString() => + $"SwitchChoice: {User} -> {SwitchTo}"; + + protected bool Equals(SwitchChoice other) => + other.User == User && other.SwitchTo == SwitchTo; + + /// + public override bool Equals(object? obj) + { + if (obj is null) + return false; + if (ReferenceEquals(this, obj)) + return true; + if (obj is not SwitchChoice other) + return false; + return Equals(other); + } + + /// + public override int GetHashCode() => + User?.GetHashCode() ?? 0 ^ SwitchTo?.GetHashCode() ?? 0; } \ No newline at end of file diff --git a/PkmnLib.Dynamic/Models/LearnedMove.cs b/PkmnLib.Dynamic/Models/LearnedMove.cs index 2cfb231..490d323 100644 --- a/PkmnLib.Dynamic/Models/LearnedMove.cs +++ b/PkmnLib.Dynamic/Models/LearnedMove.cs @@ -138,7 +138,10 @@ public class LearnedMoveImpl : ILearnedMove public bool TryUse(byte amount = 1) { if (CurrentPp < amount) + { + CurrentPp = 0; return false; + } CurrentPp -= amount; return true; diff --git a/PkmnLib.Dynamic/Models/Pokemon.cs b/PkmnLib.Dynamic/Models/Pokemon.cs index 6a3c19c..77bd337 100644 --- a/PkmnLib.Dynamic/Models/Pokemon.cs +++ b/PkmnLib.Dynamic/Models/Pokemon.cs @@ -904,7 +904,7 @@ public class PokemonImpl : ScriptSource, IPokemon changed = change switch { > 0 => StatBoost.IncreaseStatistic(stat, change), - < 0 => StatBoost.DecreaseStatistic(stat, change), + < 0 => StatBoost.DecreaseStatistic(stat, (sbyte)-change), _ => changed, }; if (!changed) @@ -1003,7 +1003,21 @@ public class PokemonImpl : ScriptSource, IPokemon Form = form; Types = form.Types.ToList(); HeightInMeters = form.Height; - var newAbility = Form.GetAbility(AbilityIndex); + var abilityIndex = AbilityIndex; + abilityIndex = AbilityIndex.IsHidden switch + { + true when form.HiddenAbilities.Count <= abilityIndex.Index => new AbilityIndex + { + IsHidden = true, Index = (byte)(form.HiddenAbilities.Count - 1), + }, + false when form.Abilities.Count <= abilityIndex.Index => new AbilityIndex + { + IsHidden = false, Index = (byte)(form.Abilities.Count - 1), + }, + _ => abilityIndex, + }; + + var newAbility = Form.GetAbility(abilityIndex); if (OverrideAbility == null && oldAbility != newAbility) { @@ -1122,6 +1136,7 @@ public class PokemonImpl : ScriptSource, IPokemon BattleData.Battle.Sides[BattleData.SideIndex].MarkPositionAsUnfillable(BattleData.Position); } BattleData.BattleSide.MarkFaint(BattleData.Position); + BattleData.BattleSide.ForceClearPokemonFromField(BattleData.Position); // Validate the battle state to see if the battle is over. BattleData.Battle.ValidateBattleState(); diff --git a/PkmnLib.Dynamic/Models/PokemonParty.cs b/PkmnLib.Dynamic/Models/PokemonPartyImpl.cs similarity index 95% rename from PkmnLib.Dynamic/Models/PokemonParty.cs rename to PkmnLib.Dynamic/Models/PokemonPartyImpl.cs index 13b903b..9bd5101 100644 --- a/PkmnLib.Dynamic/Models/PokemonParty.cs +++ b/PkmnLib.Dynamic/Models/PokemonPartyImpl.cs @@ -43,12 +43,12 @@ public interface IPokemonParty : IReadOnlyList, IDeepCloneable } /// -public class PokemonParty : IPokemonParty +public class PokemonPartyImpl : IPokemonParty { private readonly IPokemon?[] _pokemon; - /// - public PokemonParty(int size) + /// + public PokemonPartyImpl(int size) { _pokemon = new IPokemon[size]; } diff --git a/PkmnLib.Dynamic/ScriptHandling/Registry/ScriptUtils.cs b/PkmnLib.Dynamic/ScriptHandling/Registry/ScriptUtils.cs index 6d0bb98..d35cfe2 100644 --- a/PkmnLib.Dynamic/ScriptHandling/Registry/ScriptUtils.cs +++ b/PkmnLib.Dynamic/ScriptHandling/Registry/ScriptUtils.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Reflection; using PkmnLib.Static.Utils; @@ -8,7 +9,7 @@ namespace PkmnLib.Dynamic.ScriptHandling.Registry; /// public static class ScriptUtils { - private static readonly Dictionary Cache = new(); + private static readonly ConcurrentDictionary Cache = new(); /// /// Resolve name from the of the given script. diff --git a/PkmnLib.Dynamic/ScriptHandling/ScriptSet.cs b/PkmnLib.Dynamic/ScriptHandling/ScriptSet.cs index d88a46a..efbbdb8 100644 --- a/PkmnLib.Dynamic/ScriptHandling/ScriptSet.cs +++ b/PkmnLib.Dynamic/ScriptHandling/ScriptSet.cs @@ -97,7 +97,15 @@ public class ScriptSet : IScriptSet } /// - public IEnumerator GetEnumerator() => _scripts.GetEnumerator(); + public IEnumerator GetEnumerator() + { + var currentIndex = 0; + while (currentIndex < _scripts.Count) + { + yield return _scripts[currentIndex]; + currentIndex++; + } + } /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); @@ -176,7 +184,7 @@ public class ScriptSet : IScriptSet if (script is null) return; script.Script?.OnRemove(); - _scripts.Remove(script); + script.Clear(); } /// diff --git a/PkmnLib.NET.slnx b/PkmnLib.NET.slnx index 6603061..6c370fa 100644 --- a/PkmnLib.NET.slnx +++ b/PkmnLib.NET.slnx @@ -6,6 +6,14 @@ + + + + + + + + diff --git a/PkmnLib.Static/Libraries/TypeLibrary.cs b/PkmnLib.Static/Libraries/TypeLibrary.cs index 85c924d..f9fffcc 100644 --- a/PkmnLib.Static/Libraries/TypeLibrary.cs +++ b/PkmnLib.Static/Libraries/TypeLibrary.cs @@ -69,9 +69,9 @@ public class TypeLibrary : IReadOnlyTypeLibrary public float GetSingleEffectiveness(TypeIdentifier attacking, TypeIdentifier defending) { if (attacking.Value < 1 || attacking.Value > _effectiveness.Count) - throw new ArgumentOutOfRangeException(nameof(attacking)); + return 1; if (defending.Value < 1 || defending.Value > _effectiveness.Count) - throw new ArgumentOutOfRangeException(nameof(defending)); + return 1; return _effectiveness[attacking.Value - 1][defending.Value - 1]; } diff --git a/PkmnLib.Static/StatisticSet.cs b/PkmnLib.Static/StatisticSet.cs index 7262878..1062172 100644 --- a/PkmnLib.Static/StatisticSet.cs +++ b/PkmnLib.Static/StatisticSet.cs @@ -65,7 +65,7 @@ public record ImmutableStatisticSet where T : struct /// /// Gets a statistic from the set. /// - public T GetStatistic(Statistic stat) + public virtual T GetStatistic(Statistic stat) { return stat switch { @@ -75,7 +75,7 @@ public record ImmutableStatisticSet where T : struct Statistic.SpecialAttack => SpecialAttack, Statistic.SpecialDefense => SpecialDefense, Statistic.Speed => Speed, - _ => throw new ArgumentException("Invalid statistic."), + _ => throw new ArgumentException($"Invalid statistic {stat}"), }; } } @@ -308,7 +308,7 @@ public abstract record ClampedStatisticSet : StatisticSet where T : struct var current = GetStatistic(stat); var newValue = Subtract(current, value); if (newValue.CompareTo(Min) < 0) - value = Subtract(current, Min); + value = Add(Min, current); if (value.CompareTo(default) == 0) return false; return base.DecreaseStatistic(stat, value); @@ -400,6 +400,23 @@ public record StatBoostStatisticSet : ClampedStatisticSet yield return (Statistic.Accuracy, Accuracy); } + /// + public override sbyte GetStatistic(Statistic stat) + { + return stat switch + { + Statistic.Hp => Hp, + Statistic.Attack => Attack, + Statistic.Defense => Defense, + Statistic.SpecialAttack => SpecialAttack, + Statistic.SpecialDefense => SpecialDefense, + Statistic.Speed => Speed, + Statistic.Evasion => Evasion, + Statistic.Accuracy => Accuracy, + _ => throw new ArgumentException($"Invalid statistic {stat}"), + }; + } + /// /// Resets all statistics to 0. /// diff --git a/PkmnLib.Tests/Integration/IntegrationTestRunner.cs b/PkmnLib.Tests/Integration/IntegrationTestRunner.cs index dccd86e..fd2679f 100644 --- a/PkmnLib.Tests/Integration/IntegrationTestRunner.cs +++ b/PkmnLib.Tests/Integration/IntegrationTestRunner.cs @@ -66,7 +66,7 @@ public class IntegrationTestRunner var parties = await test.BattleSetup.Parties.SelectAsync(async Task (x) => { - var party = new PokemonParty(6); + var party = new PokemonPartyImpl(6); for (var index = 0; index < x.Pokemon.Length; index++) { var pokemon = x.Pokemon[index]; diff --git a/PkmnLib.Tests/Static/DeepCloneTests.cs b/PkmnLib.Tests/Static/DeepCloneTests.cs index a357468..570c868 100644 --- a/PkmnLib.Tests/Static/DeepCloneTests.cs +++ b/PkmnLib.Tests/Static/DeepCloneTests.cs @@ -90,13 +90,13 @@ public class DeepCloneTests var library = LibraryHelpers.LoadLibrary(); await Assert.That(library.StaticLibrary.Species.TryGet("bulbasaur", out var bulbasaur)).IsTrue(); await Assert.That(library.StaticLibrary.Species.TryGet("charmander", out var charmander)).IsTrue(); - var party1 = new PokemonParty(6); + var party1 = new PokemonPartyImpl(6); party1.SwapInto(new PokemonImpl(library, bulbasaur!, bulbasaur!.GetDefaultForm(), new AbilityIndex { IsHidden = false, Index = 0, }, 50, 0, Gender.Male, 0, "hardy"), 0); - var party2 = new PokemonParty(6); + var party2 = new PokemonPartyImpl(6); party2.SwapInto(new PokemonImpl(library, charmander!, charmander!.GetDefaultForm(), new AbilityIndex { IsHidden = false, diff --git a/Plugins/PkmnLib.Plugin.Gen7/Data/Abilities.jsonc b/Plugins/PkmnLib.Plugin.Gen7/Data/Abilities.jsonc index 77de6d9..03fdec2 100755 --- a/Plugins/PkmnLib.Plugin.Gen7/Data/Abilities.jsonc +++ b/Plugins/PkmnLib.Plugin.Gen7/Data/Abilities.jsonc @@ -684,6 +684,13 @@ "tinted_lens": { "effect": "tinted_lens" }, + "torrent": { + "effect": "power_up_type_at_low_health", + "parameters": { + "type": "water", + "threshold": 0.33333 + } + }, "tough_claws": { "effect": "tough_claws" }, diff --git a/Plugins/PkmnLib.Plugin.Gen7/Data/Moves.jsonc b/Plugins/PkmnLib.Plugin.Gen7/Data/Moves.jsonc index 25dc2e1..f88a312 100755 --- a/Plugins/PkmnLib.Plugin.Gen7/Data/Moves.jsonc +++ b/Plugins/PkmnLib.Plugin.Gen7/Data/Moves.jsonc @@ -11852,6 +11852,25 @@ } } }, + { + "name": "thunder_fang", + "type": "electric", + "power": 65, + "pp": 15, + "accuracy": 95, + "priority": 0, + "target": "Any", + "category": "physical", + "flags": [ + "contact", + "protect", + "mirror", + "bite" + ], + "effect": { + "name": "thunder_fang" + } + }, { "name": "thunder_punch", "type": "electric", diff --git a/Plugins/PkmnLib.Plugin.Gen7/Libraries/Battling/Gen7BattleStatCalculator.cs b/Plugins/PkmnLib.Plugin.Gen7/Libraries/Battling/Gen7BattleStatCalculator.cs index ffde538..2520c2c 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Libraries/Battling/Gen7BattleStatCalculator.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Libraries/Battling/Gen7BattleStatCalculator.cs @@ -132,7 +132,7 @@ public class Gen7BattleStatCalculator : IBattleStatCalculator 4 => 6.0f / 2.0f, 5 => 7.0f / 2.0f, 6 => 8.0f / 2.0f, - _ => throw new ArgumentException("Stat boost was out of expected range of -6 to 6"), + _ => throw new ArgumentException($"Stat boost was out of expected range of -6 to 6: {boost}"), }; } } \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7/Libraries/Battling/Gen7MiscLibrary.cs b/Plugins/PkmnLib.Plugin.Gen7/Libraries/Battling/Gen7MiscLibrary.cs index cb5dc5f..c9314f5 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Libraries/Battling/Gen7MiscLibrary.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Libraries/Battling/Gen7MiscLibrary.cs @@ -14,6 +14,10 @@ public class Gen7MiscLibrary : IMiscLibrary public ITurnChoice ReplacementChoice(IPokemon user, byte targetSide, byte targetPosition) => new MoveChoice(user, new LearnedMoveImpl(_struggleData, MoveLearnMethod.Unknown), targetSide, targetPosition); + /// + public bool IsReplacementChoice(ITurnChoice choice) => + choice is MoveChoice moveChoice && moveChoice.ChosenMove.MoveData == _struggleData; + /// public TimeOfDay GetTimeOfDay() { diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Abilities/WonderGuard.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Abilities/WonderGuard.cs index 65a7e9d..4b116d8 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Abilities/WonderGuard.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Abilities/WonderGuard.cs @@ -11,6 +11,9 @@ public class WonderGuard : Script, IScriptBlockIncomingHit /// public void BlockIncomingHit(IExecutingMove executingMove, IPokemon target, byte hitIndex, ref bool block) { + var type = executingMove.GetHitData(target, hitIndex).Type; + if (type is null || type.Value.Value == 0) + return; var effectiveness = executingMove.GetHitData(target, hitIndex).Effectiveness; if (!(effectiveness <= 1.0)) return; diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Encore.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Encore.cs index 761bb49..dff7faa 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Encore.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Encore.cs @@ -14,8 +14,8 @@ public class Encore : Script, IScriptOnSecondaryEffect var currentTurn = battle.ChoiceQueue!.LastRanChoice; var lastMove = battle.PreviousTurnChoices.SelectMany(x => x).OfType() - .TakeWhile(x => x != currentTurn).LastOrDefault(x => x.User == target); - if (lastMove == null) + .TakeWhile(x => !Equals(x, currentTurn)).LastOrDefault(x => x.User == target); + if (lastMove == null || battle.Library.MiscLibrary.IsReplacementChoice(lastMove)) { move.GetHitData(target, hit).Fail(); return; diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/BideEffect.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/BideEffect.cs index 5c400a1..05c3c40 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/BideEffect.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/BideEffect.cs @@ -48,7 +48,6 @@ public class BideEffect : Script, IScriptForceTurnSelection, IScriptOnIncomingHi choice = _choice; return; } - var opposingSideIndex = (byte)(_owner.BattleData?.SideIndex == 0 ? 1 : 0); - choice = _choice = TurnChoiceHelper.CreateMoveChoice(_owner, "bide", opposingSideIndex, position); + choice = _choice = TurnChoiceHelper.CreateMoveChoice(_owner, "bide", ownerBattleData.SideIndex, position); } } \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/FreezeShockEffect.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/FreezeShockEffect.cs index 3dbf6f4..2ad4585 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/FreezeShockEffect.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/FreezeShockEffect.cs @@ -1,3 +1,4 @@ namespace PkmnLib.Plugin.Gen7.Scripts.Pokemon; +[Script(ScriptCategory.Pokemon, "require_charge")] public class RequireChargeEffect(IPokemon owner, StringKey moveName) : BaseChargeEffect(owner, moveName); \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/TruantEffect.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/TruantEffect.cs index edd270c..db85701 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/TruantEffect.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/TruantEffect.cs @@ -1,10 +1,17 @@ namespace PkmnLib.Plugin.Gen7.Scripts.Pokemon; -public class TruantEffect(IPokemon owner) : Script, IScriptForceTurnSelection +[Script(ScriptCategory.Pokemon, "truant_effect")] +public class TruantEffect(IPokemon owner) : Script, IScriptForceTurnSelection, IScriptOnEndTurn { /// public void ForceTurnSelection(IBattle battle, byte sideIndex, byte position, ref ITurnChoice? choice) { choice = new PassChoice(owner); } + + /// + public void OnEndTurn(IScriptSource _, IBattle battle) + { + RemoveSelf(); + } } \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/WhirlpoolEffect.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/WhirlpoolEffect.cs index 4a48c84..02da252 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/WhirlpoolEffect.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/WhirlpoolEffect.cs @@ -61,6 +61,7 @@ public class WhirlpoolEffect : Script, IScriptOnEndTurn, IScriptPreventOpponentR if (_user == null) return; + List? pokemonToRemove = null; foreach (var pokemonTurn in _targetedPokemon.Where(x => x.Pokemon.BattleData?.IsOnBattlefield == true)) { var pokemon = pokemonTurn.Pokemon; @@ -68,8 +69,15 @@ public class WhirlpoolEffect : Script, IScriptOnEndTurn, IScriptPreventOpponentR pokemonTurn.Turns--; if (pokemonTurn.Turns <= 0) { - _targetedPokemon.Remove(pokemonTurn); + pokemonToRemove ??= []; + pokemonToRemove.Add(pokemonTurn); } } + if (pokemonToRemove == null) + return; + foreach (var turn in pokemonToRemove) + { + _targetedPokemon.Remove(turn); + } } } \ No newline at end of file