diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BanefulBunkerTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BanefulBunkerTests.cs
new file mode 100644
index 0000000..ae96e06
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BanefulBunkerTests.cs
@@ -0,0 +1,283 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.Models.Choices;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
+using PkmnLib.Static.Moves;
+using PkmnLib.Static.Utils;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script and its attached .
+/// Behavior is verified against the Bulbapedia page for Baneful Bunker (Generation VII).
+///
+public class BanefulBunkerTests
+{
+ ///
+ /// Creates a fully mocked setup for driving 's inherited
+ /// . The target of the secondary effect is the
+ /// Pokémon using Baneful Bunker itself, as the move is self-targeted.
+ ///
+ private static (BanefulBunker script, IExecutingMove move, IPokemon target, IHitData hitData, IScriptSet volatileSet
+ ) CreateProtectSetup(bool userMovesLast, float randomRoll)
+ {
+ var script = new BanefulBunker();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var hitData = Substitute.For();
+ move.GetHitData(target, 0).Returns(hitData);
+
+ // A queue with a remaining choice means another Pokémon still has to move after the user.
+ var queue = userMovesLast ? new BattleChoiceQueue([]) : new BattleChoiceQueue([Substitute.For()]);
+
+ var random = Substitute.For();
+ random.GetFloat().Returns(randomRoll);
+
+ var battle = Substitute.For();
+ battle.ChoiceQueue.Returns(queue);
+ battle.Random.Returns(random);
+
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ target.BattleData.Returns(battleData);
+
+ // Give the mock a real script iterator (used by the volatile add hook) and a real volatile script set.
+ target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet volatileSet = new ScriptSet(target);
+ target.Volatile.Returns(volatileSet);
+
+ return (script, move, target, hitData, volatileSet);
+ }
+
+ ///
+ /// Creates a fully mocked setup for driving , the
+ /// volatile script that attaches to its user.
+ ///
+ private static (BanefulBunkerEffect effect, IExecutingMove move, IPokemon target, IPokemon attacker)
+ CreateBlockSetup(bool isContact, bool hasProtectFlag, MoveCategory category = MoveCategory.Physical)
+ {
+ var effect = new BanefulBunkerEffect();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var hitData = Substitute.For();
+ hitData.IsContact.Returns(isContact);
+ move.GetHitData(target, 0).Returns(hitData);
+
+ var useMove = Substitute.For();
+ useMove.HasFlag(new StringKey("protect")).Returns(hasProtectFlag);
+ useMove.Category.Returns(category);
+ move.UseMove.Returns(useMove);
+
+ var attacker = Substitute.For();
+ attacker.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ move.User.Returns(attacker);
+
+ target.BattleData.Returns(Substitute.For());
+
+ return (effect, move, target, attacker);
+ }
+
+ ///
+ /// Helper to extract the status name from a Pokémon's received calls.
+ ///
+ private static string? GetStatusSet(IPokemon pokemon)
+ {
+ var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetStatus");
+ return call != null ? ((StringKey)call.GetArguments()[0]!).ToString() : null;
+ }
+
+ ///
+ /// Bulbapedia: "Baneful Bunker protects the user from all effects of moves that target it during the turn
+ /// it is used, including damage."
+ /// Using the move attaches the volatile script to the user.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_FirstUse_AddsBanefulBunkerEffect()
+ {
+ // Arrange
+ var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.0f);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(volatileSet.Get()).IsNotNull();
+ hitData.DidNotReceive().Fail();
+ }
+
+ ///
+ /// Bulbapedia: "If the user goes last in the turn, the move will fail."
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_UserMovesLast_Fails()
+ {
+ // Arrange
+ var (script, move, target, hitData, volatileSet) = CreateProtectSetup(true, 0.0f);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ await Assert.That(volatileSet.Get()).IsNull();
+ }
+
+ ///
+ /// Bulbapedia: "The chance that Baneful Bunker will succeed also drops each time the user successfully and
+ /// consecutively uses Endure, any protection move that only affects the user, Quick Guard, or Wide Guard."
+ /// A successful use registers a consecutive protection turn on the .
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_FirstUse_RegistersConsecutiveProtectionUse()
+ {
+ // Arrange
+ var (script, move, target, _, volatileSet) = CreateProtectSetup(false, 0.0f);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ var failure = volatileSet.Get();
+ await Assert.That(failure).IsNotNull();
+ await Assert.That(failure!.ProtectTurns).IsEqualTo(1);
+ await Assert.That(failure.UsedProtect).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Each time, the chance of success is divided by 3."
+ /// After one consecutive protection use the chance is 1/3, so a roll above 1/3 makes the move fail.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_SecondConsecutiveUse_RollAboveOneThird_Fails()
+ {
+ // Arrange
+ var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.5f);
+ volatileSet.Add(new ProtectionFailureScript { ProtectTurns = 1 });
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ await Assert.That(volatileSet.Get()).IsNull();
+ }
+
+ ///
+ /// Bulbapedia: "Each time, the chance of success is divided by 3."
+ /// After one consecutive protection use the chance is 1/3, so a roll below 1/3 still succeeds and
+ /// increments the consecutive use counter.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_SecondConsecutiveUse_RollBelowOneThird_Succeeds()
+ {
+ // Arrange
+ var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.2f);
+ var failure = new ProtectionFailureScript { ProtectTurns = 1 };
+ volatileSet.Add(failure);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.DidNotReceive().Fail();
+ await Assert.That(volatileSet.Get()).IsNotNull();
+ await Assert.That(failure.ProtectTurns).IsEqualTo(2);
+ }
+
+ ///
+ /// Bulbapedia: "Baneful Bunker protects the user from all effects of moves that target it during the turn
+ /// it is used, including damage."
+ /// A move that can be protected against (it has the protect flag) is blocked by the
+ /// .
+ ///
+ [Test]
+ public async Task BlockIncomingHit_MoveWithProtectFlag_BlocksHit()
+ {
+ // Arrange
+ var (effect, move, target, _) = CreateBlockSetup(false, true);
+ var block = false;
+
+ // Act
+ effect.BlockIncomingHit(move, target, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Baneful Bunker protects the user from all effects of moves that target it"; moves that
+ /// bypass protection (they lack the protect flag) are not blocked by the .
+ ///
+ [Test]
+ public async Task BlockIncomingHit_MoveWithoutProtectFlag_DoesNotBlock()
+ {
+ // Arrange
+ var (effect, move, target, _) = CreateBlockSetup(false, false);
+ var block = false;
+
+ // Act
+ effect.BlockIncomingHit(move, target, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "If Baneful Bunker blocks an attack that would make contact with the user, the attacker
+ /// becomes poisoned (unless they are immune)."
+ ///
+ [Test]
+ public async Task BlockIncomingHit_BlockedContactMove_PoisonsAttacker()
+ {
+ // Arrange
+ var (effect, move, target, attacker) = CreateBlockSetup(true, true);
+ var block = false;
+
+ // Act
+ effect.BlockIncomingHit(move, target, 0, ref block);
+
+ // Assert
+ await Assert.That(GetStatusSet(attacker)).IsEqualTo("poisoned");
+ }
+
+ ///
+ /// Bulbapedia: "If Baneful Bunker blocks an attack that would make contact with the user, the attacker
+ /// becomes poisoned (unless they are immune)."
+ /// A blocked attack that does not make contact must not poison the attacker.
+ ///
+ [Test]
+ public async Task BlockIncomingHit_BlockedNonContactMove_DoesNotPoisonAttacker()
+ {
+ // Arrange
+ var (effect, move, target, attacker) = CreateBlockSetup(false, true);
+ var block = false;
+
+ // Act
+ effect.BlockIncomingHit(move, target, 0, ref block);
+
+ // Assert
+ await Assert.That(attacker.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "If Baneful Bunker blocks an attack that would make contact with the user, the attacker
+ /// becomes poisoned (unless they are immune)."
+ /// A contact move that bypasses the protection (it lacks the protect flag, e.g. Shadow Force) is not
+ /// blocked, so the attacker must not be poisoned.
+ ///
+ [Test]
+ public async Task BlockIncomingHit_ContactMoveThatBypassesProtection_DoesNotPoisonAttacker()
+ {
+ // Arrange
+ var (effect, move, target, attacker) = CreateBlockSetup(true, false);
+ var block = false;
+
+ // Act
+ effect.BlockIncomingHit(move, target, 0, ref block);
+
+ // Assert - the hit was not blocked, so no poison is applied
+ await Assert.That(block).IsFalse();
+ await Assert.That(attacker.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BatonPassTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BatonPassTests.cs
new file mode 100644
index 0000000..a186828
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BatonPassTests.cs
@@ -0,0 +1,255 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.Models.Choices;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
+using PkmnLib.Plugin.Gen7.Scripts.Utils;
+using PkmnLib.Static;
+using PkmnLib.Static.Utils;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script.
+/// Behavior is verified against the Bulbapedia page for Baton Pass (Generation VII).
+/// In this codebase, volatile scripts that are transferred by Baton Pass are marked with
+/// (e.g. ).
+///
+public class BatonPassTests
+{
+ ///
+ /// Creates a mocked Pokémon with a real volatile and a real
+ /// .
+ ///
+ private static IPokemon CreateMockPokemon(out IScriptSet volatileSet)
+ {
+ var pokemon = Substitute.For();
+ pokemon.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ var set = new ScriptSet(pokemon);
+ pokemon.Volatile.Returns(set);
+ pokemon.StatBoost.Returns(new StatBoostStatisticSet());
+ volatileSet = set;
+ return pokemon;
+ }
+
+ ///
+ /// Creates a fully mocked test setup for Baton Pass tests. The Pokémon to switch in is stored in the
+ /// move choice's under the to_switch key.
+ ///
+ private static (BatonPass script, IExecutingMove move, IPokemon user, IPokemon toSwitch, IBattleSide side,
+ IScriptSet userVolatile, IScriptSet switchInVolatile) CreateTestSetup()
+ {
+ var script = new BatonPass();
+ var move = Substitute.For();
+ var user = CreateMockPokemon(out var userVolatile);
+ var toSwitch = CreateMockPokemon(out var switchInVolatile);
+
+ var moveChoice = Substitute.For();
+ moveChoice.AdditionalData.Returns(new Dictionary { { "to_switch", toSwitch } });
+ move.MoveChoice.Returns(moveChoice);
+
+ var side = Substitute.For();
+ var battle = Substitute.For();
+ battle.Sides.Returns(new[] { side });
+
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ battleData.SideIndex.Returns((byte)0);
+ battleData.Position.Returns((byte)1);
+ user.BattleData.Returns(battleData);
+
+ move.User.Returns(user);
+ return (script, move, user, toSwitch, side, userVolatile, switchInVolatile);
+ }
+
+ ///
+ /// Bulbapedia: "Baton Pass switches out the user and passes several effects to the Pokémon switched in
+ /// its place."
+ /// The user is swapped out for the chosen Pokémon on its own field position.
+ ///
+ [Test]
+ public void OnSecondaryEffect_SwitchTargetChosen_SwitchesOutUser()
+ {
+ // Arrange
+ var (script, move, user, toSwitch, side, _, _) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ side.Received(1).SwapPokemon((byte)1, toSwitch);
+ }
+
+ ///
+ /// Bulbapedia: "Baton Pass transfers all temporary stat stage increases and decreases".
+ /// Each stat stage (including evasion and accuracy) is copied to the Pokémon that is switched in.
+ ///
+ [Test, Arguments(Statistic.Attack, (sbyte)6), Arguments(Statistic.Defense, (sbyte)-6),
+ Arguments(Statistic.SpecialAttack, (sbyte)2), Arguments(Statistic.SpecialDefense, (sbyte)-1),
+ Arguments(Statistic.Speed, (sbyte)3), Arguments(Statistic.Evasion, (sbyte)1),
+ Arguments(Statistic.Accuracy, (sbyte)-2)]
+ public async Task OnSecondaryEffect_StatBoost_TransferredToSwitchIn(Statistic stat, sbyte boost)
+ {
+ // Arrange
+ var (script, move, user, toSwitch, _, _, _) = CreateTestSetup();
+ user.StatBoost.SetStatistic(stat, boost);
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ await Assert.That(toSwitch.StatBoost.GetStatistic(stat)).IsEqualTo(boost);
+ }
+
+ ///
+ /// Bulbapedia: "Baton Pass transfers all temporary stat stage increases and decreases".
+ /// After the stat stages are copied, the switched-in Pokémon's boosted stats are recalculated.
+ ///
+ [Test]
+ public void OnSecondaryEffect_StatBoostsTransferred_RecalculatesSwitchInStats()
+ {
+ // Arrange
+ var (script, move, user, toSwitch, _, _, _) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ toSwitch.Received(1).RecalculateBoostedStats();
+ }
+
+ ///
+ /// Bulbapedia: "Baton Pass switches out the user and passes several effects to the Pokémon switched in
+ /// its place."
+ /// A volatile script that is marked as transferable (, such as
+ /// ) is passed to the switched-in Pokémon.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TransferableVolatile_PassedToSwitchIn()
+ {
+ // Arrange
+ var (script, move, user, _, _, userVolatile, switchInVolatile) = CreateTestSetup();
+ userVolatile.Add(new AutotomizeEffect());
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ await Assert.That(switchInVolatile.Get()).IsNotNull();
+ }
+
+ ///
+ /// Bulbapedia: "Baton Pass also passes some volatile status conditions, namely confusion, getting pumped,
+ /// escape prevention, seeding, Curse, Substitute, Ingrain, Aqua Ring" — effects outside that list are not
+ /// passed. The consecutive-protection counter () is not transferable,
+ /// so it must not end up on the switched-in Pokémon.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_NonTransferableVolatile_NotPassedToSwitchIn()
+ {
+ // Arrange
+ var (script, move, user, _, _, userVolatile, switchInVolatile) = CreateTestSetup();
+ userVolatile.Add(new ProtectionFailureScript());
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ await Assert.That(switchInVolatile.Get()).IsNull();
+ }
+
+ ///
+ /// Bulbapedia: "Baton Pass switches out the user and passes several effects to the Pokémon switched in
+ /// its place."
+ /// A transferable volatile must be passed even when the user also has a non-transferable volatile that
+ /// was added before it.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TransferableVolatileAfterNonTransferable_StillPassedToSwitchIn()
+ {
+ // Arrange
+ var (script, move, user, _, _, userVolatile, switchInVolatile) = CreateTestSetup();
+ userVolatile.Add(new ProtectionFailureScript());
+ userVolatile.Add(new AutotomizeEffect());
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ await Assert.That(switchInVolatile.Get()).IsNotNull();
+ await Assert.That(switchInVolatile.Get()).IsNull();
+ }
+
+ ///
+ /// Bulbapedia: "Baton Pass switches out the user".
+ /// The user's volatile scripts leave the field with it: after the switch its volatile containers are empty.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_UserSwitchedOut_UserVolatilesCleared()
+ {
+ // Arrange
+ var (script, move, user, _, _, userVolatile, _) = CreateTestSetup();
+ userVolatile.Add(new AutotomizeEffect());
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ await Assert.That(userVolatile.Get()).IsNull();
+ }
+
+ ///
+ /// Technical test: without any there is no Pokémon to switch to,
+ /// so no switch happens.
+ ///
+ [Test]
+ public void OnSecondaryEffect_NoAdditionalData_DoesNotSwitch()
+ {
+ // Arrange
+ var (script, move, user, _, side, _, _) = CreateTestSetup();
+ move.MoveChoice.AdditionalData.Returns((Dictionary?)null);
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ side.DidNotReceive().SwapPokemon(Arg.Any(), Arg.Any());
+ }
+
+ ///
+ /// Technical test: if the stored switch target is not a Pokémon, no switch happens.
+ ///
+ [Test]
+ public void OnSecondaryEffect_SwitchTargetNotAPokemon_DoesNotSwitch()
+ {
+ // Arrange
+ var (script, move, user, _, side, _, _) = CreateTestSetup();
+ move.MoveChoice.AdditionalData.Returns(new Dictionary { { "to_switch", null } });
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ side.DidNotReceive().SwapPokemon(Arg.Any(), Arg.Any());
+ }
+
+ ///
+ /// Technical test: if the user has no , the script returns without
+ /// switching and without clearing the user's volatile scripts.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_NoBattleData_DoesNotSwitch()
+ {
+ // Arrange
+ var (script, move, user, _, side, userVolatile, _) = CreateTestSetup();
+ user.BattleData.Returns((IPokemonBattleData?)null);
+ userVolatile.Add(new AutotomizeEffect());
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ side.DidNotReceive().SwapPokemon(Arg.Any(), Arg.Any());
+ await Assert.That(userVolatile.Get()).IsNotNull();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeakBlastTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeakBlastTests.cs
new file mode 100644
index 0000000..df2b54d
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeakBlastTests.cs
@@ -0,0 +1,193 @@
+using PkmnLib.Dynamic.Events;
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.Models.Choices;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
+using PkmnLib.Static.Utils;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script and its attached .
+/// Behavior is verified against the Bulbapedia page for Beak Blast (Generation VII).
+///
+public class BeakBlastTests
+{
+ ///
+ /// Creates a fully mocked setup for driving .
+ ///
+ private static (BeakBlast script, ITurnChoice choice, IPokemon user, IScriptSet volatileSet, EventHook eventHook)
+ CreateChargeSetup(bool hasBattleData = true)
+ {
+ var script = new BeakBlast();
+ var user = Substitute.For();
+ user.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet volatileSet = new ScriptSet(user);
+ user.Volatile.Returns(volatileSet);
+
+ var eventHook = new EventHook();
+ if (hasBattleData)
+ {
+ var battle = Substitute.For();
+ battle.EventHook.Returns(eventHook);
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ user.BattleData.Returns(battleData);
+ }
+ else
+ {
+ user.BattleData.Returns((IPokemonBattleData?)null);
+ }
+
+ var choice = Substitute.For();
+ choice.User.Returns(user);
+
+ return (script, choice, user, volatileSet, eventHook);
+ }
+
+ ///
+ /// Bulbapedia: "The user charges up at the beginning of the turn and executes Beak Blast at -3 priority."
+ /// Before the turn starts, the charging is attached to the user.
+ ///
+ [Test]
+ public async Task OnBeforeTurnStart_UserInBattle_AddsChargeEffect()
+ {
+ // Arrange
+ var (script, choice, _, volatileSet, _) = CreateChargeSetup();
+
+ // Act
+ script.OnBeforeTurnStart(choice);
+
+ // Assert
+ await Assert.That(volatileSet.Get()).IsNotNull();
+ }
+
+ ///
+ /// Bulbapedia: "Its charging message is displayed before any other moves."
+ /// The charge fires a dialog event when the turn starts.
+ ///
+ [Test]
+ public async Task OnBeforeTurnStart_UserInBattle_FiresChargingMessage()
+ {
+ // Arrange
+ var (script, choice, user, _, eventHook) = CreateChargeSetup();
+ DialogEvent? capturedEvent = null;
+ eventHook.Handler += (_, args) =>
+ {
+ if (args is DialogEvent dialogEvent)
+ capturedEvent = dialogEvent;
+ };
+
+ // Act
+ script.OnBeforeTurnStart(choice);
+
+ // Assert
+ await Assert.That(capturedEvent).IsNotNull();
+ await Assert.That(capturedEvent!.Message).IsEqualTo("beak_blast_charge");
+ await Assert.That(capturedEvent.Parameters!["user"]).IsEqualTo(user);
+ }
+
+ ///
+ /// Technical test: a Pokémon without is not in battle, so no charging
+ /// phase starts.
+ ///
+ [Test]
+ public async Task OnBeforeTurnStart_NoBattleData_DoesNotAddChargeEffect()
+ {
+ // Arrange
+ var (script, choice, _, volatileSet, _) = CreateChargeSetup(false);
+
+ // Act
+ script.OnBeforeTurnStart(choice);
+
+ // Assert
+ await Assert.That(volatileSet.Get()).IsNull();
+ }
+
+ ///
+ /// Bulbapedia: "All who make contact with the user during the charging phase are burned."
+ /// The charging phase ends when the move executes, so the is removed again.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_MoveExecuted_RemovesChargeEffect()
+ {
+ // Arrange
+ var script = new BeakBlast();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var user = Substitute.For();
+ user.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet volatileSet = new ScriptSet(user);
+ user.Volatile.Returns(volatileSet);
+ move.User.Returns(user);
+ volatileSet.Add(new BeakBlastEffect());
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(volatileSet.Get()).IsNull();
+ }
+
+ ///
+ /// Creates a fully mocked setup for driving .
+ ///
+ private static (BeakBlastEffect effect, IExecutingMove move, IPokemon target, IPokemon attacker)
+ CreateIncomingHitSetup(bool isContact)
+ {
+ var effect = new BeakBlastEffect();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var hitData = Substitute.For();
+ hitData.IsContact.Returns(isContact);
+ move.GetHitData(target, 0).Returns(hitData);
+
+ var attacker = Substitute.For();
+ move.User.Returns(attacker);
+
+ return (effect, move, target, attacker);
+ }
+
+ ///
+ /// Helper to extract the status name from a Pokémon's received calls.
+ ///
+ private static string? GetStatusSet(IPokemon pokemon)
+ {
+ var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "SetStatus");
+ return call != null ? ((StringKey)call.GetArguments()[0]!).ToString() : null;
+ }
+
+ ///
+ /// Bulbapedia: "All who make contact with the user during the charging phase are burned."
+ ///
+ [Test]
+ public async Task OnIncomingHit_ContactMove_BurnsAttacker()
+ {
+ // Arrange
+ var (effect, move, target, attacker) = CreateIncomingHitSetup(true);
+
+ // Act
+ effect.OnIncomingHit(move, target, 0);
+
+ // Assert
+ await Assert.That(GetStatusSet(attacker)).IsEqualTo("burned");
+ }
+
+ ///
+ /// Bulbapedia: "All who make contact with the user during the charging phase are burned."
+ /// A hit that does not make contact must not burn the attacker.
+ ///
+ [Test]
+ public async Task OnIncomingHit_NonContactMove_DoesNotBurnAttacker()
+ {
+ // Arrange
+ var (effect, move, target, attacker) = CreateIncomingHitSetup(false);
+
+ // Act
+ effect.OnIncomingHit(move, target, 0);
+
+ // Assert
+ await Assert.That(attacker.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeatUpTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeatUpTests.cs
new file mode 100644
index 0000000..dfe28be
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BeatUpTests.cs
@@ -0,0 +1,229 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.Models.Choices;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Status;
+using PkmnLib.Static;
+using PkmnLib.Static.Species;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script.
+/// Gen VII Bulbapedia behavior: "Beat Up inflicts damage on the target from the user and from each conscious
+/// Pokémon in the user's party that does not have a non-volatile status." Since Generation V, "the base power
+/// per strike is no longer 10, but instead individually based on the Attack base stats of the party Pokémon:
+/// BasePower = BaseAttack(PartyMember)/10 + 5".
+///
+public class BeatUpTests
+{
+ ///
+ /// Creates a mocked party member with the given base Attack stat, usability, and optional non-volatile
+ /// status script in its .
+ ///
+ private static IPokemon CreatePartyMember(ushort baseAttack = 100, bool usable = true, Script? status = null)
+ {
+ var pokemon = Substitute.For();
+ pokemon.IsUsable.Returns(usable);
+ pokemon.StatusScript.Returns(status == null ? new ScriptContainer() : new ScriptContainer(status));
+ var form = Substitute.For();
+ form.BaseStats.Returns(new ImmutableStatisticSet(100, baseAttack, 100, 100, 100, 100));
+ pokemon.Form.Returns(form);
+ return pokemon;
+ }
+
+ ///
+ /// Creates a fully mocked test setup where the user and the given other Pokémon form a party in a battle.
+ ///
+ private static (BeatUp script, IMoveChoice choice, IExecutingMove move) CreateTestSetup(IPokemon user,
+ params IPokemon?[] otherPartyMembers)
+ {
+ var script = new BeatUp();
+
+ var members = new List { user };
+ members.AddRange(otherPartyMembers);
+
+ var party = Substitute.For();
+ party.GetEnumerator().Returns(_ => members.GetEnumerator());
+ var battleParty = Substitute.For();
+ battleParty.Party.Returns(party);
+
+ var battle = Substitute.For();
+ battle.Parties.Returns(new[] { battleParty });
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ user.BattleData.Returns(battleData);
+
+ var choice = Substitute.For();
+ choice.User.Returns(user);
+ var move = Substitute.For();
+ move.User.Returns(user);
+
+ return (script, choice, move);
+ }
+
+ ///
+ /// Bulbapedia: "Beat Up inflicts damage on the target from the user and from each conscious Pokémon in the
+ /// user's party that does not have a non-volatile status."
+ /// With the user and two healthy allies, the move strikes three times.
+ ///
+ [Test]
+ public async Task ChangeNumberOfHits_AllPartyMembersEligible_OneHitPerMember()
+ {
+ // Arrange
+ var user = CreatePartyMember();
+ var (script, choice, _) = CreateTestSetup(user, CreatePartyMember(), CreatePartyMember());
+ byte numberOfHits = 1;
+
+ // Act
+ script.ChangeNumberOfHits(choice, ref numberOfHits);
+
+ // Assert
+ await Assert.That(numberOfHits).IsEqualTo((byte)3);
+ }
+
+ ///
+ /// Bulbapedia: "Beat Up inflicts damage on the target from the user and from each conscious Pokémon in the
+ /// user's party".
+ /// A fainted (not conscious) party member does not contribute a strike.
+ ///
+ [Test]
+ public async Task ChangeNumberOfHits_FaintedPartyMember_Excluded()
+ {
+ // Arrange
+ var user = CreatePartyMember();
+ var (script, choice, _) = CreateTestSetup(user, CreatePartyMember(usable: false), CreatePartyMember());
+ byte numberOfHits = 1;
+
+ // Act
+ script.ChangeNumberOfHits(choice, ref numberOfHits);
+
+ // Assert
+ await Assert.That(numberOfHits).IsEqualTo((byte)2);
+ }
+
+ ///
+ /// Bulbapedia: "each conscious Pokémon in the user's party that does not have a non-volatile status".
+ /// A party member with a non-volatile status (here ) does not contribute a strike.
+ ///
+ [Test]
+ public async Task ChangeNumberOfHits_StatusedPartyMember_Excluded()
+ {
+ // Arrange
+ var user = CreatePartyMember();
+ var (script, choice, _) = CreateTestSetup(user, CreatePartyMember(status: new Burned()));
+ byte numberOfHits = 1;
+
+ // Act
+ script.ChangeNumberOfHits(choice, ref numberOfHits);
+
+ // Assert
+ await Assert.That(numberOfHits).IsEqualTo((byte)1);
+ }
+
+ ///
+ /// Technical test: outside of battle (no ) there are no relevant party
+ /// members, and the number of hits falls back to a single strike.
+ ///
+ [Test]
+ public async Task ChangeNumberOfHits_NoBattleData_SingleHit()
+ {
+ // Arrange
+ var script = new BeatUp();
+ var user = Substitute.For();
+ user.BattleData.Returns((IPokemonBattleData?)null);
+ var choice = Substitute.For();
+ choice.User.Returns(user);
+ byte numberOfHits = 3;
+
+ // Act
+ script.ChangeNumberOfHits(choice, ref numberOfHits);
+
+ // Assert
+ await Assert.That(numberOfHits).IsEqualTo((byte)1);
+ }
+
+ ///
+ /// Bulbapedia: "the base power per strike is no longer 10, but instead individually based on the Attack
+ /// base stats of the party Pokémon: BasePower = BaseAttack(PartyMember)/10 + 5".
+ /// Verifies the formula, including integer truncation of the division.
+ ///
+ [Test, Arguments((ushort)10, (ushort)6), Arguments((ushort)55, (ushort)10), Arguments((ushort)59, (ushort)10),
+ Arguments((ushort)100, (ushort)15), Arguments((ushort)255, (ushort)30)]
+ public async Task ChangeBasePower_UsesBaseAttackFormula(ushort baseAttack, ushort expectedBasePower)
+ {
+ // Arrange
+ var user = CreatePartyMember(baseAttack);
+ var (script, _, move) = CreateTestSetup(user);
+ var target = Substitute.For();
+ ushort basePower = 10;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo(expectedBasePower);
+ }
+
+ ///
+ /// Bulbapedia: "the base power per strike is [...] individually based on the Attack base stats of the party
+ /// Pokémon". The second strike uses the second eligible party member's base Attack, not the user's.
+ ///
+ [Test]
+ public async Task ChangeBasePower_SecondHit_UsesSecondPartyMembersBaseAttack()
+ {
+ // Arrange
+ var user = CreatePartyMember(100);
+ var (script, _, move) = CreateTestSetup(user, CreatePartyMember(250));
+ var target = Substitute.For();
+ ushort basePower = 10;
+
+ // Act
+ script.ChangeBasePower(move, target, 1, ref basePower);
+
+ // Assert - 250 / 10 + 5 = 30
+ await Assert.That(basePower).IsEqualTo((ushort)30);
+ }
+
+ ///
+ /// Bulbapedia: strikes only come from party members "that [do] not have a non-volatile status".
+ /// An ineligible member is skipped entirely, so the strike after the user's uses the next eligible
+ /// member's base Attack.
+ ///
+ [Test]
+ public async Task ChangeBasePower_StatusedMemberSkipped_UsesNextEligibleMember()
+ {
+ // Arrange
+ var user = CreatePartyMember(100);
+ var (script, _, move) = CreateTestSetup(user, CreatePartyMember(250, status: new Burned()),
+ CreatePartyMember(60));
+ var target = Substitute.For();
+ ushort basePower = 10;
+
+ // Act
+ script.ChangeBasePower(move, target, 1, ref basePower);
+
+ // Assert - the burned member is skipped: 60 / 10 + 5 = 11
+ await Assert.That(basePower).IsEqualTo((ushort)11);
+ }
+
+ ///
+ /// Technical test: a hit index beyond the number of eligible party members leaves the base power unchanged
+ /// instead of throwing.
+ ///
+ [Test]
+ public async Task ChangeBasePower_HitIndexBeyondEligibleMembers_BasePowerUnchanged()
+ {
+ // Arrange
+ var user = CreatePartyMember(100);
+ var (script, _, move) = CreateTestSetup(user);
+ var target = Substitute.For();
+ ushort basePower = 10;
+
+ // Act
+ script.ChangeBasePower(move, target, 3, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)10);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BelchTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BelchTests.cs
new file mode 100644
index 0000000..564ab0f
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BelchTests.cs
@@ -0,0 +1,133 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.Models.Choices;
+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: "Belch deals damage. It cannot be selected unless the user has previously eaten
+/// a Berry in the current battle."
+///
+public class BelchTests
+{
+ ///
+ /// Creates a fully mocked test setup where the user's
+ /// contains one item per given .
+ ///
+ private static (Belch script, IMoveChoice choice) CreateTestSetup(params ItemCategory[] consumedItemCategories)
+ {
+ var script = new Belch();
+ var user = Substitute.For();
+
+ var items = consumedItemCategories.Select(category =>
+ {
+ var item = Substitute.For();
+ item.Category.Returns(category);
+ return item;
+ }).ToArray();
+ var battleData = Substitute.For();
+ battleData.ConsumedItems.Returns(items);
+ user.BattleData.Returns(battleData);
+
+ var choice = Substitute.For();
+ choice.User.Returns(user);
+
+ return (script, choice);
+ }
+
+ ///
+ /// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle."
+ /// A user that has eaten a Berry may select Belch.
+ ///
+ [Test]
+ public async Task PreventMoveSelection_UserAteBerry_SelectionAllowed()
+ {
+ // Arrange
+ var (script, choice) = CreateTestSetup(ItemCategory.Berry);
+ var prevent = false;
+
+ // Act
+ script.PreventMoveSelection(choice, ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle."
+ /// A user that has not consumed anything cannot select Belch.
+ ///
+ [Test]
+ public async Task PreventMoveSelection_NoItemsConsumed_SelectionPrevented()
+ {
+ // Arrange
+ var (script, choice) = CreateTestSetup();
+ var prevent = false;
+
+ // Act
+ script.PreventMoveSelection(choice, ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle."
+ /// Having consumed only non-Berry items does not satisfy the requirement.
+ ///
+ [Test, Arguments(ItemCategory.MiscItem), Arguments(ItemCategory.Medicine)]
+ public async Task PreventMoveSelection_OnlyNonBerryItemConsumed_SelectionPrevented(ItemCategory category)
+ {
+ // Arrange
+ var (script, choice) = CreateTestSetup(category);
+ var prevent = false;
+
+ // Act
+ script.PreventMoveSelection(choice, ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "It cannot be selected unless the user has previously eaten a Berry in the current battle."
+ /// The Berry does not have to be the only consumed item: a Berry among other consumed items is enough.
+ ///
+ [Test]
+ public async Task PreventMoveSelection_BerryAmongOtherConsumedItems_SelectionAllowed()
+ {
+ // Arrange
+ var (script, choice) = CreateTestSetup(ItemCategory.MiscItem, ItemCategory.Berry);
+ var prevent = false;
+
+ // Act
+ script.PreventMoveSelection(choice, ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsFalse();
+ }
+
+ ///
+ /// Technical test: outside of battle (no ) the script does not prevent
+ /// selection.
+ ///
+ [Test]
+ public async Task PreventMoveSelection_NoBattleData_SelectionAllowed()
+ {
+ // Arrange
+ var script = new Belch();
+ var user = Substitute.For();
+ user.BattleData.Returns((IPokemonBattleData?)null);
+ var choice = Substitute.For();
+ choice.User.Returns(user);
+ var prevent = false;
+
+ // Act
+ script.PreventMoveSelection(choice, ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsFalse();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BellyDrumTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BellyDrumTests.cs
new file mode 100644
index 0000000..e5b66fa
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BellyDrumTests.cs
@@ -0,0 +1,203 @@
+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: "Belly Drum deducts half of the user's maximum HP (rounded down) from its
+/// current HP and, in return, it maximizes the user's Attack stat by raising it to +6 stages".
+///
+public class BellyDrumTests
+{
+ ///
+ /// Creates a fully mocked test setup for Belly Drum. The target of the secondary effect is the user itself.
+ ///
+ private static (BellyDrum script, IExecutingMove move, IPokemon user, IHitData hitData) CreateTestSetup(uint maxHp,
+ uint currentHp, sbyte attackBoost = 0)
+ {
+ var script = new BellyDrum();
+ var user = Substitute.For();
+ user.BoostedStats.Returns(new StatisticSet(maxHp, 10, 10, 10, 10, 10));
+ user.CurrentHealth.Returns(currentHp);
+ user.StatBoost.Returns(new StatBoostStatisticSet(0, attackBoost, 0, 0, 0, 0));
+
+ var hitData = Substitute.For();
+ var move = Substitute.For();
+ move.GetHitData(user, 0).Returns(hitData);
+ move.User.Returns(user);
+
+ return (script, move, user, hitData);
+ }
+
+ ///
+ /// Helper to extract the received Damage call from the user, if any.
+ ///
+ private static (uint damage, DamageSource source, bool forceDamage)? GetDamageCall(IPokemon user)
+ {
+ var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
+ if (call == null)
+ return null;
+ var args = call.GetArguments();
+ return ((uint)args[0]!, (DamageSource)args[1]!, (bool)args[3]!);
+ }
+
+ ///
+ /// Helper to extract the received ChangeStatBoost call from the user, if any.
+ ///
+ private static (Statistic stat, sbyte change, bool selfInflicted)? GetStatBoostCall(IPokemon user)
+ {
+ var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
+ if (call == null)
+ return null;
+ var args = call.GetArguments();
+ return ((Statistic)args[0]!, (sbyte)args[1]!, (bool)args[2]!);
+ }
+
+ ///
+ /// Bulbapedia: "Belly Drum deducts half of the user's maximum HP (rounded down) from its current HP".
+ /// Verifies the deduction amount, including the rounding down for odd maximum HP values. The deduction is
+ /// a cost rather than regular damage, so it is forced damage.
+ ///
+ [Test, Arguments(100u, 50u), Arguments(101u, 50u), Arguments(99u, 49u), Arguments(255u, 127u)]
+ public async Task OnSecondaryEffect_SufficientHp_DeductsHalfMaxHpRoundedDown(uint maxHp, uint expectedDeduction)
+ {
+ // Arrange
+ var (script, move, user, _) = CreateTestSetup(maxHp, maxHp);
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ await Assert.That(GetDamageCall(user)).IsEqualTo((expectedDeduction, DamageSource.Misc, true));
+ }
+
+ ///
+ /// Bulbapedia: "it maximizes the user's Attack stat by raising it to +6 stages".
+ /// The user receives a self-inflicted Attack boost large enough to reach the +6 cap.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_SufficientHp_MaximizesAttack()
+ {
+ // Arrange
+ var (script, move, user, _) = CreateTestSetup(100, 100);
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ var boostCall = GetStatBoostCall(user);
+ await Assert.That(boostCall).IsNotNull();
+ await Assert.That(boostCall!.Value.stat).IsEqualTo(Statistic.Attack);
+ await Assert.That(boostCall.Value.change).IsGreaterThanOrEqualTo((sbyte)6);
+ await Assert.That(boostCall.Value.selfInflicted).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "it maximizes the user's Attack stat by raising it to +6 stages, even if the user's
+ /// temporary Attack bonus stages were below 0 prior to using Belly Drum."
+ /// From -6 stages only a raise of at least 12 stages reaches +6.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_AttackStagesBelowZero_StillRaisedToPlusSix()
+ {
+ // Arrange
+ var (script, move, user, _) = CreateTestSetup(100, 100, -6);
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ var boostCall = GetStatBoostCall(user);
+ await Assert.That(boostCall).IsNotNull();
+ await Assert.That(boostCall!.Value.change).IsGreaterThanOrEqualTo((sbyte)12);
+ }
+
+ ///
+ /// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum".
+ ///
+ [Test]
+ public void OnSecondaryEffect_CurrentHpBelowHalf_Fails()
+ {
+ // Arrange
+ var (script, move, user, hitData) = CreateTestSetup(100, 49);
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ }
+
+ ///
+ /// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum".
+ /// When the move fails, no HP is deducted and the Attack stat is not changed.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_CurrentHpBelowHalf_NoHpDeductedAndNoAttackChange()
+ {
+ // Arrange
+ var (script, move, user, _) = CreateTestSetup(100, 49);
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ await Assert.That(GetDamageCall(user)).IsNull();
+ await Assert.That(GetStatBoostCall(user)).IsNull();
+ }
+
+ ///
+ /// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum".
+ /// At exactly half its maximum HP the user cannot pay the cost without fainting (deducting half of the
+ /// maximum HP would reduce it to 0 HP), so the move fails as well.
+ ///
+ [Test]
+ public void OnSecondaryEffect_CurrentHpExactlyHalf_Fails()
+ {
+ // Arrange
+ var (script, move, user, hitData) = CreateTestSetup(100, 50);
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ }
+
+ ///
+ /// Bulbapedia: "Belly Drum fails if the user's current HP is less than half its maximum, or if the user's
+ /// Attack is already at +6 (even if the user has Contrary)."
+ ///
+ [Test]
+ public void OnSecondaryEffect_AttackAlreadyAtPlusSix_Fails()
+ {
+ // Arrange
+ var (script, move, user, hitData) = CreateTestSetup(100, 100, 6);
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ }
+
+ ///
+ /// Bulbapedia: "Belly Drum fails if [...] the user's Attack is already at +6".
+ /// A failed Belly Drum does not deduct HP: the HP cost only applies when the move works (the only
+ /// exception Bulbapedia lists is a Contrary user already at -6 stages).
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_AttackAlreadyAtPlusSix_NoHpDeducted()
+ {
+ // Arrange
+ var (script, move, user, _) = CreateTestSetup(100, 100, 6);
+
+ // Act
+ script.OnSecondaryEffect(move, user, 0);
+
+ // Assert
+ await Assert.That(GetDamageCall(user)).IsNull();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BestowTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BestowTests.cs
new file mode 100644
index 0000000..1c8b3bb
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BestowTests.cs
@@ -0,0 +1,175 @@
+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: "The user transfers its held item to the target. It fails if the user has no
+/// held item, the target already has a held item, or the target is behind a substitute. [...] Items given away
+/// in Trainer battles return to the original Pokémon after the battle."
+///
+public class BestowTests
+{
+ private static (Bestow script, IExecutingMove move, IPokemon user, IPokemon target, IHitData hitData)
+ CreateTestSetup(IItem? userItem, IItem? targetItem)
+ {
+ var script = new Bestow();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var hitData = Substitute.For();
+ move.GetHitData(target, 0).Returns(hitData);
+ target.HeldItem.Returns(targetItem);
+
+ var user = Substitute.For();
+ user.HeldItem.Returns(userItem);
+ user.RemoveHeldItemForBattle().Returns(userItem);
+ move.User.Returns(user);
+
+ return (script, move, user, target, hitData);
+ }
+
+ ///
+ /// Bulbapedia: "The user transfers its held item to the target."
+ /// The target ends up holding the user's item.
+ ///
+ [Test]
+ public void OnSecondaryEffect_TargetHasNoItem_TargetReceivesUsersItem()
+ {
+ // Arrange
+ var userItem = Substitute.For();
+ var (script, move, _, target, _) = CreateTestSetup(userItem, null);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ target.Received(1).ForceSetHeldItem(userItem);
+ }
+
+ ///
+ /// Bulbapedia: "The user transfers its held item to the target."
+ /// A successful transfer does not fail the move.
+ ///
+ [Test]
+ public void OnSecondaryEffect_TargetHasNoItem_MoveDoesNotFail()
+ {
+ // Arrange
+ var userItem = Substitute.For();
+ var (script, move, _, target, hitData) = CreateTestSetup(userItem, null);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.DidNotReceive().Fail();
+ }
+
+ ///
+ /// Bulbapedia: "Items given away in Trainer battles return to the original Pokémon after the battle."
+ /// The item is taken from the user through , which only
+ /// removes the item for the duration of the battle.
+ ///
+ [Test]
+ public void OnSecondaryEffect_TargetHasNoItem_UserItemIsRemovedForBattleOnly()
+ {
+ // Arrange
+ var userItem = Substitute.For();
+ var (script, move, user, target, _) = CreateTestSetup(userItem, null);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ user.Received(1).RemoveHeldItemForBattle();
+ }
+
+ ///
+ /// Bulbapedia: "It fails if the user has no held item".
+ ///
+ [Test]
+ public void OnSecondaryEffect_UserHasNoHeldItem_MoveFails()
+ {
+ // Arrange
+ var (script, move, _, target, hitData) = CreateTestSetup(null, null);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ }
+
+ ///
+ /// Bulbapedia: "It fails if the user has no held item".
+ /// When the move fails, no item is given to the target.
+ ///
+ [Test]
+ public void OnSecondaryEffect_UserHasNoHeldItem_TargetDoesNotReceiveAnItem()
+ {
+ // Arrange
+ var (script, move, _, target, _) = CreateTestSetup(null, null);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ target.DidNotReceive().ForceSetHeldItem(Arg.Any());
+ }
+
+ ///
+ /// Bulbapedia: "It fails if [...] the target already has a held item".
+ ///
+ [Test]
+ public void OnSecondaryEffect_TargetAlreadyHasHeldItem_MoveFails()
+ {
+ // Arrange
+ var (script, move, _, target, hitData) = CreateTestSetup(Substitute.For(), Substitute.For());
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ }
+
+ ///
+ /// Bulbapedia: "It fails if [...] the target already has a held item".
+ /// The target's own held item is left untouched by the failed move.
+ ///
+ [Test]
+ public void OnSecondaryEffect_TargetAlreadyHasHeldItem_TargetItemIsNotReplaced()
+ {
+ // Arrange
+ var (script, move, _, target, _) = CreateTestSetup(Substitute.For(), Substitute.For());
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ target.DidNotReceive().ForceSetHeldItem(Arg.Any());
+ }
+
+ ///
+ /// Bulbapedia: "It fails if [...] the target already has a held item".
+ /// A failed Bestow must not cost the user its held item: the item is either never taken from the user, or
+ /// it is given back after the failure check.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetAlreadyHasHeldItem_UserKeepsItsHeldItem()
+ {
+ // Arrange
+ var userItem = Substitute.For();
+ var (script, move, user, target, _) = CreateTestSetup(userItem, Substitute.For());
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert - the user still holds its item: it was either never removed, or restored after the failure.
+ var removed = user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "RemoveHeldItemForBattle");
+ var restored = user.ReceivedCalls().Any(c =>
+ c.GetMethodInfo().Name == "ForceSetHeldItem" && ReferenceEquals(c.GetArguments()[0], userItem));
+ await Assert.That(!removed || restored).IsTrue();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BideTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BideTests.cs
new file mode 100644
index 0000000..9170a2a
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BideTests.cs
@@ -0,0 +1,280 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script and its volatile.
+/// Gen VII Bulbapedia behavior (base plus Gen III-VII deltas): the user idles while storing the damage it
+/// receives, then "Bide will do damage equal to twice the damage received during the idling period", enduring
+/// "attacks for two turns" and hitting "the last Pokémon to attack the user, even if this is an ally".
+///
+public class BideTests
+{
+ private static (Bide script, IExecutingMove move, IPokemon user, IPokemon target, IScriptSet userVolatile, IHitData
+ hitData) CreateTestSetup()
+ {
+ var script = new Bide();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var hitData = Substitute.For();
+ move.GetHitData(target, 0).Returns(hitData);
+
+ var user = Substitute.For();
+ user.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ // A real script set so the volatile Bide effect can actually be added, retrieved and removed.
+ var userVolatile = new ScriptSet(user);
+ user.Volatile.Returns(userVolatile);
+ move.User.Returns(user);
+
+ return (script, move, user, target, userVolatile, hitData);
+ }
+
+ private static IPokemon CreateAttacker(bool onBattlefield = true)
+ {
+ var attacker = Substitute.For();
+ var battleData = Substitute.For();
+ battleData.IsOnBattlefield.Returns(onBattlefield);
+ attacker.BattleData.Returns(battleData);
+ return attacker;
+ }
+
+ ///
+ /// Adds a to the user's volatile scripts, as if Bide has already been storing
+ /// energy for the given number of executed turns.
+ ///
+ private static BideEffect AddStoredBideEffect(IScriptSet userVolatile, IPokemon user, byte turns, uint damageTaken,
+ params IPokemon[] hitBy)
+ {
+ var effect = new BideEffect(user)
+ {
+ Turns = turns,
+ DamageTaken = damageTaken,
+ };
+ effect.HitBy.AddRange(hitBy);
+ userVolatile.Add(effect);
+ return effect;
+ }
+
+ ///
+ /// Helper to check whether a Pokémon received any Damage call.
+ ///
+ private static bool ReceivedDamage(IPokemon pokemon) =>
+ pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage");
+
+ ///
+ /// Helper to extract the damage amount from a Pokémon'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;
+ }
+
+ ///
+ /// Helper to extract the damage source from a Pokémon's received Damage calls.
+ ///
+ private static DamageSource? GetDamageSource(IPokemon pokemon)
+ {
+ var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
+ return call != null ? (DamageSource)call.GetArguments()[1]! : null;
+ }
+
+ ///
+ /// Bulbapedia: "After Bide is selected, the user will be unable to select a move for an idling period".
+ /// The first use adds the volatile to the user, which forces the Bide choice on
+ /// the following turns and records incoming damage.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_FirstUse_AddsBideEffectToUserVolatile()
+ {
+ // Arrange
+ var (script, move, _, target, userVolatile, _) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(userVolatile.Get()).IsNotNull();
+ }
+
+ ///
+ /// Bulbapedia: "Bide will now only endure attacks for two turns."
+ /// The turn Bide is selected begins the storing period, which spans two more turns; the stored damage is
+ /// only released on the third execution, so on the second execution nothing is released yet.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_SecondExecution_DoesNotReleaseStoredDamage()
+ {
+ // Arrange
+ var (script, move, user, target, userVolatile, _) = CreateTestSetup();
+ var attacker = CreateAttacker();
+ _ = AddStoredBideEffect(userVolatile, user, 1, 50, attacker);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert - still storing, so the attacker has not been damaged
+ await Assert.That(ReceivedDamage(attacker)).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "Bide will do damage equal to twice the damage received during the idling period."
+ ///
+ [Test, Arguments(50u, 100u), Arguments(1u, 2u), Arguments(123u, 246u)]
+ public async Task OnSecondaryEffect_Release_DealsTwiceTheStoredDamage(uint damageTaken, uint expectedDamage)
+ {
+ // Arrange
+ var (script, move, user, target, userVolatile, _) = CreateTestSetup();
+ var attacker = CreateAttacker();
+ _ = AddStoredBideEffect(userVolatile, user, 2, damageTaken, attacker);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(GetDamageAmount(attacker)).IsEqualTo(expectedDamage);
+ }
+
+ ///
+ /// Bulbapedia: "Bide will do damage equal to twice the damage received during the idling period."
+ /// The released damage is dealt as move damage, not as indirect damage.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_Release_DealsMoveDamage()
+ {
+ // Arrange
+ var (script, move, user, target, userVolatile, _) = CreateTestSetup();
+ var attacker = CreateAttacker();
+ _ = AddStoredBideEffect(userVolatile, user, 2, 50, attacker);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(GetDamageSource(attacker)).IsEqualTo(DamageSource.MoveDamage);
+ }
+
+ ///
+ /// Bulbapedia: "Bide hits the last Pokémon to attack the user, even if this is an ally."
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_Release_HitsLastPokemonThatAttackedUser()
+ {
+ // Arrange
+ var (script, move, user, target, userVolatile, _) = CreateTestSetup();
+ var firstAttacker = CreateAttacker();
+ var lastAttacker = CreateAttacker();
+ _ = AddStoredBideEffect(userVolatile, user, 2, 50, firstAttacker, lastAttacker);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert - only the last attacker is hit
+ await Assert.That(ReceivedDamage(lastAttacker)).IsTrue();
+ await Assert.That(ReceivedDamage(firstAttacker)).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "this attack will hit the last Pokémon to attack the user".
+ /// A Pokémon that has since left the battlefield cannot be hit by the released damage.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_Release_AttackerNoLongerOnBattlefield_IsNotDamaged()
+ {
+ // Arrange
+ var (script, move, user, target, userVolatile, _) = CreateTestSetup();
+ var departedAttacker = CreateAttacker(false);
+ _ = AddStoredBideEffect(userVolatile, user, 2, 50, departedAttacker);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(ReceivedDamage(departedAttacker)).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "If the user is not directly attacked during the biding period, it will fail on the turn it
+ /// would have released."
+ ///
+ [Test]
+ public void OnSecondaryEffect_ReleaseWithoutBeingAttacked_MoveFails()
+ {
+ // Arrange
+ var (script, move, user, target, userVolatile, hitData) = CreateTestSetup();
+ _ = AddStoredBideEffect(userVolatile, user, 2, 0);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ }
+
+ ///
+ /// Bulbapedia: "Once a Pokémon has started biding, it cannot choose to switch out until the attack is
+ /// complete."
+ /// Once the stored damage is released the attack is complete, so the volatile must
+ /// be removed from the user; otherwise the user would remain locked into Bide indefinitely through
+ /// .
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_Release_RemovesBideEffectFromUser()
+ {
+ // Arrange
+ var (script, move, user, target, userVolatile, _) = CreateTestSetup();
+ var attacker = CreateAttacker();
+ _ = AddStoredBideEffect(userVolatile, user, 2, 50, attacker);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(userVolatile.Get()).IsNull();
+ }
+
+ ///
+ /// Bulbapedia: "Bide will do damage equal to twice the damage received during the idling period."
+ /// The volatile accumulates every chunk of damage the user takes while storing
+ /// energy.
+ ///
+ [Test]
+ public async Task BideEffect_OnDamage_AccumulatesDamageTaken()
+ {
+ // Arrange
+ var user = Substitute.For();
+ var effect = new BideEffect(user);
+
+ // Act - the user drops from 100 to 60 HP, then from 60 to 50 HP
+ effect.OnDamage(user, DamageSource.MoveDamage, 100, 60);
+ effect.OnDamage(user, DamageSource.MoveDamage, 60, 50);
+
+ // Assert
+ await Assert.That(effect.DamageTaken).IsEqualTo(50u);
+ }
+
+ ///
+ /// Bulbapedia: "Bide hits the last Pokémon to attack the user, even if this is an ally."
+ /// The volatile records every Pokémon that hits the user, in order, so the last
+ /// attacker can be targeted on release.
+ ///
+ [Test]
+ public async Task BideEffect_OnIncomingHit_RecordsAttacker()
+ {
+ // Arrange
+ var user = Substitute.For();
+ var effect = new BideEffect(user);
+ var incomingMove = Substitute.For();
+ var attacker = Substitute.For();
+ incomingMove.User.Returns(attacker);
+
+ // Act
+ effect.OnIncomingHit(incomingMove, user, 0);
+
+ // Assert
+ await Assert.That(effect.HitBy.LastOrDefault()).IsEqualTo(attacker);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BindTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BindTests.cs
new file mode 100644
index 0000000..a6089fb
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BindTests.cs
@@ -0,0 +1,257 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.Models.Choices;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Dynamic.ScriptHandling.Registry;
+using PkmnLib.Plugin.Gen7.Scripts;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
+using PkmnLib.Static.Utils;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script and its volatile.
+/// Gen VII Bulbapedia behavior (Gen II base plus Gen V/VI deltas): "It also traps the target, preventing
+/// switching and escape", "Its effect will last either 4 or 5 turns", and "The end turn damage of Bind is
+/// increased from 1/16 to 1/8 of the target's maximum HP."
+///
+public class BindTests
+{
+ ///
+ /// Test helper script that modifies the Bind duration/damage through the
+ /// custom trigger, the same way the Grip Claw and Binding Band item scripts do.
+ ///
+ [Script(ScriptCategory.Pokemon, "test_modify_bind_trigger")]
+ private class ModifyBindTrigger : Script, IScriptCustomTrigger
+ {
+ private readonly int? _duration;
+ private readonly float? _damagePercent;
+
+ public ModifyBindTrigger(int? duration = null, float? damagePercent = null)
+ {
+ _duration = duration;
+ _damagePercent = damagePercent;
+ }
+
+ public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args)
+ {
+ if (eventName != CustomTriggers.ModifyBind || args is not CustomTriggers.ModifyBindArgs bindArgs)
+ return;
+ if (_duration.HasValue)
+ bindArgs.Duration = _duration.Value;
+ if (_damagePercent.HasValue)
+ bindArgs.DamagePercent = _damagePercent.Value;
+ }
+ }
+
+ private static (Bind script, IExecutingMove move, IPokemon user, IPokemon target, IScriptSet targetVolatile)
+ CreateTestSetup(params Script[] userScripts)
+ {
+ var script = new Bind();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var targetVolatile = Substitute.For();
+ target.Volatile.Returns(targetVolatile);
+
+ var user = Substitute.For();
+ // RunScriptHook iterates the user's scripts; give the mock a real iterator so the ModifyBind trigger
+ // pass runs (empty unless the test attaches scripts such as the Grip Claw / Binding Band stand-in).
+ var containers = userScripts.Select(IEnumerable (s) => new ScriptContainer(s)).ToArray();
+ user.GetScripts().Returns(_ => new ScriptIterator(containers));
+ move.User.Returns(user);
+
+ return (script, move, user, target, targetVolatile);
+ }
+
+ ///
+ /// Helper to extract the instance that was added to the target's volatile scripts.
+ ///
+ private static BindEffect? GetAddedEffect(IScriptSet targetVolatile) => targetVolatile.ReceivedCalls()
+ .Where(c => c.GetMethodInfo().Name == "Add").Select(c => c.GetArguments()[0]).OfType()
+ .FirstOrDefault();
+
+ ///
+ /// Helper to extract the damage amount from the target's first received Damage call.
+ ///
+ private static uint? GetDamageAmount(IPokemon pokemon)
+ {
+ var call = pokemon.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
+ return call != null ? (uint)call.GetArguments()[0]! : null;
+ }
+
+ ///
+ /// Runs end-of-turn handling on the effect repeatedly and counts how many turns dealt damage to the target.
+ ///
+ private static int CountEndTurnDamageTicks(BindEffect effect, IPokemon target, int maxTurns = 10)
+ {
+ var battle = Substitute.For();
+ for (var i = 0; i < maxTurns; i++)
+ effect.OnEndTurn(target, battle);
+ return target.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "Damage");
+ }
+
+ ///
+ /// Bulbapedia: "It also traps the target, preventing switching and escape."
+ /// Using Bind adds the volatile to the target.
+ ///
+ [Test]
+ public void OnSecondaryEffect_AddsBindEffectToTargetVolatile()
+ {
+ // Arrange
+ var (script, move, _, target, targetVolatile) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ targetVolatile.Received(1).Add(Arg.Any());
+ }
+
+ ///
+ /// Bulbapedia: "The end turn damage of Bind is increased from 1/16 to 1/8 of the target's maximum HP."
+ /// Tests several max HP values to ensure proper integer truncation of the 1/8 fraction.
+ ///
+ [Test, Arguments(160u, 20u), Arguments(100u, 12u), Arguments(15u, 1u)]
+ public async Task OnSecondaryEffect_Default_EndTurnDamageIsOneEighthOfTargetMaxHp(uint maxHealth,
+ uint expectedDamage)
+ {
+ // Arrange
+ var (script, move, _, target, targetVolatile) = CreateTestSetup();
+ target.MaxHealth.Returns(maxHealth);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+ var effect = GetAddedEffect(targetVolatile);
+ await Assert.That(effect).IsNotNull();
+ effect!.OnEndTurn(target, Substitute.For());
+
+ // Assert
+ await Assert.That(GetDamageAmount(target)).IsEqualTo(expectedDamage);
+ }
+
+ ///
+ /// Bulbapedia: "Its effect will last either 4 or 5 turns."
+ /// The added effect deals its end-of-turn damage for 4 or 5 turns and then ends.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_Default_EffectLastsFourOrFiveTurns()
+ {
+ // Arrange
+ var (script, move, _, target, targetVolatile) = CreateTestSetup();
+ target.MaxHealth.Returns(160u);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+ var effect = GetAddedEffect(targetVolatile);
+ await Assert.That(effect).IsNotNull();
+ var turnsWithDamage = CountEndTurnDamageTicks(effect!, target);
+
+ // Assert
+ await Assert.That(turnsWithDamage is 4 or 5).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "If the user of Bind is holding a Grip Claw, the duration will be 7 turns."
+ /// The duration change flows through the custom trigger; a script
+ /// on the user that sets the duration to 7 results in an effect lasting 7 turns.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_DurationSetToSevenByTrigger_EffectLastsSevenTurns()
+ {
+ // Arrange
+ var (script, move, _, target, targetVolatile) = CreateTestSetup(new ModifyBindTrigger(7));
+ target.MaxHealth.Returns(160u);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+ var effect = GetAddedEffect(targetVolatile);
+ await Assert.That(effect).IsNotNull();
+ var turnsWithDamage = CountEndTurnDamageTicks(effect!, target);
+
+ // Assert
+ await Assert.That(turnsWithDamage).IsEqualTo(7);
+ }
+
+ ///
+ /// Bulbapedia: "If the user is holding a Binding Band, the end turn damage of Bind will increase to 1/6 of
+ /// the target's maximum HP."
+ /// The damage change flows through the custom trigger; a script on
+ /// the user that sets the damage to 1/6 results in end-of-turn damage of 1/6 max HP.
+ ///
+ [Test, Arguments(60u, 10u), Arguments(48u, 8u)]
+ public async Task OnSecondaryEffect_DamageSetToOneSixthByTrigger_EndTurnDamageIsOneSixth(uint maxHealth,
+ uint expectedDamage)
+ {
+ // Arrange
+ var (script, move, _, target, targetVolatile) = CreateTestSetup(new ModifyBindTrigger(damagePercent: 1f / 6f));
+ target.MaxHealth.Returns(maxHealth);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+ var effect = GetAddedEffect(targetVolatile);
+ await Assert.That(effect).IsNotNull();
+ effect!.OnEndTurn(target, Substitute.For());
+
+ // Assert
+ await Assert.That(GetDamageAmount(target)).IsEqualTo(expectedDamage);
+ }
+
+ ///
+ /// Bulbapedia: "It also traps the target, preventing switching and escape."
+ /// While the effect is active, the target cannot switch out.
+ ///
+ [Test]
+ public async Task BindEffect_WhileActive_PreventsSwitching()
+ {
+ // Arrange
+ var target = Substitute.For();
+ var effect = new BindEffect(target, 5, 1f / 8f);
+ var prevent = false;
+
+ // Act
+ effect.PreventSelfSwitch(Substitute.For(), ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "It also traps the target, preventing switching and escape."
+ /// While the effect is active, the target cannot flee.
+ ///
+ [Test]
+ public async Task BindEffect_WhileActive_PreventsRunningAway()
+ {
+ // Arrange
+ var target = Substitute.For();
+ var effect = new BindEffect(target, 5, 1f / 8f);
+ var prevent = false;
+
+ // Act
+ effect.PreventSelfRunAway(Substitute.For(), ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Its effect will last either 4 or 5 turns."
+ /// Once the duration has run out, the target is no longer prevented from switching.
+ ///
+ [Test]
+ public async Task BindEffect_AfterDurationExpires_NoLongerPreventsSwitching()
+ {
+ // Arrange
+ var target = Substitute.For();
+ target.MaxHealth.Returns(160u);
+ var effect = new BindEffect(target, 1, 1f / 8f);
+
+ // Act - the single remaining turn elapses
+ effect.OnEndTurn(target, Substitute.For());
+ var prevent = false;
+ effect.PreventSelfSwitch(Substitute.For(), ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsFalse();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BlockTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BlockTests.cs
new file mode 100644
index 0000000..6e466f7
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BlockTests.cs
@@ -0,0 +1,119 @@
+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;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script.
+/// Gen VII Bulbapedia behavior: "Block prevents the target from switching out or fleeing (including via
+/// Teleport)." From Generation VI onward: "Block no longer affects Ghost-type Pokémon."
+///
+public class BlockTests
+{
+ private static (Block script, IExecutingMove move, IPokemon target, ScriptSet targetVolatile) CreateTestSetup()
+ {
+ var script = new Block();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ move.User.Returns(user);
+
+ var target = Substitute.For();
+ // Use a real script set so the volatile script added by Block can be inspected afterwards.
+ var targetVolatile = new ScriptSet(target);
+ target.Volatile.Returns(targetVolatile);
+ target.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ move.GetHitData(target, 0).Returns(Substitute.For());
+
+ return (script, move, target, targetVolatile);
+ }
+
+ ///
+ /// Bulbapedia: "Block prevents the target from switching out or fleeing (including via Teleport)."
+ /// Hitting the target attaches the volatile script to it.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_Hit_AddsBlockEffectToTarget()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Block prevents the target from switching out".
+ /// The applied by the move prevents the target's switch choices through
+ /// .
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_AppliedEffect_PreventsTargetFromSwitchingOut()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile) = CreateTestSetup();
+ script.OnSecondaryEffect(move, target, 0);
+ await Assert.That(targetVolatile.TryGet(out var effect)).IsTrue();
+
+ // Act
+ var prevent = false;
+ effect!.PreventSelfSwitch(Substitute.For(), ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Block prevents the target from [...] fleeing (including via Teleport)."
+ /// The applied by the move prevents the target's flee choices through
+ /// .
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_AppliedEffect_PreventsTargetFromFleeing()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile) = CreateTestSetup();
+ script.OnSecondaryEffect(move, target, 0);
+ await Assert.That(targetVolatile.TryGet(out var effect)).IsTrue();
+
+ // Act
+ var prevent = false;
+ effect!.PreventSelfRunAway(Substitute.For(), ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia (Generation VI onward): "Block no longer affects Ghost-type Pokémon."
+ /// Using Block on a Ghost-type target should not trap it, so no may be added.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_GhostTypeTarget_DoesNotTrapTarget()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile) = CreateTestSetup();
+ var library = LibraryHelpers.LoadLibrary();
+ await Assert.That(library.StaticLibrary.Types.TryGetTypeIdentifier("ghost", out var ghostType)).IsTrue();
+ target.Types.Returns(new[] { ghostType });
+
+ var battle = Substitute.For();
+ battle.Library.Returns(library);
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ move.User.BattleData.Returns(battleData);
+ target.BattleData.Returns(battleData);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BounceTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BounceTests.cs
new file mode 100644
index 0000000..096d675
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BounceTests.cs
@@ -0,0 +1,327 @@
+using PkmnLib.Dynamic.Events;
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.Models.Choices;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Dynamic.ScriptHandling.Registry;
+using PkmnLib.Plugin.Gen7.Common;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
+using PkmnLib.Static.Moves;
+using PkmnLib.Static.Utils;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script.
+/// Gen VII Bulbapedia behavior: "The user gains the Sky-High status on the turn this move is used, then
+/// attacks on the following turn, inflicting damage with a 30% chance of paralyzing the target. While in
+/// Sky-High status, the user is invulnerable to most moves, but can still be hit by Gust, Twister, Thunder,
+/// and Sky Uppercut", where (from Generation V onward) for Gust and Twister "the damage dealt will be doubled".
+///
+public class BounceTests
+{
+ private static (Bounce script, IExecutingMove move, IPokemon user, ScriptSet userVolatile, IMoveChoice moveChoice,
+ IBattleRandom random) CreateTestSetup()
+ {
+ var script = new Bounce();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ // Use a real script set so the charge volatile added by Bounce can be inspected afterwards.
+ var userVolatile = new ScriptSet(user);
+ user.Volatile.Returns(userVolatile);
+ user.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ move.User.Returns(user);
+
+ var moveChoice = Substitute.For();
+ move.MoveChoice.Returns(moveChoice);
+
+ var battle = Substitute.For();
+ var random = Substitute.For();
+ battle.Random.Returns(random);
+ battle.EventHook.Returns(new EventHook());
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ user.BattleData.Returns(battleData);
+
+ return (script, move, user, userVolatile, moveChoice, random);
+ }
+
+ ///
+ /// Bulbapedia: "The user gains the Sky-High status on the turn this move is used, then attacks on the
+ /// following turn". On the first turn the move is prevented from executing.
+ ///
+ [Test]
+ public async Task PreventMove_FirstUse_PreventsMoveForChargeTurn()
+ {
+ // Arrange
+ var (script, move, _, _, _, _) = CreateTestSetup();
+ var prevent = false;
+
+ // Act
+ script.PreventMove(move, ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "The user gains the Sky-High status on the turn this move is used".
+ /// The charge turn attaches the volatile script to the user.
+ ///
+ [Test]
+ public async Task PreventMove_FirstUse_AddsChargeBounceEffectToUser()
+ {
+ // Arrange
+ var (script, move, _, userVolatile, _, _) = CreateTestSetup();
+ var prevent = false;
+
+ // Act
+ script.PreventMove(move, ref prevent);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "The user gains the Sky-High status on the turn this move is used".
+ /// The charge turn is marked on the so the
+ /// knows this choice was the charging turn.
+ ///
+ [Test]
+ public async Task PreventMove_FirstUse_MarksMoveChoiceAsChargeTurn()
+ {
+ // Arrange
+ var (script, move, _, _, moveChoice, _) = CreateTestSetup();
+ var prevent = false;
+
+ // Act
+ script.PreventMove(move, ref prevent);
+
+ // Assert
+ moveChoice.Received(1).SetAdditionalData(new StringKey("bounce_charge"), true);
+ await Assert.That(prevent).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "then attacks on the following turn". When the user already has the
+ /// from the charge turn, the move is not prevented again.
+ ///
+ [Test]
+ public async Task PreventMove_SecondTurn_DoesNotPreventMove()
+ {
+ // Arrange
+ var (script, move, user, userVolatile, _, _) = CreateTestSetup();
+ userVolatile.Add(new ChargeBounceEffect(user));
+ var prevent = false;
+
+ // Act
+ script.PreventMove(move, ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "then attacks on the following turn". Once the attack executes, the Sky-High state
+ /// () is removed from the user.
+ ///
+ [Test]
+ public async Task OnBeforeMove_ChargeCompleted_RemovesChargeBounceEffect()
+ {
+ // Arrange
+ var (script, move, user, userVolatile, _, _) = CreateTestSetup();
+ userVolatile.Add(new ChargeBounceEffect(user));
+
+ // Act
+ script.OnBeforeMove(move);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "inflicting damage with a 30% chance of paralyzing the target."
+ /// When the effect chance roll succeeds, the target is paralyzed.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_EffectChanceSucceeds_ParalyzesTarget()
+ {
+ // Arrange
+ var (script, move, user, _, _, random) = CreateTestSetup();
+ var target = Substitute.For();
+ random.EffectChance(30, move, target, 0).Returns(true);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ target.Received(1).SetStatus(new StringKey("paralyzed"), user);
+ await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "inflicting damage with a 30% chance of paralyzing the target."
+ /// When the effect chance roll fails, the target is not paralyzed.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_EffectChanceFails_DoesNotParalyzeTarget()
+ {
+ // Arrange
+ var (script, move, _, _, _, random) = CreateTestSetup();
+ var target = Substitute.For();
+ random.EffectChance(30, move, target, 0).Returns(false);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "a 30% chance of paralyzing the target."
+ /// The paralysis roll is made with exactly a 30% chance.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_ParalysisRoll_UsesThirtyPercentChance()
+ {
+ // Arrange
+ var (script, move, _, _, _, random) = CreateTestSetup();
+ var target = Substitute.For();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ random.Received(1).EffectChance(30, move, target, 0);
+ await Assert.That(random.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "EffectChance")).IsTrue();
+ }
+
+ ///
+ /// Technical test: outside of battle (no battle data) the secondary effect does nothing and does not throw.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_NoBattleData_DoesNotParalyzeTarget()
+ {
+ // Arrange
+ var (script, move, user, _, _, _) = CreateTestSetup();
+ user.BattleData.Returns((IPokemonBattleData?)null);
+ var target = Substitute.For();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "SetStatus")).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "While in Sky-High status, the user is invulnerable to most moves".
+ /// The added by the charge turn blocks incoming hits from moves that
+ /// cannot hit semi-invulnerable flying Pokémon.
+ ///
+ [Test]
+ public async Task BlockIncomingHit_MoveCannotHitSkyHighUser_BlocksHit()
+ {
+ // Arrange
+ var (script, move, user, userVolatile, _, _) = CreateTestSetup();
+ var prevent = false;
+ script.PreventMove(move, ref prevent);
+ await Assert.That(userVolatile.TryGet(out var effect)).IsTrue();
+
+ var incomingMove = Substitute.For();
+ var incomingMoveData = Substitute.For();
+ incomingMoveData.HasFlag(MoveFlags.HitFlying).Returns(false);
+ incomingMove.UseMove.Returns(incomingMoveData);
+
+ // Act
+ var block = false;
+ effect!.BlockIncomingHit(incomingMove, user, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "but can still be hit by Gust, Twister, Thunder, and Sky Uppercut".
+ /// Moves flagged as able to hit semi-invulnerable flying Pokémon are not blocked by the
+ /// .
+ ///
+ [Test]
+ public async Task BlockIncomingHit_MoveCanHitSkyHighUser_DoesNotBlockHit()
+ {
+ // Arrange
+ var (script, move, user, userVolatile, _, _) = CreateTestSetup();
+ var prevent = false;
+ script.PreventMove(move, ref prevent);
+ await Assert.That(userVolatile.TryGet(out var effect)).IsTrue();
+
+ var incomingMove = Substitute.For();
+ var incomingMoveData = Substitute.For();
+ incomingMoveData.HasFlag(MoveFlags.HitFlying).Returns(true);
+ incomingMove.UseMove.Returns(incomingMoveData);
+
+ // Act
+ var block = false;
+ effect!.BlockIncomingHit(incomingMove, user, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia (Generation V onward): "If the user is hit by Gust or Twister while the user has the
+ /// Sky-High status, the damage dealt will be doubled". Gust and Twister carry the
+ /// flag, so their damage against the Sky-High user doubles.
+ ///
+ [Test]
+ public async Task ChangeIncomingMoveDamage_GustLikeMove_DamageIsDoubled()
+ {
+ // Arrange
+ var (script, move, user, userVolatile, _, _) = CreateTestSetup();
+ var prevent = false;
+ script.PreventMove(move, ref prevent);
+ await Assert.That(userVolatile.TryGet(out var effect)).IsTrue();
+
+ var incomingMove = Substitute.For();
+ var incomingMoveData = Substitute.For();
+ incomingMoveData.HasFlag(MoveFlags.HitFlying).Returns(true);
+ incomingMoveData.HasFlag(MoveFlags.EffectiveAgainstFly).Returns(true);
+ incomingMove.UseMove.Returns(incomingMoveData);
+ uint damage = 100;
+
+ // Act
+ effect!.ChangeIncomingMoveDamage(incomingMove, user, 0, ref damage);
+
+ // Assert
+ await Assert.That(damage).IsEqualTo(200u);
+ }
+
+ ///
+ /// Bulbapedia (Generation V onward): only for Gust and Twister "the damage dealt will be doubled".
+ /// Other moves that can hit the Sky-High user (e.g. Thunder, Sky Uppercut) deal regular damage; they do
+ /// not carry the flag.
+ ///
+ [Test]
+ public async Task ChangeIncomingMoveDamage_ThunderLikeMove_DamageIsUnchanged()
+ {
+ // Arrange
+ var (script, move, user, userVolatile, _, _) = CreateTestSetup();
+ var prevent = false;
+ script.PreventMove(move, ref prevent);
+ await Assert.That(userVolatile.TryGet(out var effect)).IsTrue();
+
+ var incomingMove = Substitute.For();
+ var incomingMoveData = Substitute.For();
+ incomingMoveData.HasFlag(MoveFlags.HitFlying).Returns(true);
+ incomingMoveData.HasFlag(MoveFlags.EffectiveAgainstFly).Returns(false);
+ incomingMove.UseMove.Returns(incomingMoveData);
+ uint damage = 100;
+
+ // Act
+ effect!.ChangeIncomingMoveDamage(incomingMove, user, 0, ref damage);
+
+ // Assert
+ await Assert.That(damage).IsEqualTo(100u);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrickBreakTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrickBreakTests.cs
new file mode 100644
index 0000000..6390690
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrickBreakTests.cs
@@ -0,0 +1,180 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Side;
+using PkmnLib.Static.Utils;
+using TUnit.Assertions.AssertConditions.Throws;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script.
+/// Gen VII Bulbapedia behavior: "Brick Break removes Light Screen and Reflect", from Generation IV onward
+/// "from the target's side of the field, even if the target is an ally", and from Generation V onwards
+/// "Brick Break can also remove Aurora Veil."
+///
+public class BrickBreakTests
+{
+ ///
+ /// Creates a mocked Pokémon whose is the given side.
+ ///
+ private static IPokemon CreatePokemonOnSide(IBattleSide side)
+ {
+ var pokemon = Substitute.For();
+ var battleData = Substitute.For();
+ battleData.BattleSide.Returns(side);
+ pokemon.BattleData.Returns(battleData);
+ return pokemon;
+ }
+
+ ///
+ /// Creates a fully mocked test setup where the user targets a Pokémon on the opposing side.
+ ///
+ private static (BrickBreak script, IExecutingMove move, IPokemon user, IScriptSet userSideScripts, IScriptSet
+ targetSideScripts) CreateTestSetup()
+ {
+ var script = new BrickBreak();
+ var move = Substitute.For();
+
+ var userSideScripts = Substitute.For();
+ var userSide = Substitute.For();
+ userSide.VolatileScripts.Returns(userSideScripts);
+ var targetSideScripts = Substitute.For();
+ var targetSide = Substitute.For();
+ targetSide.VolatileScripts.Returns(targetSideScripts);
+
+ var user = CreatePokemonOnSide(userSide);
+ move.User.Returns(user);
+ var target = CreatePokemonOnSide(targetSide);
+ move.Targets.Returns(new IPokemon?[] { target });
+
+ return (script, move, user, userSideScripts, targetSideScripts);
+ }
+
+ ///
+ /// Bulbapedia (Generation IV onward): "Brick Break now removes Light Screen and Reflect from the target's
+ /// side of the field, even if the target is an ally." The screen effects are removed from the target's
+ /// side before the move hits. The script names match and
+ /// .
+ ///
+ [Test, Arguments("reflect"), Arguments("light_screen")]
+ public async Task OnBeforeMove_ScreenOnTargetSide_RemovesScreenFromTargetSide(string screenScriptName)
+ {
+ // Arrange
+ var (script, move, _, _, targetSideScripts) = CreateTestSetup();
+
+ // Act
+ script.OnBeforeMove(move);
+
+ // Assert
+ targetSideScripts.Received(1).Remove(new StringKey(screenScriptName));
+ await Assert.That(targetSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia (Generation V onwards): "Brick Break can also remove Aurora Veil."
+ /// The is removed from the target's side as well.
+ ///
+ [Test]
+ public async Task OnBeforeMove_AuroraVeilOnTargetSide_RemovesAuroraVeilFromTargetSide()
+ {
+ // Arrange
+ var (script, move, _, _, targetSideScripts) = CreateTestSetup();
+
+ // Act
+ script.OnBeforeMove(move);
+
+ // Assert
+ targetSideScripts.Received(1).Remove(new StringKey("aurora_veil"));
+ await Assert.That(targetSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia (Generation IV onward): "Brick Break now removes Light Screen and Reflect from the target's
+ /// side of the field". When the target is an opponent, screens on the user's own side stay up.
+ ///
+ [Test]
+ public async Task OnBeforeMove_ScreensOnUserSide_DoesNotRemoveScreensFromUserSide()
+ {
+ // Arrange
+ var (script, move, _, userSideScripts, _) = CreateTestSetup();
+
+ // Act
+ script.OnBeforeMove(move);
+
+ // Assert
+ await Assert.That(userSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia (Generation IV onward): "Brick Break now removes Light Screen and Reflect from the target's
+ /// side of the field, even if the target is an ally." An ally shares the user's side, so targeting an ally
+ /// removes the screens from the user's own side.
+ ///
+ [Test,
+ TestFailing("BrickBreak.OnBeforeMove excludes the user's own side unconditionally, so targeting an " +
+ "ally removes nothing (Bulbapedia: 'removes Light Screen and Reflect from the target's " +
+ "side of the field, even if the target is an ally')")]
+ public async Task OnBeforeMove_AllyTargetOnUserSide_RemovesScreensFromUserSide()
+ {
+ // Arrange
+ var (script, move, user, userSideScripts, _) = CreateTestSetup();
+ var ally = CreatePokemonOnSide(user.BattleData!.BattleSide);
+ move.Targets.Returns(new IPokemon?[] { ally });
+
+ // Act
+ script.OnBeforeMove(move);
+
+ // Assert
+ await Assert.That(userSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Brick Break removes Light Screen and Reflect [...], then inflicts damage."
+ /// When no screens are up there is nothing to remove; the move simply proceeds without error and the
+ /// sides remain unaffected.
+ ///
+ [Test]
+ public async Task OnBeforeMove_NoScreensPresent_LeavesSidesUnchanged()
+ {
+ // Arrange
+ var script = new BrickBreak();
+ var move = Substitute.For();
+ // Real, empty script sets so the removals (if any wrongly happen) would be observable as mutations.
+ var userSideScripts = new ScriptSet(Substitute.For());
+ var targetSideScripts = new ScriptSet(Substitute.For());
+ var userSide = Substitute.For();
+ userSide.VolatileScripts.Returns(userSideScripts);
+ var targetSide = Substitute.For();
+ targetSide.VolatileScripts.Returns(targetSideScripts);
+ var user = CreatePokemonOnSide(userSide);
+ move.User.Returns(user);
+ var target = CreatePokemonOnSide(targetSide);
+ move.Targets.Returns(new IPokemon?[] { target });
+
+ // Act
+ script.OnBeforeMove(move);
+
+ // Assert
+ await Assert.That(userSideScripts.Count).IsEqualTo(0);
+ await Assert.That(targetSideScripts.Count).IsEqualTo(0);
+ }
+
+ ///
+ /// Technical test: without any targets (e.g. outside of battle) the script does nothing and does not throw.
+ ///
+ [Test]
+ public async Task OnBeforeMove_NoTargets_DoesNotThrow()
+ {
+ // Arrange
+ var script = new BrickBreak();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ user.BattleData.Returns((IPokemonBattleData?)null);
+ move.User.Returns(user);
+ move.Targets.Returns(Array.Empty());
+
+ // Act & Assert
+ await Assert.That(() => script.OnBeforeMove(move)).ThrowsNothing();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrineTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrineTests.cs
new file mode 100644
index 0000000..ec573dc
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BrineTests.cs
@@ -0,0 +1,101 @@
+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.
+/// Behavior is verified against the Bulbapedia page for Brine.
+///
+public class BrineTests
+{
+ ///
+ /// Creates a fully mocked test setup for Brine tests, with a target whose max HP
+ /// () and are configured.
+ ///
+ private static (Brine brine, IExecutingMove move, IPokemon target) CreateTestSetup(uint maxHp, uint currentHp)
+ {
+ var brine = new Brine();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ target.BoostedStats.Returns(new StatisticSet(maxHp, 0, 0, 0, 0, 0));
+ target.CurrentHealth.Returns(currentHp);
+ return (brine, move, target);
+ }
+
+ ///
+ /// Bulbapedia: "Brine inflicts damage, and its power doubles to 130 when the target is at or below
+ /// 50% health."
+ /// At exactly half HP, the base power of 65 doubles to 130.
+ ///
+ [Test]
+ public async Task ChangeBasePower_TargetAtExactlyHalfHp_BasePowerDoubles()
+ {
+ // Arrange
+ var (brine, move, target) = CreateTestSetup(100, 50);
+ ushort basePower = 65;
+
+ // Act
+ brine.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)130);
+ }
+
+ ///
+ /// Bulbapedia: "its power doubles to 130 when the target is at or below 50% health."
+ /// Tests several HP fractions at or below half, including odd max HP values where 50% is not
+ /// a whole number (50/101 ≈ 49.5% is below half).
+ ///
+ [Test, Arguments(100u, 49u), Arguments(100u, 1u), Arguments(200u, 100u), Arguments(101u, 50u)]
+ public async Task ChangeBasePower_TargetAtOrBelowHalfHp_BasePowerDoubles(uint maxHp, uint currentHp)
+ {
+ // Arrange
+ var (brine, move, target) = CreateTestSetup(maxHp, currentHp);
+ ushort basePower = 65;
+
+ // Act
+ brine.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)130);
+ }
+
+ ///
+ /// Bulbapedia: "its power doubles to 130 when the target is at or below 50% health."
+ /// Above 50% health the power must remain unchanged, including one HP above half and odd max HP
+ /// values where 51/101 ≈ 50.5% is just above half.
+ ///
+ [Test, Arguments(100u, 51u), Arguments(100u, 100u), Arguments(101u, 51u), Arguments(200u, 101u)]
+ public async Task ChangeBasePower_TargetAboveHalfHp_BasePowerUnchanged(uint maxHp, uint currentHp)
+ {
+ // Arrange
+ var (brine, move, target) = CreateTestSetup(maxHp, currentHp);
+ ushort basePower = 65;
+
+ // Act
+ brine.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)65);
+ }
+
+ ///
+ /// Technical test: doubling an already very high base power must clamp to
+ /// instead of overflowing.
+ ///
+ [Test]
+ public async Task ChangeBasePower_DoublingWouldOverflow_ClampsToMax()
+ {
+ // Arrange
+ var (brine, move, target) = CreateTestSetup(100, 50);
+ ushort basePower = ushort.MaxValue - 100;
+
+ // Act
+ brine.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo(ushort.MaxValue);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BugBiteTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BugBiteTests.cs
new file mode 100644
index 0000000..c5d4583
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/BugBiteTests.cs
@@ -0,0 +1,243 @@
+using PkmnLib.Dynamic.Events;
+using PkmnLib.Dynamic.Libraries;
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.ScriptHandling;
+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.
+/// Behavior is verified against the Bulbapedia page for Bug Bite.
+///
+public class BugBiteTests
+{
+ private const string BerryEffectName = "test_berry_effect";
+
+ ///
+ /// Minimal that records whether its effect was applied through
+ /// .
+ ///
+ private class RecordingItemScript : ItemScript
+ {
+ public RecordingItemScript(IItem item) : base(item)
+ {
+ }
+
+ public bool WasUsed { get; private set; }
+
+ ///
+ public override bool IsItemUsable => true;
+
+ ///
+ public override void OnUse(EventHook eventHook) => WasUsed = true;
+ }
+
+ ///
+ /// Creates a fully mocked test setup for BugBite tests. The battle's is a
+ /// real resolver with a single registered item script constructor for , so
+ /// eating a Berry runs a that the test can inspect.
+ ///
+ private static (BugBite bugBite, IExecutingMove move, IPokemon target, IHitData hitData, List
+ createdItemScripts) CreateTestSetup(IItem? targetHeldItem, bool canSteal = true)
+ {
+ var bugBite = new BugBite();
+
+ var createdItemScripts = new List();
+ var resolver = new ScriptResolver(new Dictionary<(ScriptCategory, StringKey), Func