diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DarkestLariatTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DarkestLariatTests.cs
new file mode 100644
index 0000000..1307a49
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DarkestLariatTests.cs
@@ -0,0 +1,52 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script.
+/// Gen VII Bulbapedia behavior: "Darkest Lariat inflicts damage, ignoring any changes to the target's
+/// Defense and evasion stat stages."
+///
+public class DarkestLariatTests
+{
+ ///
+ /// Bulbapedia: "Darkest Lariat inflicts damage, ignoring any changes to the target's Defense [...]
+ /// stat stages." The script bypasses the target's defensive stat boosts.
+ ///
+ [Test]
+ public async Task BypassDefensiveStatBoosts_Always_SetsBypassToTrue()
+ {
+ // Arrange
+ var script = new DarkestLariat();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var bypass = false;
+
+ // Act
+ script.BypassDefensiveStatBoosts(move, target, 0, ref bypass);
+
+ // Assert
+ await Assert.That(bypass).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Darkest Lariat inflicts damage, ignoring any changes to the target's [...] evasion
+ /// stat stages." The script bypasses the target's evasion stat boosts.
+ ///
+ [Test]
+ public async Task BypassEvasionStatBoosts_Always_SetsBypassToTrue()
+ {
+ // Arrange
+ var script = new DarkestLariat();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var bypass = false;
+
+ // Act
+ script.BypassEvasionStatBoosts(move, target, 0, ref bypass);
+
+ // Assert
+ await Assert.That(bypass).IsTrue();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DefogTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DefogTests.cs
new file mode 100644
index 0000000..0deadc0
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DefogTests.cs
@@ -0,0 +1,164 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Dynamic.ScriptHandling.Registry;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Side;
+using PkmnLib.Static;
+using PkmnLib.Static.Utils;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script.
+/// Gen VII Bulbapedia behavior: "Defog lowers the target's evasion by one stage" and clears field effects:
+/// "It removes Spikes, Toxic Spikes, Stealth Rock, and Sticky Web from both sides, while Light Screen,
+/// Reflect, Safeguard, Mist, and Aurora Veil are cleared from the target's side only."
+///
+public class DefogTests
+{
+ ///
+ /// Creates a fully mocked test setup where the target is on side 1 and the user on side 0.
+ ///
+ private static (Defog script, IExecutingMove move, IPokemon target, IScriptSet targetSideScripts, IScriptSet
+ userSideScripts) CreateTestSetup()
+ {
+ var script = new Defog();
+ 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 battle = Substitute.For();
+ battle.Sides.Returns(new[] { userSide, targetSide });
+
+ var user = Substitute.For();
+ var userBattleData = Substitute.For();
+ userBattleData.Battle.Returns(battle);
+ userBattleData.SideIndex.Returns((byte)0);
+ user.BattleData.Returns(userBattleData);
+ move.User.Returns(user);
+
+ var target = Substitute.For();
+ var targetBattleData = Substitute.For();
+ targetBattleData.Battle.Returns(battle);
+ targetBattleData.SideIndex.Returns((byte)1);
+ target.BattleData.Returns(targetBattleData);
+
+ return (script, move, target, targetSideScripts, userSideScripts);
+ }
+
+ ///
+ /// Bulbapedia: "Light Screen, Reflect, Safeguard, Mist, and Aurora Veil are cleared from the target's
+ /// side only." The screen and protection effects (,
+ /// , , ) are removed
+ /// from the target's side.
+ ///
+ [Test, Arguments("light_screen"), Arguments("reflect"), Arguments("safe_guard"), Arguments("mist")]
+ public async Task OnSecondaryEffect_ScreenOnTargetSide_RemovesEffectFromTargetSide(string scriptName)
+ {
+ // Arrange
+ var (script, move, target, targetSideScripts, _) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ targetSideScripts.Received(1).Remove(new StringKey(scriptName));
+ await Assert.That(targetSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "It removes Spikes, Toxic Spikes, Stealth Rock, and Sticky Web" — the entry hazards
+ /// (, , ,
+ /// ) are removed from the target's side.
+ ///
+ [Test, Arguments("spikes"), Arguments("toxic_spikes"), Arguments("stealth_rock"), Arguments("sticky_web")]
+ public async Task OnSecondaryEffect_HazardOnTargetSide_RemovesHazardFromTargetSide(string scriptName)
+ {
+ // Arrange
+ var (script, move, target, targetSideScripts, _) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ targetSideScripts.Received(1).Remove(new StringKey(scriptName));
+ await Assert.That(targetSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia (Generations VI and VII): "It removes Spikes, Toxic Spikes, Stealth Rock, and Sticky Web
+ /// from both sides" — the entry hazards must also be removed from the user's own side.
+ ///
+ [Test, Arguments("spikes"), Arguments("toxic_spikes"), Arguments("stealth_rock"), Arguments("sticky_web")]
+ public async Task OnSecondaryEffect_HazardOnUserSide_RemovesHazardFromUserSide(string scriptName)
+ {
+ // Arrange
+ var (script, move, target, _, userSideScripts) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ userSideScripts.Received(1).Remove(new StringKey(scriptName));
+ await Assert.That(userSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia (Generations VI and VII): "Light Screen, Reflect, Safeguard, Mist, and Aurora Veil are
+ /// cleared from the target's side only." The must be removed as well.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_AuroraVeilOnTargetSide_RemovesAuroraVeilFromTargetSide()
+ {
+ // Arrange
+ var (script, move, target, targetSideScripts, _) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ targetSideScripts.Received(1).Remove(new StringKey("aurora_veil"));
+ await Assert.That(targetSideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Remove")).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Defog lowers the target's evasion by one stage". Neither the script nor the move data
+ /// applies the evasion drop, so the target's evasion is never lowered.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_Always_LowersTargetEvasionByOneStage()
+ {
+ // Arrange
+ var (script, move, target, _, _) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(target.ReceivedCalls().Any(c =>
+ c.GetMethodInfo().Name == "ChangeStatBoost" && (Statistic)c.GetArguments()[0]! == Statistic.Evasion &&
+ (sbyte)c.GetArguments()[1]! == -1)).IsTrue();
+ }
+
+ ///
+ /// Technical test: without battle data (outside of battle) the secondary effect does nothing and does
+ /// not throw.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetHasNoBattleData_DoesNotThrow()
+ {
+ // Arrange
+ var script = new Defog();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ target.BattleData.Returns((IPokemonBattleData?)null);
+
+ // Act & Assert
+ await Assert.That(() => script.OnSecondaryEffect(move, target, 0)).ThrowsNothing();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DestinyBondTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DestinyBondTests.cs
new file mode 100644
index 0000000..58d7f41
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DestinyBondTests.cs
@@ -0,0 +1,173 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.Models.Choices;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Dynamic.ScriptHandling.Registry;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
+using PkmnLib.Static;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script and its .
+/// Gen VII Bulbapedia behavior: "When Destiny Bond is used, the user gains the Destiny Bound status.
+/// Until the user moves again, if the user faints as the direct result of an attack, the attacking
+/// Pokémon also faints."
+///
+public class DestinyBondTests
+{
+ ///
+ /// Creates a mocked user whose is a real so
+ /// the volatile added by the move can be inspected.
+ ///
+ private static (DestinyBond script, IExecutingMove move, IPokemon user, ScriptSet userVolatile) CreateTestSetup()
+ {
+ var script = new DestinyBond();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ var userVolatile = new ScriptSet(user);
+ user.Volatile.Returns(userVolatile);
+ user.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ move.User.Returns(user);
+ return (script, move, user, userVolatile);
+ }
+
+ ///
+ /// Creates a fainting Pokémon whose battle's is a move
+ /// choice made by the given attacker.
+ ///
+ private static IPokemon CreateFaintingPokemonAttackedBy(IPokemon attacker)
+ {
+ var moveChoice = Substitute.For();
+ moveChoice.User.Returns(attacker);
+ var queue = new BattleChoiceQueue([moveChoice]);
+ queue.Dequeue();
+
+ var battle = Substitute.For();
+ battle.ChoiceQueue.Returns(queue);
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ var pokemon = Substitute.For();
+ pokemon.BattleData.Returns(battleData);
+ return pokemon;
+ }
+
+ ///
+ /// Bulbapedia: "When Destiny Bond is used, the user gains the Destiny Bound status." The move adds the
+ /// volatile script to the user.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_Used_AddsDestinyBondEffectToUser()
+ {
+ // Arrange
+ var (script, move, _, userVolatile) = CreateTestSetup();
+ var target = Substitute.For();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "if the user faints as the direct result of an attack, the attacking Pokémon also
+ /// faints." When the Destiny Bound user faints from move damage, the attacker takes damage far in
+ /// excess of its HP, guaranteeing the faint.
+ ///
+ [Test]
+ public async Task OnFaint_FaintsFromMoveDamage_AttackerIsDamagedToFaint()
+ {
+ // Arrange
+ var effect = new DestinyBondEffect();
+ var attacker = Substitute.For();
+ attacker.BoostedStats.Returns(new StatisticSet(100, 1, 1, 1, 1, 1));
+ var pokemon = CreateFaintingPokemonAttackedBy(attacker);
+
+ // Act
+ effect.OnFaint(pokemon, DamageSource.MoveDamage);
+
+ // Assert - the attacker is damaged by well over its max HP (10x) so it always faints
+ var damageCall = attacker.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
+ await Assert.That(damageCall).IsNotNull();
+ await Assert.That((uint)damageCall!.GetArguments()[0]!).IsEqualTo(1000u);
+ }
+
+ ///
+ /// Bulbapedia: Destiny Bond "bypasses protective abilities like Focus Sash and Sturdy". The faint
+ /// damage is dealt with force, so scripts cannot reduce or prevent it.
+ ///
+ [Test]
+ public async Task OnFaint_FaintsFromMoveDamage_DamageIsForced()
+ {
+ // Arrange
+ var effect = new DestinyBondEffect();
+ var attacker = Substitute.For();
+ attacker.BoostedStats.Returns(new StatisticSet(100, 1, 1, 1, 1, 1));
+ var pokemon = CreateFaintingPokemonAttackedBy(attacker);
+
+ // Act
+ effect.OnFaint(pokemon, DamageSource.MoveDamage);
+
+ // Assert - the forceDamage argument (last parameter) is true
+ var damageCall = attacker.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage");
+ await Assert.That((bool)damageCall.GetArguments()[3]!).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "The move doesn't activate from indirect damage like weather or Leech Seed."
+ /// Fainting from a non-move damage source does not take the attacker down.
+ ///
+ [Test]
+ public async Task OnFaint_FaintsFromIndirectDamage_AttackerIsNotDamaged()
+ {
+ // Arrange
+ var effect = new DestinyBondEffect();
+ var attacker = Substitute.For();
+ attacker.BoostedStats.Returns(new StatisticSet(100, 1, 1, 1, 1, 1));
+ var pokemon = CreateFaintingPokemonAttackedBy(attacker);
+
+ // Act
+ effect.OnFaint(pokemon, DamageSource.Misc);
+
+ // Assert
+ await Assert.That(attacker.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "Until the user moves again" — the Destiny Bound status wears off when the user makes
+ /// its next move, so the effect removes itself before the user's move.
+ ///
+ [Test]
+ public async Task OnBeforeMove_UserMovesAgain_RemovesDestinyBondEffect()
+ {
+ // Arrange
+ var user = Substitute.For();
+ var userVolatile = new ScriptSet(user);
+ user.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ var effect = new DestinyBondEffect();
+ userVolatile.Add(effect);
+ var move = Substitute.For();
+
+ // Act
+ effect.OnBeforeMove(move);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Technical test: fainting without battle data (outside of battle) does nothing and does not throw.
+ ///
+ [Test]
+ public async Task OnFaint_NoBattleData_DoesNotThrow()
+ {
+ // Arrange
+ var effect = new DestinyBondEffect();
+ var pokemon = Substitute.For();
+ pokemon.BattleData.Returns((IPokemonBattleData?)null);
+
+ // Act & Assert
+ await Assert.That(() => effect.OnFaint(pokemon, DamageSource.MoveDamage)).ThrowsNothing();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DigTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DigTests.cs
new file mode 100644
index 0000000..442a14b
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DigTests.cs
@@ -0,0 +1,290 @@
+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.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 .
+/// Gen VII Bulbapedia behavior: the user digs underground on the first turn, becoming semi-invulnerable,
+/// and attacks on the second turn. "Earthquake, Magnitude, and Fissure hit underground targets (dealing
+/// double damage from Gen II onward)".
+///
+public class DigTests
+{
+ private static (Dig script, IExecutingMove move, IPokemon user, ScriptSet userVolatile, IMoveChoice moveChoice)
+ CreateTestSetup()
+ {
+ var script = new Dig();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ // Use a real script set so the charge volatile added by Dig 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();
+ battle.EventHook.Returns(new EventHook());
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ user.BattleData.Returns(battleData);
+
+ return (script, move, user, userVolatile, moveChoice);
+ }
+
+ ///
+ /// Bulbapedia: "The user excavates underground, becoming immune to most moves during the initial 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 "a semi-invulnerable state" on the initial turn. The charge turn
+ /// attaches the volatile script to the user.
+ ///
+ [Test]
+ public async Task PreventMove_FirstUse_AddsDigEffectToUser()
+ {
+ // Arrange
+ var (script, move, _, userVolatile, _) = CreateTestSetup();
+ var prevent = false;
+
+ // Act
+ script.PreventMove(move, ref prevent);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// The charge turn is marked on the so the knows
+ /// this choice was the charging turn and does not remove itself for it.
+ ///
+ [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("dig_charge"), true);
+ await Assert.That(prevent).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "On the following turn, Dig deals damage." 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 DigEffect(user));
+ var prevent = false;
+
+ // Act
+ script.PreventMove(move, ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "On the following turn, Dig deals damage." Once the attack executes, the underground
+ /// state () is removed from the user.
+ ///
+ [Test]
+ public async Task OnBeforeMove_ChargeCompleted_RemovesDigEffect()
+ {
+ // Arrange
+ var (script, move, user, userVolatile, _) = CreateTestSetup();
+ userVolatile.Add(new DigEffect(user));
+
+ // Act
+ script.OnBeforeMove(move);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: while underground the user is in "a semi-invulnerable state" and is "immune to most
+ /// moves". The blocks incoming hits from moves that cannot hit underground
+ /// Pokémon.
+ ///
+ [Test]
+ public async Task BlockIncomingHit_MoveCannotHitUnderground_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(new StringKey("hit_underground")).Returns(false);
+ incomingMove.UseMove.Returns(incomingMoveData);
+
+ // Act
+ var block = false;
+ effect!.BlockIncomingHit(incomingMove, user, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Earthquake, Magnitude, and Fissure hit underground targets". Moves flagged as able to
+ /// hit underground Pokémon are not blocked by the .
+ ///
+ [Test]
+ public async Task BlockIncomingHit_EarthquakeLikeMove_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(new StringKey("hit_underground")).Returns(true);
+ incomingMove.UseMove.Returns(incomingMoveData);
+
+ // Act
+ var block = false;
+ effect!.BlockIncomingHit(incomingMove, user, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "Earthquake, Magnitude, and Fissure hit underground targets (dealing double damage from
+ /// Gen II onward)". A move flagged as effective against underground Pokémon deals doubled damage to the
+ /// underground user.
+ ///
+ [Test]
+ public async Task ChangeIncomingMoveDamage_EarthquakeLikeMove_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(new StringKey("hit_underground")).Returns(true);
+ incomingMoveData.HasFlag(new StringKey("effective_against_underground")).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: only Earthquake and Magnitude deal double damage to underground targets. A move without
+ /// the effective-against-underground flag deals regular damage.
+ ///
+ [Test]
+ public async Task ChangeIncomingMoveDamage_RegularMove_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(new StringKey("effective_against_underground")).Returns(false);
+ incomingMove.UseMove.Returns(incomingMoveData);
+ uint damage = 100;
+
+ // Act
+ effect!.ChangeIncomingMoveDamage(incomingMove, user, 0, ref damage);
+
+ // Assert
+ await Assert.That(damage).IsEqualTo(100u);
+ }
+
+ ///
+ /// Bulbapedia: "If disrupted, PP isn't consumed and the move doesn't count as the last used" — when
+ /// the user's executed choice was not the Dig charge (i.e. the move was disrupted or replaced), the
+ /// underground state is removed.
+ ///
+ [Test]
+ public async Task OnAfterMoveChoice_ChoiceIsNotDigCharge_RemovesDigEffect()
+ {
+ // Arrange
+ var user = Substitute.For();
+ var userVolatile = new ScriptSet(user);
+ user.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ var effect = new DigEffect(user);
+ userVolatile.Add(effect);
+ var choice = Substitute.For();
+ choice.AdditionalData.Returns((Dictionary?)null);
+
+ // Act
+ effect.OnAfterMoveChoice(choice);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// The charging turn itself carries the "dig_charge" marker; the must survive
+ /// that turn so the user stays underground until the attack turn.
+ ///
+ [Test]
+ public async Task OnAfterMoveChoice_ChoiceIsDigCharge_KeepsDigEffect()
+ {
+ // Arrange
+ var user = Substitute.For();
+ var userVolatile = new ScriptSet(user);
+ user.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ var effect = new DigEffect(user);
+ userVolatile.Add(effect);
+ var choice = Substitute.For();
+ choice.AdditionalData.Returns(new Dictionary { { "dig_charge", true } });
+
+ // Act
+ effect.OnAfterMoveChoice(choice);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DisableTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DisableTests.cs
new file mode 100644
index 0000000..d793767
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DisableTests.cs
@@ -0,0 +1,247 @@
+using PkmnLib.Dynamic.Libraries;
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.Models.Choices;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Dynamic.ScriptHandling.Registry;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
+using PkmnLib.Static.Moves;
+using PkmnLib.Static.Utils;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script and its .
+/// Gen VII Bulbapedia behavior: "Disable temporarily prevents the target from using a specific move";
+/// "Disable now disables the last move that the target used", failing "if the target hasn't used a move
+/// yet", and "the effect lasts [...] 4 turns from Generation V onward."
+///
+public class DisableTests
+{
+ ///
+ /// Creates a mocked move choice whose chosen move has the given name.
+ ///
+ private static IMoveChoice CreateMoveChoice(string moveName)
+ {
+ var moveData = Substitute.For();
+ moveData.Name.Returns(new StringKey(moveName));
+ var learnedMove = Substitute.For();
+ learnedMove.MoveData.Returns(moveData);
+ var choice = Substitute.For();
+ choice.ChosenMove.Returns(learnedMove);
+ return choice;
+ }
+
+ ///
+ /// Creates a fully mocked test setup where the target's volatile scripts are a real
+ /// and its last used move is the given one (or none).
+ ///
+ private static (Disable script, IExecutingMove move, IPokemon target, ScriptSet targetVolatile, IHitData hitData)
+ CreateTestSetup(string? lastUsedMove, bool lastMoveIsStruggle = false)
+ {
+ var script = new Disable();
+ var move = Substitute.For();
+
+ var user = Substitute.For();
+ var userBattleData = Substitute.For();
+ user.BattleData.Returns(userBattleData);
+ move.User.Returns(user);
+
+ var target = Substitute.For();
+ var targetVolatile = new ScriptSet(target);
+ target.Volatile.Returns(targetVolatile);
+ target.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ var targetBattleData = Substitute.For();
+ // Create the choice before the Returns call; configuring substitutes inside Returns is not allowed.
+ var lastMoveChoice = lastUsedMove == null ? null : CreateMoveChoice(lastUsedMove);
+ targetBattleData.LastMoveChoice.Returns(lastMoveChoice);
+ target.BattleData.Returns(targetBattleData);
+
+ // Struggle is recognized through the misc library's replacement choice check, not by name.
+ var miscLibrary = Substitute.For();
+ var library = Substitute.For();
+ library.MiscLibrary.Returns(miscLibrary);
+ target.Library.Returns(library);
+ if (lastMoveChoice != null)
+ miscLibrary.IsReplacementChoice(lastMoveChoice).Returns(lastMoveIsStruggle);
+
+ var hitData = Substitute.For();
+ move.GetHitData(target, 0).Returns(hitData);
+
+ return (script, move, target, targetVolatile, hitData);
+ }
+
+ ///
+ /// Bulbapedia: "Disable now disables the last move that the target used." The target gains the
+ /// volatile script.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetUsedMove_AddsDisableEffectToTarget()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile, _) = CreateTestSetup("tackle");
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "It fails if the target hasn't used a move yet". Without a last move choice on the
+ /// target, the hit fails and no disable is applied.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetHasNotUsedMove_FailsHit()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile, hitData) = CreateTestSetup(null);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "Disable fails if a move is already disabled". Using Disable on a target that already
+ /// has a disabled move must fail the hit rather than silently doing nothing.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetAlreadyDisabled_FailsHit()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile, hitData) = CreateTestSetup("tackle");
+ targetVolatile.Add(new DisableEffect(new StringKey("growl")));
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ await Assert.That(hitData.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "It fails if the target hasn't used a move yet or used Struggle". A target whose last
+ /// move was Struggle (a replacement choice per ) cannot
+ /// have it disabled.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetLastUsedStruggle_FailsHit()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile, hitData) = CreateTestSetup("struggle", true);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Technical test: without battle data on the user (outside of battle) the effect does nothing and
+ /// does not throw.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_UserHasNoBattleData_DoesNothing()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile, _) = CreateTestSetup("tackle");
+ move.User.BattleData.Returns((IPokemonBattleData?)null);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "Disable temporarily prevents the target from using a specific move." Selecting the
+ /// disabled move is prevented by the .
+ ///
+ [Test]
+ public async Task PreventMoveSelection_ChoiceIsDisabledMove_PreventsSelection()
+ {
+ // Arrange
+ var effect = new DisableEffect(new StringKey("tackle"));
+ var choice = CreateMoveChoice("tackle");
+ var prevent = false;
+
+ // Act
+ effect.PreventMoveSelection(choice, ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: only the disabled move is prevented; the target's other moves remain selectable.
+ ///
+ [Test]
+ public async Task PreventMoveSelection_ChoiceIsOtherMove_DoesNotPreventSelection()
+ {
+ // Arrange
+ var effect = new DisableEffect(new StringKey("tackle"));
+ var choice = CreateMoveChoice("growl");
+ var prevent = false;
+
+ // Act
+ effect.PreventMoveSelection(choice, ref prevent);
+
+ // Assert
+ await Assert.That(prevent).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "The effect lasts [...] 4 turns from Generation V onward." After three end-of-turn
+ /// ticks the effect is still active.
+ ///
+ [Test]
+ public async Task OnEndTurn_ThreeTurnsPassed_EffectStillActive()
+ {
+ // Arrange
+ var target = Substitute.For();
+ var targetVolatile = new ScriptSet(target);
+ target.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ var effect = new DisableEffect(new StringKey("tackle"));
+ targetVolatile.Add(effect);
+ var battle = Substitute.For();
+
+ // Act
+ for (var i = 0; i < 3; i++)
+ effect.OnEndTurn(target, battle);
+
+ // Assert
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "The effect lasts [...] 4 turns from Generation V onward." After the fourth
+ /// end-of-turn tick the effect removes itself.
+ ///
+ [Test]
+ public async Task OnEndTurn_FourTurnsPassed_EffectRemovesItself()
+ {
+ // Arrange
+ var target = Substitute.For();
+ var targetVolatile = new ScriptSet(target);
+ target.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ var effect = new DisableEffect(new StringKey("tackle"));
+ targetVolatile.Add(effect);
+ var battle = Substitute.For();
+
+ // Act
+ for (var i = 0; i < 4; i++)
+ effect.OnEndTurn(target, battle);
+
+ // 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/DiveTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DiveTests.cs
new file mode 100644
index 0000000..73b69ab
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DiveTests.cs
@@ -0,0 +1,288 @@
+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.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 .
+/// Gen VII Bulbapedia behavior: "The user gains the Submerged status on the turn this move is used, then
+/// attacks on the following turn. While submerged, the user is invulnerable to most moves but can still be
+/// hit by Surf and Whirlpool, dealing twice the regular damage."
+///
+public class DiveTests
+{
+ private static (Dive script, IExecutingMove move, IPokemon user, ScriptSet userVolatile, IMoveChoice moveChoice)
+ CreateTestSetup()
+ {
+ var script = new Dive();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ // Use a real script set so the charge volatile added by Dive 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();
+ battle.EventHook.Returns(new EventHook());
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ user.BattleData.Returns(battleData);
+
+ return (script, move, user, userVolatile, moveChoice);
+ }
+
+ ///
+ /// Bulbapedia: "The user gains the Submerged 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 Submerged status on the turn this move is used." The charge turn
+ /// attaches the volatile script to the user.
+ ///
+ [Test]
+ public async Task PreventMove_FirstUse_AddsDiveEffectToUser()
+ {
+ // Arrange
+ var (script, move, _, userVolatile, _) = CreateTestSetup();
+ var prevent = false;
+
+ // Act
+ script.PreventMove(move, ref prevent);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// The charge turn is marked on the so the knows
+ /// this choice was the charging turn and does not remove itself for it.
+ ///
+ [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("dive_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 DiveEffect(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 lands, the Submerged state
+ /// () is removed from the user.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_AttackExecuted_RemovesDiveEffect()
+ {
+ // Arrange
+ var (script, move, user, userVolatile, _) = CreateTestSetup();
+ userVolatile.Add(new DiveEffect(user));
+ var target = Substitute.For();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "While submerged, the user is invulnerable to most moves". The
+ /// blocks incoming hits from moves that cannot hit underwater Pokémon.
+ ///
+ [Test]
+ public async Task BlockIncomingHit_MoveCannotHitUnderwater_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(new StringKey("hit_underwater")).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 Surf and Whirlpool". Moves flagged as able to hit underwater
+ /// Pokémon are not blocked by the .
+ ///
+ [Test]
+ public async Task BlockIncomingHit_SurfLikeMove_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(new StringKey("hit_underwater")).Returns(true);
+ incomingMove.UseMove.Returns(incomingMoveData);
+
+ // Act
+ var block = false;
+ effect!.BlockIncomingHit(incomingMove, user, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "can still be hit by Surf and Whirlpool, dealing twice the regular damage." A move
+ /// flagged as effective against underwater Pokémon deals doubled damage to the submerged user.
+ ///
+ [Test]
+ public async Task ChangeIncomingMoveDamage_SurfLikeMove_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(new StringKey("hit_underwater")).Returns(true);
+ incomingMoveData.HasFlag(new StringKey("effective_against_underwater")).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: only Surf and Whirlpool deal "twice the regular damage" to the submerged user. A move
+ /// without the effective-against-underwater flag deals regular damage.
+ ///
+ [Test]
+ public async Task ChangeIncomingMoveDamage_RegularMove_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(new StringKey("effective_against_underwater")).Returns(false);
+ incomingMove.UseMove.Returns(incomingMoveData);
+ uint damage = 100;
+
+ // Act
+ effect!.ChangeIncomingMoveDamage(incomingMove, user, 0, ref damage);
+
+ // Assert
+ await Assert.That(damage).IsEqualTo(100u);
+ }
+
+ ///
+ /// When the user's executed choice was not the Dive charge (i.e. the move was disrupted or replaced),
+ /// the Submerged state is removed.
+ ///
+ [Test]
+ public async Task OnAfterMoveChoice_ChoiceIsNotDiveCharge_RemovesDiveEffect()
+ {
+ // Arrange
+ var user = Substitute.For();
+ var userVolatile = new ScriptSet(user);
+ user.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ var effect = new DiveEffect(user);
+ userVolatile.Add(effect);
+ var choice = Substitute.For();
+ choice.AdditionalData.Returns((Dictionary?)null);
+
+ // Act
+ effect.OnAfterMoveChoice(choice);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// The charging turn itself carries the "dive_charge" marker; the must
+ /// survive that turn so the user stays submerged until the attack turn.
+ ///
+ [Test]
+ public async Task OnAfterMoveChoice_ChoiceIsDiveCharge_KeepsDiveEffect()
+ {
+ // Arrange
+ var user = Substitute.For();
+ var userVolatile = new ScriptSet(user);
+ user.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ var effect = new DiveEffect(user);
+ userVolatile.Add(effect);
+ var choice = Substitute.For();
+ choice.AdditionalData.Returns(new Dictionary { { "dive_charge", true } });
+
+ // Act
+ effect.OnAfterMoveChoice(choice);
+
+ // Assert
+ await Assert.That(userVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoomDesireTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoomDesireTests.cs
new file mode 100644
index 0000000..0742338
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoomDesireTests.cs
@@ -0,0 +1,199 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Dynamic.ScriptHandling.Registry;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Side;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script and its .
+/// Gen VII Bulbapedia behavior: "On the turn Doom Desire is selected, this attack will do nothing other
+/// than state that the user has chosen Doom Desire as its destiny. Two turns later, Doom Desire will do
+/// damage against the target."
+///
+public class DoomDesireTests
+{
+ ///
+ /// Creates a fully mocked test setup where the target is at the given position on a side whose
+ /// volatile scripts are a real .
+ ///
+ private static (DoomDesire script, IExecutingMove move, IPokemon target, IBattleSide side, ScriptSet sideScripts,
+ IHitData hitData) CreateTestSetup(byte position = 0, uint damage = 100)
+ {
+ var script = new DoomDesire();
+ var move = Substitute.For();
+
+ var side = Substitute.For();
+ var sideScripts = new ScriptSet(side);
+ side.VolatileScripts.Returns(sideScripts);
+ side.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ var battle = Substitute.For();
+ battle.Sides.Returns(new[] { side });
+
+ var target = Substitute.For();
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ battleData.SideIndex.Returns((byte)0);
+ battleData.Position.Returns(position);
+ target.BattleData.Returns(battleData);
+ side.Pokemon.Returns(new IPokemon?[] { target });
+
+ var hitData = Substitute.For();
+ hitData.Damage.Returns(damage);
+ move.GetHitData(target, 0).Returns(hitData);
+
+ return (script, move, target, side, sideScripts, hitData);
+ }
+
+ ///
+ /// Bulbapedia: "On the turn Doom Desire is selected, this attack will do nothing" — the immediate hit
+ /// is blocked, and the pending damage is stored as a on the target's
+ /// side.
+ ///
+ [Test]
+ public async Task BlockOutgoingHit_FirstUse_BlocksHitAndAddsSideEffect()
+ {
+ // Arrange
+ var (script, move, target, _, sideScripts, _) = CreateTestSetup();
+ var block = false;
+
+ // Act
+ script.BlockOutgoingHit(move, target, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsTrue();
+ await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// The pending hit is registered against the target's position, so the effect knows where the damage
+ /// must land two turns later.
+ ///
+ [Test]
+ public async Task BlockOutgoingHit_FirstUse_RegistersTargetPosition()
+ {
+ // Arrange
+ var (script, move, target, _, sideScripts, _) = CreateTestSetup();
+ var block = false;
+
+ // Act
+ script.BlockOutgoingHit(move, target, 0, ref block);
+
+ // Assert
+ await Assert.That(sideScripts.TryGet(out var effect)).IsTrue();
+ await Assert.That(effect!.HasTarget(0)).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: Doom Desire fails when it is already in effect against the target. The second use
+ /// fails the hit instead of queueing a second strike.
+ ///
+ [Test]
+ public async Task BlockOutgoingHit_AlreadyPendingForTarget_FailsHit()
+ {
+ // Arrange
+ var (script, move, target, _, _, hitData) = CreateTestSetup();
+ var block = false;
+ script.BlockOutgoingHit(move, target, 0, ref block);
+ block = false;
+
+ // Act
+ script.BlockOutgoingHit(move, target, 0, ref block);
+
+ // Assert
+ hitData.Received(1).Fail();
+ await Assert.That(block).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "Two turns later, Doom Desire will do damage against the target." One end-of-turn tick
+ /// after selection (the selection turn itself) is not enough for the strike to land.
+ ///
+ [Test]
+ public async Task OnEndTurn_TwoTurnTicks_DoesNotDamageTargetYet()
+ {
+ // Arrange
+ var (script, move, target, side, sideScripts, _) = CreateTestSetup();
+ var block = false;
+ script.BlockOutgoingHit(move, target, 0, ref block);
+ await Assert.That(sideScripts.TryGet(out var effect)).IsTrue();
+ var battle = Substitute.For();
+
+ // Act - end of the selection turn and of the first turn after it
+ effect!.OnEndTurn(side, battle);
+ effect.OnEndTurn(side, battle);
+
+ // Assert
+ await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "Two turns later, Doom Desire will do damage against the target." At the end of the
+ /// second turn after selection the stored damage is dealt to the Pokémon at the registered position.
+ ///
+ [Test]
+ public async Task OnEndTurn_ThreeTurnTicks_DamagesTargetWithStoredDamage()
+ {
+ // Arrange
+ var (script, move, target, side, sideScripts, _) = CreateTestSetup(damage: 123);
+ var block = false;
+ script.BlockOutgoingHit(move, target, 0, ref block);
+ await Assert.That(sideScripts.TryGet(out var effect)).IsTrue();
+ var battle = Substitute.For();
+
+ // Act - selection turn plus the two turns after it
+ effect!.OnEndTurn(side, battle);
+ effect.OnEndTurn(side, battle);
+ effect.OnEndTurn(side, battle);
+
+ // Assert
+ var damageCall = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
+ await Assert.That(damageCall).IsNotNull();
+ await Assert.That((uint)damageCall!.GetArguments()[0]!).IsEqualTo(123u);
+ }
+
+ ///
+ /// Once the pending strike has landed, the effect has no remaining targets and removes itself from
+ /// the side.
+ ///
+ [Test]
+ public async Task OnEndTurn_StrikeLanded_EffectRemovesItself()
+ {
+ // Arrange
+ var (script, move, target, side, sideScripts, _) = CreateTestSetup();
+ var block = false;
+ script.BlockOutgoingHit(move, target, 0, ref block);
+ await Assert.That(sideScripts.TryGet(out var effect)).IsTrue();
+ var battle = Substitute.For();
+
+ // Act
+ effect!.OnEndTurn(side, battle);
+ effect.OnEndTurn(side, battle);
+ effect.OnEndTurn(side, battle);
+
+ // Assert
+ await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Technical test: without battle data on the target (outside of battle) the script does nothing and
+ /// does not throw.
+ ///
+ [Test]
+ public async Task BlockOutgoingHit_TargetHasNoBattleData_DoesNotBlock()
+ {
+ // Arrange
+ var script = new DoomDesire();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ target.BattleData.Returns((IPokemonBattleData?)null);
+ var block = false;
+
+ // Act
+ script.BlockOutgoingHit(move, target, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsFalse();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoubleHitMoveTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoubleHitMoveTests.cs
new file mode 100644
index 0000000..82fdcc3
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoubleHitMoveTests.cs
@@ -0,0 +1,31 @@
+using PkmnLib.Dynamic.Models.Choices;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the script, which implements Double Hit (and the other
+/// fixed two-hit moves such as Double Kick, Dual Chop, Bonemerang and Gear Grind).
+/// Behavior is verified against the Bulbapedia page for Double Hit.
+///
+public class DoubleHitMoveTests
+{
+ ///
+ /// Bulbapedia: "Double Hit inflicts damage, hitting twice per use." The number of hits is always set
+ /// to two, regardless of the default.
+ ///
+ [Test, Arguments((byte)1), Arguments((byte)2), Arguments((byte)5)]
+ public async Task ChangeNumberOfHits_AnyInitialValue_SetsNumberOfHitsToTwo(byte initialHits)
+ {
+ // Arrange
+ var script = new DoubleHitMove();
+ var choice = Substitute.For();
+ var numberOfHits = initialHits;
+
+ // Act
+ script.ChangeNumberOfHits(choice, ref numberOfHits);
+
+ // Assert
+ await Assert.That(numberOfHits).IsEqualTo((byte)2);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoublePowerIfTargetDamagedInTurnTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoublePowerIfTargetDamagedInTurnTests.cs
new file mode 100644
index 0000000..fb26d47
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoublePowerIfTargetDamagedInTurnTests.cs
@@ -0,0 +1,218 @@
+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.Side;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the script, which implements Assurance (and
+/// the other moves whose power doubles when the target was already damaged this turn, such as Avalanche
+/// and Revenge). Behavior is verified against the Bulbapedia page for Assurance:
+/// "Assurance inflicts damage and will deal double damage if the target has already received damage on
+/// the turn it is used."
+///
+public class DoublePowerIfTargetDamagedInTurnTests
+{
+ ///
+ /// Creates a fully mocked test setup where the target sits on side 0 whose volatile scripts are a
+ /// real .
+ ///
+ private static (DoublePowerIfTargetDamagedInTurn script, IExecutingMove move, IPokemon target, ScriptSet sideScripts
+ ) CreateTestSetup()
+ {
+ var script = new DoublePowerIfTargetDamagedInTurn();
+ var move = Substitute.For();
+
+ var side = Substitute.For();
+ var sideScripts = new ScriptSet(side);
+ side.VolatileScripts.Returns(sideScripts);
+ side.GetScripts().Returns(_ => new ScriptIterator(new List>()));
+ var battle = Substitute.For();
+ battle.Sides.Returns(new[] { side });
+
+ var user = Substitute.For();
+ var userBattleData = Substitute.For();
+ userBattleData.Battle.Returns(battle);
+ user.BattleData.Returns(userBattleData);
+ move.User.Returns(user);
+
+ var target = Substitute.For();
+ var targetBattleData = Substitute.For();
+ targetBattleData.Battle.Returns(battle);
+ targetBattleData.SideIndex.Returns((byte)0);
+ target.BattleData.Returns(targetBattleData);
+
+ return (script, move, target, sideScripts);
+ }
+
+ ///
+ /// Bulbapedia: "will deal double damage if the target has already received damage on the turn it is
+ /// used." Before the turn starts, a tracking script is placed on the targeted side to record which
+ /// Pokémon take damage this turn.
+ ///
+ [Test]
+ public async Task OnBeforeTurnStart_MoveChoice_AddsTrackingDataToTargetSide()
+ {
+ // Arrange
+ var (script, move, _, sideScripts) = CreateTestSetup();
+ var user = move.User;
+ var choice = Substitute.For();
+ choice.User.Returns(user);
+ choice.TargetSide.Returns((byte)0);
+
+ // Act
+ script.OnBeforeTurnStart(choice);
+
+ // Assert
+ await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName()))
+ .IsTrue();
+ }
+
+ ///
+ /// Technical test: a non-move turn choice (e.g. a switch) does not place the tracking script.
+ ///
+ [Test]
+ public async Task OnBeforeTurnStart_NonMoveChoice_DoesNotAddTrackingData()
+ {
+ // Arrange
+ var (script, _, _, sideScripts) = CreateTestSetup();
+ var choice = Substitute.For();
+
+ // Act
+ script.OnBeforeTurnStart(choice);
+
+ // Assert
+ await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName()))
+ .IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "will deal double damage if the target has already received damage on the turn it is
+ /// used." When the tracking data recorded the target as hit this turn, the base power doubles.
+ ///
+ [Test]
+ public async Task ChangeBasePower_TargetDamagedThisTurn_BasePowerDoubles()
+ {
+ // Arrange
+ var (script, move, target, sideScripts) = CreateTestSetup();
+ var data = new DoublePowerIfTargetDamagedInTurnData();
+ sideScripts.Add(data);
+ data.OnDamage(target, DamageSource.MoveDamage, 100, 80);
+ ushort basePower = 60;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)120);
+ }
+
+ ///
+ /// Bulbapedia: the power only doubles "if the target has already received damage on the turn it is
+ /// used." An undamaged target takes regular base power.
+ ///
+ [Test]
+ public async Task ChangeBasePower_TargetNotDamagedThisTurn_BasePowerUnchanged()
+ {
+ // Arrange
+ var (script, move, target, sideScripts) = CreateTestSetup();
+ sideScripts.Add(new DoublePowerIfTargetDamagedInTurnData());
+ ushort basePower = 60;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)60);
+ }
+
+ ///
+ /// Bulbapedia: only damage to the target itself counts. Damage recorded for a different Pokémon on
+ /// the same side does not double the power against this target.
+ ///
+ [Test]
+ public async Task ChangeBasePower_OtherPokemonDamagedThisTurn_BasePowerUnchanged()
+ {
+ // Arrange
+ var (script, move, target, sideScripts) = CreateTestSetup();
+ var data = new DoublePowerIfTargetDamagedInTurnData();
+ sideScripts.Add(data);
+ data.OnDamage(Substitute.For(), DamageSource.MoveDamage, 100, 80);
+ ushort basePower = 60;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)60);
+ }
+
+ ///
+ /// Bulbapedia: "This includes self-inflicted and indirect damage, such as damage from a Life Orb,
+ /// recoil, crash damage, or hurting itself in confusion." Any damage source recorded by the tracking
+ /// data counts.
+ ///
+ [Test, Arguments(DamageSource.Misc), Arguments(DamageSource.Confusion)]
+ public async Task ChangeBasePower_TargetTookIndirectDamage_BasePowerDoubles(DamageSource source)
+ {
+ // Arrange
+ var (script, move, target, sideScripts) = CreateTestSetup();
+ var data = new DoublePowerIfTargetDamagedInTurnData();
+ sideScripts.Add(data);
+ data.OnDamage(target, source, 100, 90);
+ ushort basePower = 60;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)120);
+ }
+
+ ///
+ /// The damage tracking only spans a single turn: at the end of the turn the tracking data removes
+ /// itself, so damage from a previous turn cannot double the power.
+ ///
+ [Test]
+ public async Task OnEndTurn_TurnEnds_TrackingDataRemovesItself()
+ {
+ // Arrange
+ var (_, _, _, sideScripts) = CreateTestSetup();
+ var data = new DoublePowerIfTargetDamagedInTurnData();
+ sideScripts.Add(data);
+ var battle = Substitute.For();
+
+ // Act
+ data.OnEndTurn(Substitute.For(), battle);
+
+ // Assert
+ await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName()))
+ .IsFalse();
+ }
+
+ ///
+ /// Technical test: without battle data on the user (outside of battle) the base power is unchanged
+ /// and nothing throws.
+ ///
+ [Test]
+ public async Task ChangeBasePower_UserHasNoBattleData_BasePowerUnchanged()
+ {
+ // Arrange
+ var script = new DoublePowerIfTargetDamagedInTurn();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ user.BattleData.Returns((IPokemonBattleData?)null);
+ move.User.Returns(user);
+ var target = Substitute.For();
+ ushort basePower = 60;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)60);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DragonAscentTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DragonAscentTests.cs
new file mode 100644
index 0000000..dea7292
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DragonAscentTests.cs
@@ -0,0 +1,106 @@
+using PkmnLib.Dynamic.Events;
+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: "Dragon Ascent inflicts damage and then lowers the user's Defense stat
+/// and Special Defense stat by one stage each."
+///
+public class DragonAscentTests
+{
+ private static (DragonAscent script, IExecutingMove move, IPokemon user) CreateTestSetup()
+ {
+ var script = new DragonAscent();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ move.User.Returns(user);
+ return (script, move, user);
+ }
+
+ ///
+ /// Helper checking whether the given Pokémon received a ChangeStatBoost call for the given stat with
+ /// a one-stage self-inflicted, non-forced drop.
+ ///
+ private static bool ReceivedSelfInflictedDrop(IPokemon pokemon, Statistic stat) =>
+ pokemon.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost" &&
+ (Statistic)c.GetArguments()[0]! == stat && (sbyte)c.GetArguments()[1]! == -1 &&
+ (bool)c.GetArguments()[2]! && !(bool)c.GetArguments()[3]!);
+
+ ///
+ /// Bulbapedia: "lowers the user's Defense stat [...] by one stage". The stat change is self-inflicted
+ /// and not forced.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_MoveHits_LowersUserDefenseByOneStage()
+ {
+ // Arrange
+ var (script, move, user) = CreateTestSetup();
+ var target = Substitute.For();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(ReceivedSelfInflictedDrop(user, Statistic.Defense)).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "lowers the user's [...] Special Defense stat by one stage". The stat change is
+ /// self-inflicted and not forced.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_MoveHits_LowersUserSpecialDefenseByOneStage()
+ {
+ // Arrange
+ var (script, move, user) = CreateTestSetup();
+ var target = Substitute.For();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(ReceivedSelfInflictedDrop(user, Statistic.SpecialDefense)).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: the drop hits the user's stats, not the target's. The target's stat stages are left
+ /// untouched.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_MoveHits_DoesNotChangeTargetStats()
+ {
+ // Arrange
+ var (script, move, _) = CreateTestSetup();
+ var target = Substitute.For();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
+ }
+
+ ///
+ /// Both stat drops are reported under the same event batch, so they display as a single combined
+ /// event.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_MoveHits_BothStatDropsShareEventBatch()
+ {
+ // Arrange
+ var (script, move, user) = CreateTestSetup();
+ var target = Substitute.For();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ var batchIds = user.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost")
+ .Select(c => (EventBatchId)c.GetArguments()[4]!).Distinct().ToList();
+ await Assert.That(batchIds.Count).IsEqualTo(1);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DreamEaterTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DreamEaterTests.cs
new file mode 100644
index 0000000..e28b8df
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DreamEaterTests.cs
@@ -0,0 +1,198 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Plugin.Gen7.Scripts.Abilities;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Static.Species;
+using PkmnLib.Static.Utils;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script.
+/// Gen VII Bulbapedia behavior: "Dream Eater only works if the target is asleep; otherwise, it does
+/// nothing", and "50% of the damage dealt to the target is restored to the user as HP".
+///
+public class DreamEaterTests
+{
+ ///
+ /// Creates a fully mocked test setup for Dream Eater tests.
+ ///
+ private static (DreamEater script, IExecutingMove move, IPokemon target, IPokemon user) CreateTestSetup(uint damage,
+ bool holdsBigRoot = false, Script[]? targetScripts = null)
+ {
+ var script = new DreamEater();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var hitData = Substitute.For();
+ hitData.Damage.Returns(damage);
+ move.GetHitData(target, 0).Returns(hitData);
+
+ // RunScriptHook iterates the target's scripts; give the mock a real iterator so the
+ // hook pass runs (empty unless the test attaches scripts such as Liquid Ooze).
+ var containers = (targetScripts ?? []).Select(IEnumerable (s) => new ScriptContainer(s))
+ .ToArray();
+ target.GetScripts().Returns(_ => new ScriptIterator(containers));
+
+ var user = Substitute.For();
+ if (holdsBigRoot)
+ user.HasHeldItem("big_root").Returns(true);
+ move.User.Returns(user);
+
+ return (script, move, target, user);
+ }
+
+ ///
+ /// Helper to extract the heal amount from the user's received Heal calls.
+ ///
+ private static uint? GetHealAmount(IPokemon user)
+ {
+ var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Heal");
+ return call != null ? (uint)call.GetArguments()[0]! : null;
+ }
+
+ ///
+ /// Bulbapedia: "Dream Eater only works if the target is asleep; otherwise, it does nothing."
+ /// The outgoing hit is blocked when the target does not have the sleep status.
+ ///
+ [Test]
+ public async Task BlockOutgoingHit_TargetNotAsleep_BlocksHit()
+ {
+ // Arrange
+ var (script, move, target, _) = CreateTestSetup(100);
+ target.HasStatus(new StringKey("asleep")).Returns(false);
+ var block = false;
+
+ // Act
+ script.BlockOutgoingHit(move, target, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Dream Eater only works if the target is asleep". A sleeping target can be hit.
+ ///
+ [Test]
+ public async Task BlockOutgoingHit_TargetAsleep_DoesNotBlockHit()
+ {
+ // Arrange
+ var (script, move, target, _) = CreateTestSetup(100);
+ target.HasStatus(new StringKey("asleep")).Returns(true);
+ var block = false;
+
+ // Act
+ script.BlockOutgoingHit(move, target, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "Dream Eater also affects targets with the Comatose Ability, which act as though they
+ /// are asleep without the status condition."
+ ///
+ [Test]
+ public async Task BlockOutgoingHit_TargetHasComatose_DoesNotBlockHit()
+ {
+ // Arrange
+ var (script, move, target, _) = CreateTestSetup(100);
+ target.HasStatus(new StringKey("asleep")).Returns(false);
+ var comatose = Substitute.For();
+ comatose.Name.Returns(new StringKey("comatose"));
+ target.ActiveAbility.Returns(comatose);
+ var block = false;
+
+ // Act
+ script.BlockOutgoingHit(move, target, 0, ref block);
+
+ // Assert
+ await Assert.That(block).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "50% of the damage dealt to the target is restored to the user as HP".
+ ///
+ [Test, Arguments(100u, 50u), Arguments(200u, 100u), Arguments(51u, 25u), Arguments(99u, 49u)]
+ public async Task OnSecondaryEffect_DamageDealt_UserHealsHalfOfDamage(uint damage, uint expectedHeal)
+ {
+ // Arrange
+ var (script, move, target, user) = CreateTestSetup(damage);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(GetHealAmount(user)!.Value).IsEqualTo(expectedHeal);
+ }
+
+ ///
+ /// Bulbapedia: "if the attack deals 1 HP of damage, 1 HP will be restored to the user."
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_DamageOfOne_HealsAtLeastOneHp()
+ {
+ // Arrange
+ var (script, move, target, user) = CreateTestSetup(1);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(GetHealAmount(user)!.Value).IsEqualTo(1u);
+ }
+
+ ///
+ /// Bulbapedia: "If the user is holding a Big Root, the HP restored is increased by 30% (making the
+ /// restored HP 65% of the damage dealt)."
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_UserHoldingBigRoot_HealIncreasedByThirtyPercent()
+ {
+ // Arrange
+ var (script, move, target, user) = CreateTestSetup(100, true);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert - 65% of 100 damage
+ await Assert.That(GetHealAmount(user)!.Value).IsEqualTo(65u);
+ }
+
+ ///
+ /// Bulbapedia: "When used on a Pokémon with the Liquid Ooze Ability, the user will lose the amount of
+ /// HP it would have gained instead."
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetHasLiquidOoze_UserLosesHpInsteadOfHealing()
+ {
+ // Arrange
+ var (script, move, target, user) = CreateTestSetup(100, targetScripts: [new LiquidOoze()]);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert - user takes the 50 HP it would have gained, and is not healed
+ var damageCall = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
+ await Assert.That(damageCall).IsNotNull();
+ await Assert.That((uint)damageCall!.GetArguments()[0]!).IsEqualTo(50u);
+ await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Heal")).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "the user will lose the amount of HP it would have gained instead." The HP loss caused
+ /// by Liquid Ooze is indirect damage, not move damage.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetHasLiquidOoze_UsesMiscDamageSource()
+ {
+ // Arrange
+ var (script, move, target, user) = CreateTestSetup(100, targetScripts: [new LiquidOoze()]);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ var damageCall = user.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage");
+ await Assert.That((DamageSource)damageCall.GetArguments()[1]!).IsEqualTo(DamageSource.Misc);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Defog.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Defog.cs
index bf2970c..950ba34 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Defog.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Defog.cs
@@ -8,6 +8,7 @@ public class Defog : Script, IScriptOnSecondaryEffect
{
[PublicAPI] public static HashSet DefoggedEffects =
[
+ ScriptUtils.ResolveName(),
ScriptUtils.ResolveName(),
ScriptUtils.ResolveName(),
ScriptUtils.ResolveName(),
@@ -24,10 +25,14 @@ public class Defog : Script, IScriptOnSecondaryEffect
if (target.BattleData == null)
return;
- var targetSide = target.BattleData.Battle.Sides[target.BattleData.SideIndex];
- foreach (var effect in DefoggedEffects)
+ target.ChangeStatBoost(Statistic.Evasion, -1, false, false);
+
+ foreach (var sides in target.BattleData.Battle.Sides)
{
- targetSide.VolatileScripts.Remove(effect);
+ foreach (var effect in DefoggedEffects)
+ {
+ sides.VolatileScripts.Remove(effect);
+ }
}
}
}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Disable.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Disable.cs
index cc50e84..66bd52f 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Disable.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Disable.cs
@@ -11,12 +11,24 @@ public class Disable : Script, IScriptOnSecondaryEffect
var battleData = move.User.BattleData;
if (battleData == null)
return;
+ if (target.Volatile.Contains())
+ {
+ move.GetHitData(target, hit).Fail();
+ return;
+ }
var lastMove = target.BattleData?.LastMoveChoice;
if (lastMove == null)
{
move.GetHitData(target, hit).Fail();
return;
}
+ // If the last move was struggle, the move fails and no disable is applied.
+ if (target.Library.MiscLibrary.IsReplacementChoice(lastMove))
+ {
+ move.GetHitData(target, hit).Fail();
+ return;
+ }
+
var lastMoveName = lastMove.ChosenMove.MoveData.Name;
target.Volatile.Add(new DisableEffect(lastMoveName));
}
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/DreamEater.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/DreamEater.cs
index a627efa..c8260fb 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/DreamEater.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/DreamEater.cs
@@ -3,10 +3,13 @@ namespace PkmnLib.Plugin.Gen7.Scripts.Moves;
[Script(ScriptCategory.Move, "dream_eater")]
public class DreamEater : Script, IScriptOnSecondaryEffect, IScriptBlockOutgoingHit
{
+ private static readonly StringKey AsleepStatus = new("asleep");
+ private static readonly StringKey ComatoseAbility = new("comatose");
+
///
public void BlockOutgoingHit(IExecutingMove executingMove, IPokemon target, byte hitIndex, ref bool block)
{
- if (!target.HasStatus("asleep"))
+ if (!target.HasStatus(AsleepStatus) && target.ActiveAbility?.Name != ComatoseAbility)
block = true;
}
@@ -23,6 +26,8 @@ public class DreamEater : Script, IScriptOnSecondaryEffect, IScriptBlockOutgoing
target.RunScriptHook(x => x.CustomTrigger(CustomTriggers.ModifyDrain, args));
var invert = args.Invert;
healed = args.Healed;
+ if (healed == 0)
+ healed = 1;
if (invert)
{
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/DigEffect.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/DigEffect.cs
index 73deb6f..784afc3 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/DigEffect.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/DigEffect.cs
@@ -30,7 +30,7 @@ public class DigEffect : Script, IScriptForceTurnSelection, IScriptChangeIncomin
///
public void ChangeIncomingMoveDamage(IExecutingMove move, IPokemon target, byte hit, ref uint damage)
{
- if (!move.UseMove.HasFlag(MoveFlags.EffectiveAgainstUnderground))
+ if (move.UseMove.HasFlag(MoveFlags.EffectiveAgainstUnderground))
damage *= 2;
}
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/DiveEffect.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/DiveEffect.cs
index 483c17d..19633fd 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/DiveEffect.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/DiveEffect.cs
@@ -30,7 +30,7 @@ public class DiveEffect : Script, IScriptForceTurnSelection, IScriptChangeIncomi
///
public void ChangeIncomingMoveDamage(IExecutingMove move, IPokemon target, byte hit, ref uint damage)
{
- if (!move.UseMove.HasFlag(MoveFlags.EffectiveAgainstUnderwater))
+ if (move.UseMove.HasFlag(MoveFlags.EffectiveAgainstUnderwater))
damage *= 2;
}