diff --git a/PkmnLib.Dynamic/Models/Pokemon.cs b/PkmnLib.Dynamic/Models/Pokemon.cs index ddf8544..fb1013a 100644 --- a/PkmnLib.Dynamic/Models/Pokemon.cs +++ b/PkmnLib.Dynamic/Models/Pokemon.cs @@ -296,7 +296,7 @@ public interface IPokemon : IScriptSource, IDeepCloneable /// /// Suppresses the ability of the Pokémon. /// - void SuppressAbility(); + bool SuppressAbility(); /// /// Returns the currently active ability. @@ -974,10 +974,14 @@ public class PokemonImpl : ScriptSource, IPokemon public bool AbilitySuppressed { get; private set; } /// - public void SuppressAbility() + public bool SuppressAbility() { + if (ActiveAbility?.CanBeChanged == false) + return false; + AbilitySuppressed = true; AbilityScript.Clear(); + return true; } private (IAbility, AbilityIndex)? _abilityCache; diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CamouflageTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CamouflageTests.cs new file mode 100644 index 0000000..6d8dd87 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CamouflageTests.cs @@ -0,0 +1,118 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: Camouflage changes the user's type based on the current battle +/// environment, and the terrain in effect takes precedence (Electric Terrain → Electric, +/// Misty Terrain → Fairy, Grassy Terrain → Grass, Psychic Terrain → Psychic). +/// +public class CamouflageTests +{ + private static (Camouflage script, IExecutingMove move, IPokemon user, IBattle battle) CreateTestSetup( + string? terrainName, string environmentName) + { + var script = new Camouflage(); + var library = LibraryHelpers.LoadLibrary(); + + var battle = Substitute.For(); + battle.Library.Returns(library); + battle.TerrainName.Returns(terrainName == null ? null : new StringKey?(new StringKey(terrainName))); + battle.EnvironmentName.Returns(new StringKey(environmentName)); + + var user = Substitute.For(); + var move = Substitute.For(); + move.User.Returns(user); + move.Battle.Returns(battle); + + return (script, move, user, battle); + } + + /// + /// Helper that returns the single type the user was changed to, or null when + /// was never called. + /// + private static TypeIdentifier? GetSetType(IPokemon user) + { + var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetTypes"); + return call != null ? ((IReadOnlyList)call.GetArguments()[0]!).Single() : null; + } + + /// + /// Bulbapedia (Generation VI onwards): terrain effects determine the type Camouflage grants — + /// Electric Terrain makes the user Electric-type, Misty Terrain makes it Fairy-type, + /// Grassy Terrain makes it Grass-type, and (Generation VII) Psychic Terrain makes it Psychic-type. + /// + [Test, Arguments("electric_terrain", "electric"), Arguments("misty_terrain", "fairy"), + Arguments("grassy_terrain", "grass"), Arguments("psychic_terrain", "psychic")] + public async Task OnSecondaryEffect_TerrainActive_UserBecomesTerrainType(string terrainName, string expectedType) + { + // Arrange + var (script, move, user, battle) = CreateTestSetup(terrainName, "field"); + battle.Library.StaticLibrary.Types.TryGetTypeIdentifier(expectedType, out var expected); + + // Act + script.OnSecondaryEffect(move, Substitute.For(), 0); + + // Assert + await Assert.That(GetSetType(user)!.Value).IsEqualTo(expected); + } + + /// + /// Bulbapedia: without a terrain in effect, the battle environment decides the type — caves make + /// the user Rock-type, mountains and beaches Ground-type, snow Ice-type, and water Water-type. + /// + [Test, Arguments("cave", "rock"), Arguments("mountain", "ground"), Arguments("beach", "ground"), + Arguments("snow", "ice"), Arguments("sea", "water"), Arguments("lake", "water")] + public async Task OnSecondaryEffect_NoTerrain_UserBecomesEnvironmentType(string environment, string expectedType) + { + // Arrange + var (script, move, user, battle) = CreateTestSetup(null, environment); + battle.Library.StaticLibrary.Types.TryGetTypeIdentifier(expectedType, out var expected); + + // Act + script.OnSecondaryEffect(move, Substitute.For(), 0); + + // Assert + await Assert.That(GetSetType(user)!.Value).IsEqualTo(expected); + } + + /// + /// Bulbapedia: "On regular terrain" Camouflage changes the user's type to Normal. + /// + [Test, Arguments("field"), Arguments("forest"), Arguments("building")] + public async Task OnSecondaryEffect_RegularEnvironment_UserBecomesNormalType(string environment) + { + // Arrange + var (script, move, user, battle) = CreateTestSetup(null, environment); + battle.Library.StaticLibrary.Types.TryGetTypeIdentifier("normal", out var expected); + + // Act + script.OnSecondaryEffect(move, Substitute.For(), 0); + + // Assert + await Assert.That(GetSetType(user)!.Value).IsEqualTo(expected); + } + + /// + /// Bulbapedia: Camouflage changes the type of the user, not the target — the target must be + /// left untouched. + /// + [Test] + public async Task OnSecondaryEffect_TargetTypes_AreNotChanged() + { + // Arrange + var (script, move, _, _) = CreateTestSetup(null, "field"); + var target = Substitute.For(); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetTypes")).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CaptivateTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CaptivateTests.cs new file mode 100644 index 0000000..5957dca --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CaptivateTests.cs @@ -0,0 +1,133 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; +using PkmnLib.Static.Species; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Captivate lowers the Special Attack stat of the target by two stages, +/// if it is the opposite gender of the user." Gender-unknown Pokémon and Pokémon with the Oblivious +/// Ability cannot be affected, and the move fails when used by a gender-unknown Pokémon. +/// +public class CaptivateTests +{ + private static (Captivate script, IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData) + CreateTestSetup(Gender userGender, Gender targetGender) + { + var script = new Captivate(); + var move = Substitute.For(); + var user = Substitute.For(); + user.Gender.Returns(userGender); + move.User.Returns(user); + + var target = Substitute.For(); + target.Gender.Returns(targetGender); + var hitData = Substitute.For(); + move.GetHitData(target, 0).Returns(hitData); + + return (script, move, user, target, hitData); + } + + /// + /// Helper that checks whether a stat boost change was applied to the given Pokémon. + /// + private static bool ReceivedStatBoost(IPokemon pokemon, Statistic stat, sbyte amount) => + pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost" && + (Statistic)c.GetArguments()[0]! == stat && + (sbyte)c.GetArguments()[1]! == amount); + + /// + /// Bulbapedia: "Captivate lowers the Special Attack stat of the target by two stages, if it is the + /// opposite gender of the user." + /// + [Test, Arguments(Gender.Male, Gender.Female), Arguments(Gender.Female, Gender.Male)] + public async Task OnSecondaryEffect_OppositeGender_LowersTargetSpecialAttackByTwoStages(Gender userGender, + Gender targetGender) + { + // Arrange + var (script, move, _, target, hitData) = CreateTestSetup(userGender, targetGender); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(ReceivedStatBoost(target, Statistic.SpecialAttack, -2)).IsTrue(); + hitData.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: Captivate only affects a target "if it is the opposite gender of the user" — against a + /// same-gender target the move fails and no stat is lowered. + /// + [Test, Arguments(Gender.Male), Arguments(Gender.Female)] + public async Task OnSecondaryEffect_SameGender_Fails(Gender gender) + { + // Arrange + var (script, move, _, target, hitData) = CreateTestSetup(gender, gender); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse(); + } + + /// + /// Bulbapedia: "Pokémon that are gender-unknown [...] cannot be affected." + /// + [Test] + public async Task OnSecondaryEffect_GenderlessTarget_Fails() + { + // Arrange + var (script, move, _, target, hitData) = CreateTestSetup(Gender.Male, Gender.Genderless); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse(); + } + + /// + /// Bulbapedia: "those with the Oblivious ability cannot be affected." + /// + [Test] + public async Task OnSecondaryEffect_TargetHasOblivious_Fails() + { + // Arrange + var (script, move, _, target, hitData) = CreateTestSetup(Gender.Male, Gender.Female); + var ability = Substitute.For(); + ability.Name.Returns(new StringKey("oblivious")); + target.ActiveAbility.Returns(ability); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse(); + } + + /// + /// Bulbapedia: "The move fails automatically if used by a gender-unknown Pokémon." A genderless user + /// against a gendered target must fail rather than lower the target's Special Attack. + /// + [Test] + public async Task OnSecondaryEffect_GenderlessUser_Fails() + { + // Arrange + var (script, move, _, target, hitData) = CreateTestSetup(Gender.Genderless, Gender.Female); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ChargeTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ChargeTests.cs new file mode 100644 index 0000000..46549d2 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ChargeTests.cs @@ -0,0 +1,149 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Dynamic.ScriptHandling.Registry; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; +using PkmnLib.Static; +using PkmnLib.Static.Moves; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script and the it applies. +/// Gen VII Bulbapedia behavior: Charge doubles the power of the user's next Electric-type move, +/// and "It also raises the user's Special Defense stat by one stage." +/// +public class ChargeTests +{ + private static (Charge script, IExecutingMove move, IPokemon user, IScriptSet userVolatile) CreateTestSetup() + { + var script = new Charge(); + var move = Substitute.For(); + var user = Substitute.For(); + // Use a real script set so the volatile script added by Charge can be inspected afterwards. + var userVolatile = new ScriptSet(user); + user.Volatile.Returns(userVolatile); + user.GetScripts().Returns(_ => new ScriptIterator(new List>())); + move.User.Returns(user); + return (script, move, user, userVolatile); + } + + /// + /// Creates an executing move of the given type whose damage modifier can be changed by + /// . + /// + private static (IExecutingMove move, IPokemon target) CreateExecutingMoveOfType(string typeName) + { + var library = LibraryHelpers.LoadLibrary(); + var battle = Substitute.For(); + battle.Library.Returns(library); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + var target = Substitute.For(); + target.BattleData.Returns(battleData); + + library.StaticLibrary.Types.TryGetTypeIdentifier(typeName, out var typeIdentifier); + var useMove = Substitute.For(); + useMove.MoveType.Returns(typeIdentifier); + var move = Substitute.For(); + move.UseMove.Returns(useMove); + + return (move, target); + } + + /// + /// Bulbapedia: "It also raises the user's Special Defense stat by one stage." + /// + [Test] + public async Task OnSecondaryEffect_Used_RaisesUserSpecialDefenseByOneStage() + { + // Arrange + var (script, move, user, _) = CreateTestSetup(); + + // Act + script.OnSecondaryEffect(move, Substitute.For(), 0); + + // Assert + var boost = user.ReceivedCalls().SingleOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost"); + await Assert.That(boost).IsNotNull(); + await Assert.That((Statistic)boost!.GetArguments()[0]!).IsEqualTo(Statistic.SpecialDefense); + await Assert.That((sbyte)boost.GetArguments()[1]!).IsEqualTo((sbyte)1); + await Assert.That((bool)boost.GetArguments()[2]!).IsTrue(); + } + + /// + /// Bulbapedia: Charge doubles the power of the user's next Electric-type move. Using Charge attaches + /// the volatile script to the user. + /// + [Test] + public async Task OnSecondaryEffect_Used_AddsChargeEffectToUser() + { + // Arrange + var (script, move, _, userVolatile) = CreateTestSetup(); + + // Act + script.OnSecondaryEffect(move, Substitute.For(), 0); + + // Assert + await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); + } + + /// + /// Bulbapedia: "If the user's next move is Electric-type", its power is doubled. + /// + [Test] + public async Task ChargeEffect_ElectricMove_DoublesDamageModifier() + { + // Arrange + var effect = new ChargeEffect(); + var (move, target) = CreateExecutingMoveOfType("electric"); + var modifier = 1.0f; + + // Act + effect.ChangeDamageModifier(move, target, 0, ref modifier); + + // Assert + await Assert.That(modifier).IsEqualTo(2.0f); + } + + /// + /// Bulbapedia: only Electric-type moves benefit — a non-Electric move is not boosted by Charge. + /// + [Test] + public async Task ChargeEffect_NonElectricMove_DoesNotChangeDamageModifier() + { + // Arrange + var effect = new ChargeEffect(); + var (move, target) = CreateExecutingMoveOfType("water"); + var modifier = 1.0f; + + // Act + effect.ChangeDamageModifier(move, target, 0, ref modifier); + + // Assert + await Assert.That(modifier).IsEqualTo(1.0f); + } + + /// + /// Bulbapedia: Charge boosts the user's *next* Electric-type move — the effect lasts through the end + /// of the following turn and then wears off. The effect survives the end of the turn Charge was used + /// on and removes itself at the end of the next turn. + /// + [Test] + public async Task ChargeEffect_OnEndTurn_RemovesItselfAfterTheNextTurn() + { + // Arrange + var (script, move, user, userVolatile) = CreateTestSetup(); + script.OnSecondaryEffect(move, Substitute.For(), 0); + await Assert.That(userVolatile.TryGet(out var effect)).IsTrue(); + var battle = Substitute.For(); + + // Act & Assert - end of the turn of use: effect remains + effect!.OnEndTurn(user, battle); + await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); + + // Act & Assert - end of the following turn: effect is removed + effect.OnEndTurn(user, battle); + await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ChipAwayTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ChipAwayTests.cs new file mode 100644 index 0000000..23e7625 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ChipAwayTests.cs @@ -0,0 +1,48 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Plugin.Gen7.Scripts.Moves; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Chip Away inflicts damage, ignoring any changes to the target's Defense +/// and evasion stat stages — positive and negative." +/// +public class ChipAwayTests +{ + /// + /// Bulbapedia: Chip Away inflicts damage "ignoring any changes to the target's Defense [...] stat + /// stages". + /// + [Test] + public async Task BypassDefensiveStatBoosts_Always_Bypasses() + { + // Arrange + var script = new ChipAway(); + var bypass = false; + + // Act + script.BypassDefensiveStatBoosts(Substitute.For(), Substitute.For(), 0, ref bypass); + + // Assert + await Assert.That(bypass).IsTrue(); + } + + /// + /// Bulbapedia: Chip Away inflicts damage "ignoring any changes to the target's [...] evasion stat + /// stages". + /// + [Test] + public async Task BypassEvasionStatBoosts_Always_Bypasses() + { + // Arrange + var script = new ChipAway(); + var bypass = false; + + // Act + script.BypassEvasionStatBoosts(Substitute.For(), Substitute.For(), 0, ref bypass); + + // Assert + await Assert.That(bypass).IsTrue(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/Conversion2Tests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/Conversion2Tests.cs new file mode 100644 index 0000000..bea78b9 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/Conversion2Tests.cs @@ -0,0 +1,164 @@ +using PkmnLib.Dynamic.Libraries; +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; +using PkmnLib.Static.Moves; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Conversion 2 will randomly change the user's type to any type that +/// either resists or is immune to the type of the last damaging move it was hit by." From Generation V +/// onwards "Conversion 2 now targets an adjacent Pokémon" and considers that target's last used move. +/// From Generation IV onwards, "Conversion 2 will no longer change the user to any of its current types." +/// +public class Conversion2Tests +{ + private static readonly IDynamicLibrary Library = LibraryHelpers.LoadLibrary(); + + private static (Conversion2 script, IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData) + CreateTestSetup(TypeIdentifier? lastMoveType) + { + var script = new Conversion2(); + + var random = Substitute.For(); + random.GetInt().Returns(0); + var battle = Substitute.For(); + battle.Library.Returns(Library); + battle.Random.Returns(random); + + var userBattleData = Substitute.For(); + userBattleData.Battle.Returns(battle); + var user = Substitute.For(); + user.BattleData.Returns(userBattleData); + + var targetBattleData = Substitute.For(); + if (lastMoveType == null) + { + targetBattleData.LastMoveChoice.Returns((IMoveChoice?)null); + } + else + { + var moveData = Substitute.For(); + moveData.MoveType.Returns(lastMoveType.Value); + var learnedMove = Substitute.For(); + learnedMove.MoveData.Returns(moveData); + var lastChoice = Substitute.For(); + lastChoice.ChosenMove.Returns(learnedMove); + targetBattleData.LastMoveChoice.Returns(lastChoice); + } + + var target = Substitute.For(); + target.BattleData.Returns(targetBattleData); + + var move = Substitute.For(); + move.User.Returns(user); + var hitData = Substitute.For(); + move.GetHitData(target, 0).Returns(hitData); + + return (script, move, user, target, hitData); + } + + private static TypeIdentifier GetType(string name) + { + Library.StaticLibrary.Types.TryGetTypeIdentifier(name, out var type); + return type; + } + + /// + /// Helper that returns the single type the user was changed to, or null when + /// was never called. + /// + private static TypeIdentifier? GetSetType(IPokemon user) + { + var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetTypes"); + return call != null ? ((IReadOnlyList)call.GetArguments()[0]!).Single() : null; + } + + /// + /// Bulbapedia: Conversion 2 changes "the user's type to any type that either resists or is immune to + /// the type of the last damaging move" the target used. The chosen type must take less than neutral + /// damage from the target's last move type. + /// + [Test] + public async Task OnSecondaryEffect_TargetUsedMove_UserBecomesTypeResistingThatMove() + { + // Arrange + var normalType = GetType("normal"); + var (script, move, user, target, _) = CreateTestSetup(normalType); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - the new type resists (or is immune to) Normal + var newType = GetSetType(user); + await Assert.That(newType).IsNotNull(); + var effectiveness = Library.StaticLibrary.Types.GetAllEffectivenessFromAttacking(normalType) + .Single(x => x.Item1 == newType!.Value).Item2; + await Assert.That(effectiveness).IsLessThan(1f); + } + + /// + /// Bulbapedia: the move works off "the type of the last damaging move it was hit by" (the target's + /// last used move from Generation V onward) — if the target has not used a move, Conversion 2 fails. + /// + [Test] + public void OnSecondaryEffect_TargetHasNoLastMove_Fails() + { + // Arrange + var (script, move, user, target, hitData) = CreateTestSetup(null); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + user.DidNotReceive().SetTypes(Arg.Any>()); + } + + /// + /// A last move without a real type (type "none") provides no type to resist, so Conversion 2 fails. + /// + [Test] + public void OnSecondaryEffect_LastMoveHasNoType_Fails() + { + // Arrange + var (script, move, user, target, hitData) = CreateTestSetup(new TypeIdentifier(0, new StringKey("none"))); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + user.DidNotReceive().SetTypes(Arg.Any>()); + } + + /// + /// Bulbapedia (Generation IV onwards): "Conversion 2 will no longer change the user to any of its + /// current types." When the type that would be rolled is one the user already has, a different + /// resisting type must be chosen. + /// + [Test] + public async Task OnSecondaryEffect_RolledTypeIsUsersCurrentType_PicksDifferentType() + { + // Arrange + var normalType = GetType("normal"); + var (script, move, user, target, _) = CreateTestSetup(normalType); + // With the mocked random always returning 0, the script deterministically picks the resisting type + // with the lowest type identifier. Give the user exactly that type. + var predictedPick = Library.StaticLibrary.Types.GetAllEffectivenessFromAttacking(normalType) + .Where(x => x.effectiveness < 1).OrderBy(x => x.type.Value).First().Item1; + user.Types.Returns([predictedPick]); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - the user must not be "changed" to a type it already has + var newType = GetSetType(user); + await Assert.That(newType).IsNotNull(); + await Assert.That(newType!.Value).IsNotEqualTo(predictedPick); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ConversionTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ConversionTests.cs new file mode 100644 index 0000000..ff2e80c --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ConversionTests.cs @@ -0,0 +1,76 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; +using PkmnLib.Static.Moves; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Conversion now changes the user's current type to match the type of the +/// move the user has in its first move slot (even if that move cannot be selected in battle)." +/// +public class ConversionTests +{ + private static ILearnedMove CreateLearnedMoveOfType(TypeIdentifier type) + { + var moveData = Substitute.For(); + moveData.MoveType.Returns(type); + var learned = Substitute.For(); + learned.MoveData.Returns(moveData); + return learned; + } + + private static (Conversion script, IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData) + CreateTestSetup(params ILearnedMove?[] moves) + { + var script = new Conversion(); + var move = Substitute.For(); + var user = Substitute.For(); + user.Moves.Returns(moves); + move.User.Returns(user); + var target = Substitute.For(); + var hitData = Substitute.For(); + move.GetHitData(target, 0).Returns(hitData); + return (script, move, user, target, hitData); + } + + /// + /// Bulbapedia: "Conversion now changes the user's current type to match the type of the move the user + /// has in its first move slot". + /// + [Test] + public void OnSecondaryEffect_UserHasMoves_SetsUserTypeToFirstMoveSlotType() + { + // Arrange + var fireType = new TypeIdentifier(3, new StringKey("fire")); + var waterType = new TypeIdentifier(4, new StringKey("water")); + var (script, move, user, target, _) = + CreateTestSetup(CreateLearnedMoveOfType(fireType), CreateLearnedMoveOfType(waterType)); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - the first slot's type (fire) is used, not the second + user.Received(1).SetTypes(Arg.Is>(t => t.Count == 1 && t[0] == fireType)); + } + + /// + /// Bulbapedia: the type comes from "the move the user has in its first move slot" — with no known + /// moves there is no type to copy and the move fails. + /// + [Test] + public void OnSecondaryEffect_UserHasNoMoves_Fails() + { + // Arrange + var (script, move, user, target, hitData) = CreateTestSetup(null, null, null, null); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + hitData.Received(1).Fail(); + user.DidNotReceive().SetTypes(Arg.Any>()); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CopycatTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CopycatTests.cs new file mode 100644 index 0000000..c315171 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CopycatTests.cs @@ -0,0 +1,118 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static.Moves; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Copycat causes the user to use the last move that was used in the battle +/// (even if the last move was by the user)." The move fails if no prior move occurred, and certain moves +/// (such as Counter or Copycat itself) cannot be copied. +/// +public class CopycatTests +{ + private static (Copycat script, IMoveChoice choice) CreateTestSetup(string? lastMoveName) + { + var script = new Copycat(); + var user = Substitute.For(); + var battleData = Substitute.For(); + if (lastMoveName == null) + { + battleData.LastMoveChoice.Returns((IMoveChoice?)null); + } + else + { + var moveData = Substitute.For(); + moveData.Name.Returns(new StringKey(lastMoveName)); + var learnedMove = Substitute.For(); + learnedMove.MoveData.Returns(moveData); + var lastChoice = Substitute.For(); + lastChoice.ChosenMove.Returns(learnedMove); + battleData.LastMoveChoice.Returns(lastChoice); + } + user.BattleData.Returns(battleData); + + var choice = Substitute.For(); + choice.User.Returns(user); + return (script, choice); + } + + /// + /// Bulbapedia: "Copycat causes the user to use the last move that was used in the battle". + /// + [Test] + public async Task ChangeMove_LastMoveIsCopyable_ChangesMoveToLastMove() + { + // Arrange + var (script, choice) = CreateTestSetup("tackle"); + StringKey moveName = "copycat"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert + await Assert.That(moveName).IsEqualTo(new StringKey("tackle")); + choice.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: "The move fails if no prior move occurred." + /// + [Test] + public async Task ChangeMove_NoMoveUsedYet_Fails() + { + // Arrange + var (script, choice) = CreateTestSetup(null); + StringKey moveName = "copycat"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert + choice.Received(1).Fail(); + await Assert.That(moveName).IsEqualTo(new StringKey("copycat")); + } + + /// + /// Bulbapedia lists moves Copycat cannot copy (e.g. Counter, Copycat itself, Protect). If the last + /// used move is one of those, Copycat fails. + /// + [Test, Arguments("counter"), Arguments("copycat"), Arguments("protect")] + public async Task ChangeMove_LastMoveNotCopyable_Fails(string lastMove) + { + // Arrange + var (script, choice) = CreateTestSetup(lastMove); + StringKey moveName = "copycat"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert + choice.Received(1).Fail(); + await Assert.That(moveName).IsEqualTo(new StringKey("copycat")); + } + + /// + /// Technical test: outside of battle (no battle data) there is no last move, so Copycat fails. + /// + [Test] + public void ChangeMove_NoBattleData_Fails() + { + // Arrange + var script = new Copycat(); + var user = Substitute.For(); + user.BattleData.Returns((IPokemonBattleData?)null); + var choice = Substitute.For(); + choice.User.Returns(user); + StringKey moveName = "copycat"; + + // Act + script.ChangeMove(choice, ref moveName); + + // Assert + choice.Received(1).Fail(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CoreEnforcerTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CoreEnforcerTests.cs new file mode 100644 index 0000000..cd1fc0e --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CoreEnforcerTests.cs @@ -0,0 +1,181 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static.Species; +using PkmnLib.Static.Utils; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Core Enforcer deals damage. If the target has already used a move or had +/// a Bag item used on it by its Trainer in the same turn, Core Enforcer also suppresses the target's +/// Ability while it remains in battle." +/// +public class CoreEnforcerTests +{ + private static (CoreEnforcer script, IExecutingMove move, IMoveChoice currentChoice, IPokemon target, IHitData + hitData, IBattle battle) CreateTestSetup() + { + var script = new CoreEnforcer(); + var battle = Substitute.For(); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + var target = Substitute.For(); + target.BattleData.Returns(battleData); + + var currentChoice = Substitute.For(); + var move = Substitute.For(); + move.MoveChoice.Returns(currentChoice); + var hitData = Substitute.For(); + move.GetHitData(target, 0).Returns(hitData); + + return (script, move, currentChoice, target, hitData, battle); + } + + private static void SetTurnChoices(IBattle battle, params ITurnChoice[] choices) + { + battle.PreviousTurnChoices.Returns(new List> { choices }); + } + + /// + /// Bulbapedia: "If the target has already used a move [...] in the same turn, Core Enforcer also + /// suppresses the target's Ability while it remains in battle." + /// + [Test] + public void OnSecondaryEffect_TargetMovedEarlierInTurn_SuppressesTargetAbility() + { + // Arrange + var (script, move, currentChoice, target, hitData, battle) = CreateTestSetup(); + var targetChoice = Substitute.For(); + targetChoice.User.Returns(target); + SetTurnChoices(battle, targetChoice, currentChoice); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.Received(1).SuppressAbility(); + hitData.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: the suppression also happens if the target "had a Bag item used on it by its Trainer in + /// the same turn". + /// + [Test] + public void OnSecondaryEffect_ItemUsedEarlierInTurn_SuppressesTargetAbility() + { + // Arrange + var (script, move, currentChoice, target, hitData, battle) = CreateTestSetup(); + var itemChoice = Substitute.For(); + itemChoice.User.Returns(target); + SetTurnChoices(battle, itemChoice, currentChoice); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.Received(1).SuppressAbility(); + hitData.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: the Ability is only suppressed "If the target has already used a move [...] in the same + /// turn" — when Core Enforcer strikes before the target has acted, no suppression happens. + /// + [Test] + public void OnSecondaryEffect_TargetActsLaterInTurn_DoesNotSuppressAbility() + { + // Arrange + var (script, move, currentChoice, target, hitData, battle) = CreateTestSetup(); + var targetChoice = Substitute.For(); + targetChoice.User.Returns(target); + SetTurnChoices(battle, currentChoice, targetChoice); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.DidNotReceive().SuppressAbility(); + hitData.Received(1).Fail(); + } + + /// + /// Bulbapedia: suppression requires the target to have already acted — as the very first action of the + /// turn, Core Enforcer never suppresses. + /// + [Test] + public void OnSecondaryEffect_CoreEnforcerIsFirstActionOfTurn_DoesNotSuppressAbility() + { + // Arrange + var (script, move, currentChoice, target, hitData, battle) = CreateTestSetup(); + SetTurnChoices(battle, currentChoice); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.DidNotReceive().SuppressAbility(); + hitData.Received(1).Fail(); + } + + /// + /// Technical test: a target without battle data (not in battle) is left untouched. + /// + [Test] + public void OnSecondaryEffect_TargetHasNoBattleData_DoesNothing() + { + // Arrange + var (script, move, _, target, hitData, _) = CreateTestSetup(); + target.BattleData.Returns((IPokemonBattleData?)null); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.DidNotReceive().SuppressAbility(); + hitData.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: the condition is that "the target has already used a move" — an action by a different + /// Pokémon (e.g. the user's ally in a Double Battle) does not count as the target having acted. + /// + [Test] + public void OnSecondaryEffect_OnlyOtherPokemonActedEarlier_DoesNotSuppressAbility() + { + // Arrange + var (script, move, currentChoice, target, hitData, battle) = CreateTestSetup(); + var allyChoice = Substitute.For(); + allyChoice.User.Returns(Substitute.For()); + SetTurnChoices(battle, allyChoice, currentChoice); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.DidNotReceive().SuppressAbility(); + hitData.Received(1).Fail(); + } + + /// + /// Bulbapedia: "The move cannot suppress certain signature abilities including Multitype, Stance + /// Change, Schooling, Comatose, Shields Down, Disguise, RKS System, Battle Bond, Power Construct". + /// The script requests the suppression unconditionally; refuses + /// it when is false, so these abilities must carry that flag in the + /// Gen7 data. + /// + [Test, Arguments("multitype"), Arguments("stance_change"), Arguments("schooling"), Arguments("comatose"), + Arguments("shields_down"), Arguments("disguise"), Arguments("rks_system"), Arguments("battle_bond"), + Arguments("power_construct")] + public async Task OnSecondaryEffect_UnsuppressableAbility_IsProtectedByAbilityData(string abilityName) + { + // Arrange + var library = LibraryHelpers.LoadLibrary(); + + // Assert + await Assert.That(library.StaticLibrary.Abilities.TryGet(new StringKey(abilityName), out var ability)).IsTrue(); + await Assert.That(ability!.CanBeChanged).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CounterTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CounterTests.cs new file mode 100644 index 0000000..fa04d89 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CounterTests.cs @@ -0,0 +1,194 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.Models.Choices; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Dynamic.ScriptHandling.Registry; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; +using PkmnLib.Static.Moves; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script and its . +/// Gen VII Bulbapedia behavior: Counter retaliates against the last physical move that damaged the user +/// this turn, dealing twice the damage the user received to the Pokémon that dealt it. Since Generation II +/// "Counter now affects all physical moves, dealing double damage"; it fails when the user was not hit by +/// a physical move first. +/// +public class CounterTests +{ + /// + /// Creates a user whose volatile scripts contain a that has recorded + /// an incoming physical hit of the given damage by . + /// + private static IPokemon CreateUserHitBy(IPokemon? attacker, uint damage, bool physical = true) + { + var user = Substitute.For(); + var userVolatile = new ScriptSet(user); + user.Volatile.Returns(userVolatile); + user.GetScripts().Returns(_ => new ScriptIterator(new List>())); + + var helper = new CounterHelperEffect(); + userVolatile.Add(helper); + if (attacker != null) + { + var incomingMove = Substitute.For(); + var useMove = Substitute.For(); + useMove.Category.Returns(physical ? MoveCategory.Physical : MoveCategory.Special); + incomingMove.UseMove.Returns(useMove); + incomingMove.User.Returns(attacker); + var incomingHit = Substitute.For(); + incomingHit.Damage.Returns(damage); + incomingMove.GetHitData(user, 0).Returns(incomingHit); + helper.OnIncomingHit(incomingMove, user, 0); + } + + return user; + } + + /// + /// Counter watches for incoming hits over the whole turn: at the start of the turn it attaches its + /// to the user. + /// + [Test] + public async Task OnBeforeTurnStart_Always_AddsCounterHelperEffectToUser() + { + // Arrange + var script = new Counter(); + var user = Substitute.For(); + var userVolatile = new ScriptSet(user); + user.Volatile.Returns(userVolatile); + user.GetScripts().Returns(_ => new ScriptIterator(new List>())); + var choice = Substitute.For(); + choice.User.Returns(user); + + // Act + script.OnBeforeTurnStart(choice); + + // Assert + await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); + } + + /// + /// Bulbapedia: Counter strikes back at the Pokémon that last hit the user with a physical move — the + /// move is redirected to that attacker. + /// + [Test] + public async Task ChangeTargets_UserWasHitByPhysicalMove_TargetsTheAttacker() + { + // Arrange + var script = new Counter(); + var attacker = Substitute.For(); + var user = CreateUserHitBy(attacker, 40); + var choice = Substitute.For(); + choice.User.Returns(user); + IReadOnlyList targets = new IPokemon?[] { Substitute.For() }; + + // Act + script.ChangeTargets(choice, ref targets); + + // Assert + await Assert.That(targets.Single()).IsEqualTo(attacker); + choice.DidNotReceive().Fail(); + } + + /// + /// Bulbapedia: Counter fails when the user has not been hit by a physical move this turn. + /// + [Test] + public void ChangeTargets_UserWasNotHit_Fails() + { + // Arrange + var script = new Counter(); + var user = CreateUserHitBy(null, 0); + var choice = Substitute.For(); + choice.User.Returns(user); + IReadOnlyList targets = new IPokemon?[] { Substitute.For() }; + + // Act + script.ChangeTargets(choice, ref targets); + + // Assert + choice.Received(1).Fail(); + } + + /// + /// Bulbapedia (Generation II onwards): "Counter now affects all physical moves, dealing double + /// damage." The damage dealt is twice the damage of the recorded hit. + /// + [Test, Arguments(40u, 80u), Arguments(1u, 2u), Arguments(123u, 246u)] + public async Task ChangeMoveDamage_UserWasHitByPhysicalMove_DealsDoubleTheDamageTaken(uint damageTaken, + uint expectedDamage) + { + // Arrange + var script = new Counter(); + var attacker = Substitute.For(); + var user = CreateUserHitBy(attacker, damageTaken); + var move = Substitute.For(); + move.User.Returns(user); + uint damage = 0; + + // Act + script.ChangeMoveDamage(move, attacker, 0, ref damage); + + // Assert + await Assert.That(damage).IsEqualTo(expectedDamage); + } + + /// + /// Bulbapedia: Counter retaliates against the Pokémon that hit the user — against any other target the + /// hit fails. + /// + [Test] + public void ChangeMoveDamage_TargetIsNotTheLastAttacker_FailsHit() + { + // Arrange + var script = new Counter(); + var attacker = Substitute.For(); + var user = CreateUserHitBy(attacker, 40); + var someoneElse = Substitute.For(); + var move = Substitute.For(); + move.User.Returns(user); + var hitData = Substitute.For(); + move.GetHitData(someoneElse, 0).Returns(hitData); + uint damage = 0; + + // Act + script.ChangeMoveDamage(move, someoneElse, 0, ref damage); + + // Assert + hitData.Received(1).Fail(); + } + + /// + /// Bulbapedia: only physical moves can be countered — the helper records physical hits. + /// + [Test] + public async Task CounterHelperEffect_PhysicalHit_RecordsAttackerAndDamage() + { + // Arrange + var attacker = Substitute.For(); + var user = CreateUserHitBy(attacker, 40); + + // Assert + var helper = ((IScriptSet)user.Volatile).Get(); + await Assert.That(helper!.LastHitBy).IsEqualTo(attacker); + await Assert.That(helper.LastDamage).IsEqualTo(40u); + } + + /// + /// Bulbapedia (Generation IV onwards): special moves cannot be countered — the helper ignores + /// non-physical hits, so Counter fails after a special hit. + /// + [Test] + public async Task CounterHelperEffect_SpecialHit_IsNotRecorded() + { + // Arrange + var attacker = Substitute.For(); + var user = CreateUserHitBy(attacker, 40, false); + + // Assert + var helper = ((IScriptSet)user.Volatile).Get(); + await Assert.That(helper!.LastHitBy).IsNull(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CovetTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CovetTests.cs new file mode 100644 index 0000000..ed10133 --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CovetTests.cs @@ -0,0 +1,92 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Covet inflicts damage and steals the target's held item if it is holding +/// any. If the target is not holding an item or the user is already holding an item, Covet cannot steal an +/// item." +/// +public class CovetTests +{ + private static (Covet script, IExecutingMove move, IPokemon user, IPokemon target) CreateTestSetup( + IItem? targetItem) + { + var script = new Covet(); + var move = Substitute.For(); + var user = Substitute.For(); + // Explicitly return null to suppress NSubstitute's auto-substitution; Covet must see an empty-handed user. + user.HeldItem.Returns((IItem?)null); + move.User.Returns(user); + + var target = Substitute.For(); + target.HeldItem.Returns(targetItem); + if (targetItem != null) + { + target.TryStealHeldItem(out Arg.Any()).Returns(x => + { + x[0] = targetItem; + return true; + }); + } + + return (script, move, user, target); + } + + /// + /// Bulbapedia: "Covet inflicts damage and steals the target's held item if it is holding any." The + /// item is removed from the target and ends up held by the user. + /// + [Test] + public void OnSecondaryEffect_TargetHoldsItem_UserStealsIt() + { + // Arrange + var item = Substitute.For(); + var (script, move, user, target) = CreateTestSetup(item); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - the item is taken from the target and given to the user + target.Received(1).TryStealHeldItem(out Arg.Any()); + user.Received(1).ForceSetHeldItem(item); + } + + /// + /// Bulbapedia: "If the target is not holding an item [...], Covet cannot steal an item." + /// + [Test] + public void OnSecondaryEffect_TargetHoldsNoItem_NothingIsStolen() + { + // Arrange + var (script, move, user, target) = CreateTestSetup(null); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + target.DidNotReceive().TryStealHeldItem(out Arg.Any()); + user.DidNotReceive().ForceSetHeldItem(Arg.Any()); + } + + /// + /// Bulbapedia: "If [...] the user is already holding an item, Covet cannot steal an item." + /// + [Test] + public void OnSecondaryEffect_UserAlreadyHoldsItem_TargetItemIsNotStolen() + { + // Arrange + var targetItem = Substitute.For(); + var (script, move, user, target) = CreateTestSetup(targetItem); + user.HeldItem.Returns(Substitute.For()); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert - the user must not end up with the target's item + user.DidNotReceive().ForceSetHeldItem(targetItem); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CraftyShieldTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CraftyShieldTests.cs new file mode 100644 index 0000000..5b5c30d --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CraftyShieldTests.cs @@ -0,0 +1,119 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Dynamic.ScriptHandling.Registry; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Side; +using PkmnLib.Static.Moves; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script and the it +/// applies. Gen VII Bulbapedia behavior: "Crafty Shield protects all Pokémon on the user's side of the +/// field from status moves." +/// +public class CraftyShieldTests +{ + private static (CraftyShield script, IExecutingMove move, IScriptSet sideVolatile) CreateTestSetup() + { + var script = new CraftyShield(); + + var side = Substitute.For(); + // Use a real script set so the side script added by Crafty Shield can be inspected afterwards. + var sideVolatile = new ScriptSet(side); + side.VolatileScripts.Returns(sideVolatile); + side.GetScripts().Returns(_ => new ScriptIterator(new List>())); + + var battle = Substitute.For(); + battle.Sides.Returns(new[] { side }); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + battleData.SideIndex.Returns((byte)0); + + var user = Substitute.For(); + user.BattleData.Returns(battleData); + var move = Substitute.For(); + move.User.Returns(user); + + return (script, move, sideVolatile); + } + + private static IExecutingMove CreateExecutingMoveOfCategory(MoveCategory category) + { + var useMove = Substitute.For(); + useMove.Category.Returns(category); + var move = Substitute.For(); + move.UseMove.Returns(useMove); + return move; + } + + /// + /// Bulbapedia: "Crafty Shield protects all Pokémon on the user's side of the field" — using the move + /// attaches the to the user's side. + /// + [Test] + public async Task OnSecondaryEffect_Used_AddsCraftyShieldEffectToUsersSide() + { + // Arrange + var (script, move, sideVolatile) = CreateTestSetup(); + + // Act + script.OnSecondaryEffect(move, Substitute.For(), 0); + + // Assert + await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); + } + + /// + /// Bulbapedia: the shield protects "from status moves" — an incoming status move is stopped. + /// + [Test] + public async Task CraftyShieldEffect_StatusMove_IsStopped() + { + // Arrange + var effect = new CraftyShieldEffect(); + var move = CreateExecutingMoveOfCategory(MoveCategory.Status); + var stop = false; + + // Act + effect.StopBeforeMove(move, ref stop); + + // Assert + await Assert.That(stop).IsTrue(); + } + + /// + /// Bulbapedia: Crafty Shield only blocks status moves — damaging moves go through it unhindered. + /// + [Test, Arguments(MoveCategory.Physical), Arguments(MoveCategory.Special)] + public async Task CraftyShieldEffect_DamagingMove_IsNotStopped(MoveCategory category) + { + // Arrange + var effect = new CraftyShieldEffect(); + var move = CreateExecutingMoveOfCategory(category); + var stop = false; + + // Act + effect.StopBeforeMove(move, ref stop); + + // Assert + await Assert.That(stop).IsFalse(); + } + + /// + /// Technical test: outside of battle (no battle data) no shield can be raised and nothing happens. + /// + [Test] + public async Task OnSecondaryEffect_NoBattleData_DoesNothing() + { + // Arrange + var (script, move, sideVolatile) = CreateTestSetup(); + move.User.BattleData.Returns((IPokemonBattleData?)null); + + // Act + script.OnSecondaryEffect(move, Substitute.For(), 0); + + // Assert - no effect is added + await Assert.That(sideVolatile.Contains(ScriptUtils.ResolveName())).IsFalse(); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CrushGripTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CrushGripTests.cs new file mode 100644 index 0000000..2a6214e --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CrushGripTests.cs @@ -0,0 +1,62 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Static; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script. +/// Gen VII Bulbapedia behavior: "Crush Grip deals damage. Its power is greater the more HP the target +/// has." Power = 120 × (Current HP target / Max HP target), with a minimum of 1. +/// +public class CrushGripTests +{ + private static (CrushGrip script, IExecutingMove move, IPokemon target) CreateTestSetup(uint currentHealth, + uint maxHealth) + { + var script = new CrushGrip(); + var move = Substitute.For(); + var target = Substitute.For(); + target.CurrentHealth.Returns(currentHealth); + target.BoostedStats.Returns(new StatisticSet(maxHealth, 1, 1, 1, 1, 1)); + return (script, move, target); + } + + /// + /// Bulbapedia: "Power = 120 × (Current HP target / Max HP target)" — at full HP the power is 120, and + /// it scales down (with integer truncation) as the target's HP drops. + /// + [Test, Arguments(100u, 100u, (ushort)120), Arguments(200u, 200u, (ushort)120), Arguments(50u, 100u, (ushort)60), + Arguments(75u, 100u, (ushort)90), Arguments(33u, 100u, (ushort)39), Arguments(1u, 4u, (ushort)30)] + public async Task ChangeBasePower_ScalesWithTargetCurrentHp(uint currentHealth, uint maxHealth, + ushort expectedPower) + { + // Arrange + var (script, move, target) = CreateTestSetup(currentHealth, maxHealth); + ushort basePower = 120; + + // Act + script.ChangeBasePower(move, target, 0, ref basePower); + + // Assert + await Assert.That(basePower).IsEqualTo(expectedPower); + } + + /// + /// Bulbapedia: the power varies "between 1 and 120 [...] with a minimum of 1" — even against a target + /// with almost no HP left, the power cannot drop below 1. + /// + [Test, Arguments(1u, 200u), Arguments(1u, 121u)] + public async Task ChangeBasePower_TargetAtVeryLowHp_PowerIsAtLeastOne(uint currentHealth, uint maxHealth) + { + // Arrange + var (script, move, target) = CreateTestSetup(currentHealth, maxHealth); + ushort basePower = 120; + + // Act + script.ChangeBasePower(move, target, 0, ref basePower); + + // Assert + await Assert.That(basePower).IsEqualTo((ushort)1); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CurseTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CurseTests.cs new file mode 100644 index 0000000..da8a6db --- /dev/null +++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/CurseTests.cs @@ -0,0 +1,212 @@ +using PkmnLib.Dynamic.Models; +using PkmnLib.Dynamic.ScriptHandling; +using PkmnLib.Dynamic.ScriptHandling.Registry; +using PkmnLib.Plugin.Gen7.Scripts.Moves; +using PkmnLib.Plugin.Gen7.Scripts.Pokemon; +using PkmnLib.Static; + +namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; + +/// +/// Tests for the move script and the it applies. +/// Gen VII Bulbapedia behavior — non-Ghost user: "The user's Speed stat will drop by one stage and its +/// Attack stat and Defense stat will rise by one stage each." Ghost-type user: "The user will lose half of +/// its maximum HP (rounded down) and put a curse on the target", and "A cursed Pokémon will lose ¼ of its +/// maximum HP at the end of each turn." +/// +public class CurseTests +{ + private static (Curse script, IExecutingMove move, IPokemon user, IPokemon target, IScriptSet targetVolatile) + CreateTestSetup(bool userIsGhost, uint userMaxHealth = 100, uint userCurrentHealth = 100) + { + var script = new Curse(); + var library = LibraryHelpers.LoadLibrary(); + library.StaticLibrary.Types.TryGetTypeIdentifier("ghost", out var ghostType); + library.StaticLibrary.Types.TryGetTypeIdentifier("normal", out var normalType); + + var battle = Substitute.For(); + battle.Library.Returns(library); + var battleData = Substitute.For(); + battleData.Battle.Returns(battle); + + var user = Substitute.For(); + user.BattleData.Returns(battleData); + user.Types.Returns(new[] { userIsGhost ? ghostType : normalType }); + user.MaxHealth.Returns(userMaxHealth); + user.CurrentHealth.Returns(userCurrentHealth); + var move = Substitute.For(); + move.User.Returns(user); + + var target = Substitute.For(); + // Use a real script set so the curse applied to the target can be inspected afterwards. + var targetVolatile = new ScriptSet(target); + target.Volatile.Returns(targetVolatile); + target.GetScripts().Returns(_ => new ScriptIterator(new List>())); + + return (script, move, user, target, targetVolatile); + } + + /// + /// Helper that checks whether a stat boost change was applied to the given Pokémon. + /// + private static bool ReceivedStatBoost(IPokemon pokemon, Statistic stat, sbyte amount) => + pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost" && + (Statistic)c.GetArguments()[0]! == stat && + (sbyte)c.GetArguments()[1]! == amount); + + /// + /// Helper to extract the damage amount from a substitute's received Damage calls. + /// + private static uint? GetDamageAmount(IPokemon pokemon) + { + var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage"); + return call != null ? (uint)call.GetArguments()[0]! : null; + } + + /// + /// Bulbapedia (non-Ghost user): "The user's Speed stat will drop by one stage and its Attack stat and + /// Defense stat will rise by one stage each." + /// + [Test] + public async Task OnSecondaryEffect_NonGhostUser_LowersSpeedAndRaisesAttackAndDefense() + { + // Arrange + var (script, move, user, target, _) = CreateTestSetup(false); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(ReceivedStatBoost(user, Statistic.Speed, -1)).IsTrue(); + await Assert.That(ReceivedStatBoost(user, Statistic.Attack, 1)).IsTrue(); + await Assert.That(ReceivedStatBoost(user, Statistic.Defense, 1)).IsTrue(); + } + + /// + /// Bulbapedia: only a Ghost-type user pays HP and curses the target — a non-Ghost user takes no damage + /// and does not curse the target. + /// + [Test] + public async Task OnSecondaryEffect_NonGhostUser_DoesNotDamageUserOrCurseTarget() + { + // Arrange + var (script, move, user, target, targetVolatile) = CreateTestSetup(false); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(GetDamageAmount(user)).IsNull(); + await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsFalse(); + } + + /// + /// Bulbapedia (Ghost-type user): "The user will lose half of its maximum HP (rounded down)". At full + /// HP that is half of its current HP as well. + /// + [Test] + public async Task OnSecondaryEffect_GhostUserAtFullHp_UserLosesHalfItsMaximumHp() + { + // Arrange + var (script, move, user, target, _) = CreateTestSetup(true, 100, 100); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(50u); + } + + /// + /// Bulbapedia (Ghost-type user): the user pays HP to "put a curse on the target" — the + /// is attached to the target. + /// + [Test] + public async Task OnSecondaryEffect_GhostUser_CursesTarget() + { + // Arrange + var (script, move, _, target, targetVolatile) = CreateTestSetup(true); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); + } + + /// + /// Bulbapedia (Ghost-type user): "The user will lose half of its maximum HP (rounded down)" — the cost + /// is based on maximum HP, not on the HP the user has left. + /// + [Test] + public async Task OnSecondaryEffect_GhostUserAtHalfHp_UserStillLosesHalfItsMaximumHp() + { + // Arrange + var (script, move, user, target, _) = CreateTestSetup(true, 100, 50); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(GetDamageAmount(user)!.Value).IsEqualTo(50u); + } + + /// + /// Bulbapedia (Ghost-type user): "If this causes the user's HP to drop to 0, the move will execute + /// fully but cause the user to faint." — the curse is still put on the target. + /// + [Test] + public async Task OnSecondaryEffect_GhostUserFaintsFromHpCost_TargetIsStillCursed() + { + // Arrange + var (script, move, user, target, targetVolatile) = CreateTestSetup(true); + // The first read of CurrentHealth computes the HP cost; afterwards the user has fainted. + user.CurrentHealth.Returns(100u, 0u); + + // Act + script.OnSecondaryEffect(move, target, 0); + + // Assert + await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); + } + + /// + /// Bulbapedia: "A cursed Pokémon will lose ¼ of its maximum HP at the end of each turn." At full HP + /// that is a quarter of its current HP as well. + /// + [Test] + public async Task GhostCurseEffect_OnEndTurnAtFullHp_CursedPokemonLosesQuarterOfMaximumHp() + { + // Arrange + var cursed = Substitute.For(); + cursed.MaxHealth.Returns(100u); + cursed.CurrentHealth.Returns(100u); + var effect = new GhostCurseEffect(cursed); + + // Act + effect.OnEndTurn(Substitute.For(), Substitute.For()); + + // Assert + await Assert.That(GetDamageAmount(cursed)!.Value).IsEqualTo(25u); + } + + /// + /// Bulbapedia: "A cursed Pokémon will lose ¼ of its maximum HP at the end of each turn." — the drain + /// is based on maximum HP, not on the HP the Pokémon has left. + /// + [Test] + public async Task GhostCurseEffect_OnEndTurnAtLowHp_CursedPokemonStillLosesQuarterOfMaximumHp() + { + // Arrange + var cursed = Substitute.For(); + cursed.MaxHealth.Returns(100u); + cursed.CurrentHealth.Returns(40u); + var effect = new GhostCurseEffect(cursed); + + // Act + effect.OnEndTurn(Substitute.For(), Substitute.For()); + + // Assert + await Assert.That(GetDamageAmount(cursed)!.Value).IsEqualTo(25u); + } +} \ No newline at end of file diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Captivate.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Captivate.cs index 162c037..72f3f56 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Captivate.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Captivate.cs @@ -9,7 +9,7 @@ public class Captivate : Script, IScriptOnSecondaryEffect public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit) { var user = move.User; - if (target.Gender == Gender.Genderless || target.Gender == user.Gender) + if (target.Gender == Gender.Genderless || target.Gender == user.Gender || user.Gender == Gender.Genderless) { move.GetHitData(target, hit).Fail(); return; diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Conversion2.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Conversion2.cs index d5e1b7f..8ff8858 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Conversion2.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Conversion2.cs @@ -16,7 +16,7 @@ public class Conversion2 : Script, IScriptOnSecondaryEffect var typeLibrary = move.User.BattleData!.Battle.Library.StaticLibrary.Types; // Get all types against which the last move would be not very effective var type = typeLibrary.GetAllEffectivenessFromAttacking(lastMoveByTarget.ChosenMove.MoveData.MoveType) - .Where(x => x.effectiveness < 1) + .Where(x => x.effectiveness < 1 && !move.User.Types.Contains(x.type)) // Shuffle them randomly, but deterministically .OrderBy(_ => move.User.BattleData.Battle.Random.GetInt()).ThenBy(x => x.type.Value) // And grab the first one diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/CoreEnforcer.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/CoreEnforcer.cs index f34a4aa..802015c 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/CoreEnforcer.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/CoreEnforcer.cs @@ -11,8 +11,8 @@ public class CoreEnforcer : Script, IScriptOnSecondaryEffect return; var turnChoices = battleData.Battle.PreviousTurnChoices.Last(); var currentChoiceIndex = turnChoices.IndexOf(move.MoveChoice); - if (currentChoiceIndex == -1 || - !turnChoices.Take(currentChoiceIndex).Any(choice => choice is IMoveChoice or IItemChoice)) + if (currentChoiceIndex == -1 || !turnChoices.Take(currentChoiceIndex) + .Any(choice => choice is IMoveChoice or IItemChoice && choice.User == target)) { move.GetHitData(target, hit).Fail(); return; diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Covet.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Covet.cs index d085ebd..3b04f5c 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Covet.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Covet.cs @@ -8,7 +8,9 @@ public class Covet : Script, IScriptOnSecondaryEffect { if (target.HeldItem == null) return; - if (!move.User.TryStealHeldItem(out var item)) + if (move.User.HeldItem is not null) + return; + if (!target.TryStealHeldItem(out var item)) return; _ = move.User.ForceSetHeldItem(item); } diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Curse.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Curse.cs index 6889e8e..e763780 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Curse.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Curse.cs @@ -16,9 +16,7 @@ public class Curse : Script, IScriptOnSecondaryEffect return; if (move.User.Types.Contains(ghostType)) { - move.User.Damage(move.User.CurrentHealth / 2, DamageSource.Misc, forceDamage: true); - if (move.User.CurrentHealth == 0) - return; + move.User.Damage(move.User.MaxHealth / 2, DamageSource.Misc, forceDamage: true); target.Volatile.Add(new GhostCurseEffect(target)); } else diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/GhostCurseEffect.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/GhostCurseEffect.cs index 6347d75..2e48d8e 100644 --- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/GhostCurseEffect.cs +++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/GhostCurseEffect.cs @@ -13,12 +13,12 @@ public class GhostCurseEffect : Script, IScriptOnEndTurn, IAIInfoScriptExpectedE /// public void OnEndTurn(IScriptSource owner, IBattle battle) { - _pokemon.Damage(_pokemon.CurrentHealth / 4, DamageSource.Misc); + _pokemon.Damage(_pokemon.MaxHealth / 4, DamageSource.Misc); } /// public void ExpectedEndOfTurnDamage(IPokemon pokemon, ref int damage) { - damage += (int)(_pokemon.CurrentHealth / 4f); + damage += (int)(_pokemon.MaxHealth / 4f); } } \ No newline at end of file