diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EchoedVoiceTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EchoedVoiceTests.cs
new file mode 100644
index 0000000..93020d2
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EchoedVoiceTests.cs
@@ -0,0 +1,219 @@
+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;
+using PkmnLib.Static.Moves;
+using PkmnLib.Static.Utils;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script and its side marker.
+/// Gen VII Bulbapedia behavior: "The move's base power increases by 40 each consecutive turn when used
+/// repeatedly, reaching a maximum of 200. Power increases only once per round, regardless of how many
+/// Pokémon deploy it."
+///
+public class EchoedVoiceTests
+{
+ ///
+ /// 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 user's side has a real as its
+ /// volatile scripts.
+ ///
+ private static (EchoedVoice script, IExecutingMove move, IPokemon target, IScriptSet sideScripts) CreateTestSetup()
+ {
+ var script = new EchoedVoice();
+ var move = Substitute.For();
+ var target = Substitute.For();
+
+ var side = Substitute.For();
+ side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet sideScripts = new ScriptSet(side);
+ side.VolatileScripts.Returns(sideScripts);
+
+ var battle = Substitute.For();
+ battle.Sides.Returns(new[] { side });
+
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ battleData.SideIndex.Returns((byte)0);
+
+ var user = Substitute.For();
+ user.BattleData.Returns(battleData);
+ move.User.Returns(user);
+
+ return (script, move, target, sideScripts);
+ }
+
+ ///
+ /// Bulbapedia: "The move's base power increases by 40 each consecutive turn" — on the first use there
+ /// is no previous turn, so the base power is not modified.
+ ///
+ [Test]
+ public async Task ChangeBasePower_FirstUse_BasePowerUnchanged()
+ {
+ // Arrange
+ var (script, move, target, _) = CreateTestSetup();
+ ushort basePower = 40;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)40);
+ }
+
+ ///
+ /// Bulbapedia: "The move's base power increases by 40 each consecutive turn when used repeatedly,
+ /// reaching a maximum of 200." Each previous consecutive use adds a stack worth 40 power: 80 on the
+ /// second turn, 120 on the third, 160 on the fourth, and 200 on the fifth.
+ ///
+ [Test, Arguments(1, (ushort)80), Arguments(2, (ushort)120), Arguments(3, (ushort)160), Arguments(4, (ushort)200)]
+ public async Task ChangeBasePower_ConsecutiveUses_PowerIncreasesByFortyPerTurn(int previousUses,
+ ushort expectedPower)
+ {
+ // Arrange - the move hit on the given number of previous consecutive turns
+ var (script, move, target, _) = CreateTestSetup();
+ for (var i = 0; i < previousUses; i++)
+ script.OnSecondaryEffect(move, target, 0);
+ ushort basePower = 40;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo(expectedPower);
+ }
+
+ ///
+ /// Bulbapedia: the base power increases "reaching a maximum of 200" — further consecutive uses beyond
+ /// the fifth turn do not push the power past the cap.
+ ///
+ [Test]
+ public async Task ChangeBasePower_MoreThanFourPreviousUses_PowerCappedAtTwoHundred()
+ {
+ // Arrange
+ var (script, move, target, _) = CreateTestSetup();
+ for (var i = 0; i < 6; i++)
+ script.OnSecondaryEffect(move, target, 0);
+ ushort basePower = 40;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)200);
+ }
+
+ ///
+ /// Bulbapedia: the power increase applies "when used repeatedly" — using the move marks the user's side
+ /// with so the next turn's use is recognized as consecutive.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_AddsEchoedVoiceDataToUserSide()
+ {
+ // Arrange
+ var (script, move, target, sideScripts) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "The move's base power increases by 40 each consecutive turn" — each additional use
+ /// stacks the side marker, increasing .
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_UsedAgain_StacksEchoedVoiceData()
+ {
+ // Arrange
+ var (script, move, target, sideScripts) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(sideScripts.Get()!.Stacks).IsEqualTo(2);
+ }
+
+ ///
+ /// Technical test: without battle data on the user (outside of battle) the base power hook does
+ /// nothing and does not throw.
+ ///
+ [Test]
+ public async Task ChangeBasePower_UserHasNoBattleData_BasePowerUnchanged()
+ {
+ // Arrange
+ var (script, move, target, _) = CreateTestSetup();
+ move.User.BattleData.Returns((IPokemonBattleData?)null);
+ ushort basePower = 40;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)40);
+ }
+
+ ///
+ /// Bulbapedia: the boost only applies on "consecutive" turns — when a Pokémon on the side chooses a
+ /// move other than Echoed Voice, the marker removes itself, resetting
+ /// the power.
+ ///
+ [Test]
+ public async Task OnBeforeMoveChoice_DifferentMoveChosen_MarkerRemovesItself()
+ {
+ // Arrange
+ var side = Substitute.For();
+ side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet sideScripts = new ScriptSet(side);
+ var data = new EchoedVoiceData();
+ sideScripts.Add(data);
+
+ // Act
+ data.OnBeforeMoveChoice(CreateMoveChoice("tackle"));
+
+ // Assert
+ await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "The move's base power increases by 40 each consecutive turn when used repeatedly" —
+ /// choosing Echoed Voice again keeps the marker (and its stacks) alive.
+ ///
+ [Test]
+ public async Task OnBeforeMoveChoice_EchoedVoiceChosen_MarkerStaysActive()
+ {
+ // Arrange
+ var side = Substitute.For();
+ side.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet sideScripts = new ScriptSet(side);
+ var data = new EchoedVoiceData();
+ sideScripts.Add(data);
+
+ // Act
+ data.OnBeforeMoveChoice(CreateMoveChoice("echoed_voice"));
+
+ // Assert
+ await Assert.That(sideScripts.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectricTerrainTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectricTerrainTests.cs
new file mode 100644
index 0000000..c5209c8
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectricTerrainTests.cs
@@ -0,0 +1,66 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.ScriptHandling.Registry;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using ElectricTerrainScript = PkmnLib.Plugin.Gen7.Scripts.Terrain.ElectricTerrain;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script.
+/// Gen VII Bulbapedia behavior (Generations VI to VII): Electric Terrain "envelops the field and replaces
+/// the background environment and any other terrain that is already in effect."
+///
+public class ElectricTerrainTests
+{
+ private static (ElectricTerrain script, IExecutingMove move, IBattle battle) CreateTestSetup()
+ {
+ var script = new ElectricTerrain();
+ var move = Substitute.For();
+
+ var battle = Substitute.For();
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+
+ var user = Substitute.For();
+ user.BattleData.Returns(battleData);
+ move.User.Returns(user);
+
+ return (script, move, battle);
+ }
+
+ ///
+ /// Bulbapedia: Electric Terrain "envelops the field and replaces the background environment and any
+ /// other terrain that is already in effect" — the move sets the battle's terrain to the
+ /// terrain script.
+ ///
+ [Test]
+ public void OnSecondaryEffect_SetsElectricTerrainOnBattle()
+ {
+ // Arrange
+ var (script, move, battle) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, Substitute.For(), 0);
+
+ // Assert
+ battle.Received(1).SetTerrain(ScriptUtils.ResolveName());
+ }
+
+ ///
+ /// Technical test: without battle data on the user (outside of battle) the effect does nothing and
+ /// does not throw.
+ ///
+ [Test]
+ public void OnSecondaryEffect_UserHasNoBattleData_DoesNotSetTerrain()
+ {
+ // Arrange
+ var (script, move, battle) = CreateTestSetup();
+ move.User.BattleData.Returns((IPokemonBattleData?)null);
+
+ // Act
+ script.OnSecondaryEffect(move, Substitute.For(), 0);
+
+ // Assert
+ battle.DidNotReceiveWithAnyArgs().SetTerrain(default);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectrifyTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectrifyTests.cs
new file mode 100644
index 0000000..15d5fd0
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectrifyTests.cs
@@ -0,0 +1,150 @@
+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.MoveVolatile;
+using PkmnLib.Static;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script and its move volatile.
+/// Gen VII Bulbapedia behavior: "If the target has not yet used a move, Electrify makes the target's move
+/// Electric-type for that turn, even if it is a status move."
+///
+public class ElectrifyTests
+{
+ ///
+ /// Creates a fully mocked test setup where the battle's choice queue is a real
+ /// . When is false, the queue
+ /// only contains another Pokémon's choice, simulating a target that already moved this turn.
+ ///
+ private static (Electrify script, IExecutingMove move, IPokemon target, IScriptSet choiceVolatile, IHitData hitData)
+ CreateTestSetup(bool targetStillHasChoice = true, bool hasQueue = true)
+ {
+ var script = new Electrify();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var hitData = Substitute.For();
+ move.GetHitData(target, 0).Returns(hitData);
+
+ var targetChoice = Substitute.For();
+ targetChoice.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet choiceVolatile = new ScriptSet(targetChoice);
+ targetChoice.Volatile.Returns(choiceVolatile);
+ targetChoice.User.Returns(target);
+
+ var queue = new BattleChoiceQueue(targetStillHasChoice ? [targetChoice] : [Substitute.For()]);
+
+ var battle = Substitute.For();
+ battle.ChoiceQueue.Returns(hasQueue ? queue : null);
+
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ target.BattleData.Returns(battleData);
+
+ return (script, move, target, choiceVolatile, hitData);
+ }
+
+ ///
+ /// Bulbapedia: "If the target has not yet used a move, Electrify makes the target's move Electric-type
+ /// for that turn" — the is attached to the target's pending move choice.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetHasNotMovedYet_AddsElectrifyEffectToTargetChoice()
+ {
+ // Arrange
+ var (script, move, target, choiceVolatile, hitData) = CreateTestSetup();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(choiceVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ hitData.DidNotReceive().Fail();
+ }
+
+ ///
+ /// Bulbapedia: the effect only applies "if the target has not yet used a move" — when the target has
+ /// no remaining choice in the queue this turn, the move fails.
+ ///
+ [Test]
+ public void OnSecondaryEffect_TargetAlreadyMoved_FailsHit()
+ {
+ // Arrange
+ var (script, move, target, _, hitData) = CreateTestSetup(false);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ }
+
+ ///
+ /// Technical test: when the battle has no active choice queue, the script returns without throwing and
+ /// without failing the hit.
+ ///
+ [Test]
+ public void OnSecondaryEffect_NoChoiceQueue_DoesNothing()
+ {
+ // Arrange
+ var (script, move, target, _, hitData) = CreateTestSetup(hasQueue: false);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.DidNotReceive().Fail();
+ }
+
+ ///
+ /// Bulbapedia: "Electrify makes the target's move Electric-type for that turn, even if it is a status
+ /// move" — the changes the executed move's type to Electric.
+ ///
+ [Test]
+ public async Task ChangeMoveType_ChangesMoveTypeToElectric()
+ {
+ // Arrange
+ var effect = new ElectrifyEffect();
+ var library = LibraryHelpers.LoadLibrary();
+ var battle = Substitute.For();
+ battle.Library.Returns(library);
+ var battleData = Substitute.For();
+ battleData.Battle.Returns(battle);
+ var target = Substitute.For();
+ target.BattleData.Returns(battleData);
+
+ await Assert.That(library.StaticLibrary.Types.TryGetTypeIdentifier("normal", out var normal)).IsTrue();
+ await Assert.That(library.StaticLibrary.Types.TryGetTypeIdentifier("electric", out var electric)).IsTrue();
+ TypeIdentifier? moveType = normal;
+
+ // Act
+ effect.ChangeMoveType(Substitute.For(), target, 0, ref moveType);
+
+ // Assert
+ await Assert.That(moveType).IsNotNull();
+ await Assert.That(moveType!.Value).IsEqualTo(electric);
+ }
+
+ ///
+ /// Technical test: without battle data on the target the move type cannot be resolved and remains
+ /// unchanged.
+ ///
+ [Test]
+ public async Task ChangeMoveType_TargetHasNoBattleData_TypeUnchanged()
+ {
+ // Arrange
+ var effect = new ElectrifyEffect();
+ var target = Substitute.For();
+ target.BattleData.Returns((IPokemonBattleData?)null);
+ TypeIdentifier? moveType = null;
+
+ // Act
+ effect.ChangeMoveType(Substitute.For(), target, 0, ref moveType);
+
+ // Assert
+ await Assert.That(moveType).IsNull();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectroBallTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectroBallTests.cs
new file mode 100644
index 0000000..fd285e7
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectroBallTests.cs
@@ -0,0 +1,88 @@
+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: "Electro Ball inflicts more damage the faster the user is compared to the
+/// target." The power is based on the target's Speed as a percentage of the user's Speed:
+/// more than 100% (or exactly 0) → 40, 50.01%–100% → 60, 33.34%–50% → 80, 25.01%–33.33% → 120,
+/// 0.01%–25% → 150. "The Speed values used take all modifiers into account (including stat stages,
+/// paralysis, held items [...], and Abilities [...])."
+///
+public class ElectroBallTests
+{
+ private static (ElectroBall script, IExecutingMove move, IPokemon target) CreateTestSetup(uint userSpeed,
+ uint targetSpeed)
+ {
+ var script = new ElectroBall();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ user.BoostedStats.Returns(new StatisticSet(1, 1, 1, 1, 1, userSpeed));
+ move.User.Returns(user);
+ var target = Substitute.For();
+ target.BoostedStats.Returns(new StatisticSet(1, 1, 1, 1, 1, targetSpeed));
+ return (script, move, target);
+ }
+
+ ///
+ /// Bulbapedia: the power scales with the target's Speed as a percentage of the user's Speed —
+ /// more than 100% → 40, 50.01%–100% → 60, 33.34%–50% → 80, 25.01%–33.33% → 120, 0.01%–25% → 150.
+ ///
+ [Test, Arguments(100u, 150u, (ushort)40), Arguments(100u, 101u, (ushort)40), Arguments(100u, 70u, (ushort)60),
+ Arguments(100u, 40u, (ushort)80), Arguments(100u, 30u, (ushort)120), Arguments(100u, 20u, (ushort)150),
+ Arguments(500u, 100u, (ushort)150)]
+ public async Task ChangeBasePower_ScalesWithSpeedRatio(uint userSpeed, uint targetSpeed, ushort expectedPower)
+ {
+ // Arrange
+ var (script, move, target) = CreateTestSetup(userSpeed, targetSpeed);
+ ushort basePower = 40;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo(expectedPower);
+ }
+
+ ///
+ /// Bulbapedia: the power bands are inclusive of their upper boundary — a target at exactly 100% of the
+ /// user's Speed yields 60 power, exactly 50% yields 80, exactly 33.33% yields 120, and exactly 25%
+ /// yields 150.
+ ///
+ [Test, Arguments(100u, 100u, (ushort)60), Arguments(100u, 50u, (ushort)80), Arguments(300u, 100u, (ushort)120),
+ Arguments(400u, 100u, (ushort)150)]
+ public async Task ChangeBasePower_SpeedRatioAtExactBoundary_UsesHigherTier(uint userSpeed, uint targetSpeed,
+ ushort expectedPower)
+ {
+ // Arrange
+ var (script, move, target) = CreateTestSetup(userSpeed, targetSpeed);
+ ushort basePower = 40;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo(expectedPower);
+ }
+
+ ///
+ /// Bulbapedia: the power is 40 when the target's Speed is "more than 100%, or exactly 0" of the
+ /// user's Speed.
+ ///
+ [Test]
+ public async Task ChangeBasePower_TargetSpeedZero_PowerIsForty()
+ {
+ // Arrange
+ var (script, move, target) = CreateTestSetup(100, 0);
+ ushort basePower = 40;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)40);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EmbargoTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EmbargoTests.cs
new file mode 100644
index 0000000..0d3781c
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EmbargoTests.cs
@@ -0,0 +1,126 @@
+using PkmnLib.Dynamic.Models;
+using PkmnLib.Dynamic.ScriptHandling;
+using PkmnLib.Dynamic.ScriptHandling.Registry;
+using PkmnLib.Plugin.Gen7.Scripts.Moves;
+using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
+using PkmnLib.Static;
+
+namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
+
+///
+/// Tests for the move script and its .
+/// Gen VII Bulbapedia behavior: "For five turns, the target's held item has its effects negated and its
+/// Trainer cannot use items from the Bag on it."
+///
+public class EmbargoTests
+{
+ ///
+ /// Creates a target Pokémon whose volatile scripts are a real .
+ ///
+ private static (IPokemon target, IScriptSet targetVolatile) CreateTarget()
+ {
+ var target = Substitute.For();
+ target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet targetVolatile = new ScriptSet(target);
+ target.Volatile.Returns(targetVolatile);
+ return (target, targetVolatile);
+ }
+
+ ///
+ /// Bulbapedia: "For five turns, the target's held item has its effects negated" — using the move
+ /// attaches the to the target.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_AddsEmbargoEffectToTarget()
+ {
+ // Arrange
+ var script = new Embargo();
+ var move = Substitute.For();
+ var (target, targetVolatile) = CreateTarget();
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: the target's "Trainer cannot use items from the Bag on it" and its held item is
+ /// negated — the effect prevents held item consumption.
+ ///
+ [Test]
+ public async Task PreventHeldItemConsume_EffectActive_ConsumptionPrevented()
+ {
+ // Arrange
+ var effect = new EmbargoEffect();
+ var prevented = false;
+
+ // Act
+ effect.PreventHeldItemConsume(Substitute.For(), Substitute.For(), ref prevented);
+
+ // Assert
+ await Assert.That(prevented).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: the effect lasts "for five turns" — after four end-of-turn ticks it is still active.
+ ///
+ [Test]
+ public async Task OnEndTurn_FourTurnsPassed_EffectStillActive()
+ {
+ // Arrange
+ var (target, targetVolatile) = CreateTarget();
+ var effect = new EmbargoEffect();
+ targetVolatile.Add(effect);
+
+ // Act
+ for (var i = 0; i < 4; i++)
+ effect.OnEndTurn(target, Substitute.For());
+
+ // Assert
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: the effect lasts "for five turns" — after the fifth end-of-turn tick it removes itself.
+ ///
+ [Test]
+ public async Task OnEndTurn_FiveTurnsPassed_EffectRemovesItself()
+ {
+ // Arrange
+ var (target, targetVolatile) = CreateTarget();
+ var effect = new EmbargoEffect();
+ targetVolatile.Add(effect);
+
+ // Act
+ for (var i = 0; i < 5; i++)
+ effect.OnEndTurn(target, Substitute.For());
+
+ // Assert
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "For five turns" — using Embargo again on an already affected target restarts the
+ /// five turn duration (the effect stacks by resetting its counter).
+ ///
+ [Test]
+ public async Task Stack_UsedAgain_DurationResetsToFiveTurns()
+ {
+ // Arrange
+ var (target, targetVolatile) = CreateTarget();
+ var effect = new EmbargoEffect();
+ targetVolatile.Add(effect);
+ for (var i = 0; i < 3; i++)
+ effect.OnEndTurn(target, Substitute.For());
+
+ // Act - Embargo is used again, restarting the duration
+ effect.Stack();
+ for (var i = 0; i < 4; i++)
+ effect.OnEndTurn(target, Substitute.For());
+
+ // Assert - only four of the renewed five turns have passed
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EncoreTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EncoreTests.cs
new file mode 100644
index 0000000..4f9214f
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EncoreTests.cs
@@ -0,0 +1,230 @@
+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.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 and its .
+/// Gen VII Bulbapedia behavior: "Encore temporarily prevents the target from using any move except the
+/// last used move." It fails if the target hasn't used a move yet or its last move was Struggle, and
+/// from Generation V onward the duration is "exactly 3 turns".
+///
+public class EncoreTests
+{
+ ///
+ /// Creates a mocked move choice whose chosen move has the given name, optionally carrying the
+ /// flag.
+ ///
+ private static IMoveChoice CreateMoveChoice(string moveName, bool cantRepeat = false)
+ {
+ var moveData = Substitute.For();
+ moveData.Name.Returns(new StringKey(moveName));
+ moveData.HasFlag(MoveFlags.CantRepeat).Returns(cantRepeat);
+ 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 (Encore script, IExecutingMove move, IPokemon target, IScriptSet targetVolatile, IHitData hitData)
+ CreateTestSetup(string? lastUsedMove, bool lastMoveIsReplacement = false, bool lastMoveCantRepeat = false)
+ {
+ var script = new Encore();
+ var move = Substitute.For();
+
+ // 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);
+ var battle = Substitute.For();
+ battle.Library.Returns(library);
+
+ var target = Substitute.For();
+ target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet targetVolatile = new ScriptSet(target);
+ target.Volatile.Returns(targetVolatile);
+ 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, lastMoveCantRepeat);
+ targetBattleData.LastMoveChoice.Returns(lastMoveChoice);
+ targetBattleData.Battle.Returns(battle);
+ target.BattleData.Returns(targetBattleData);
+ if (lastMoveChoice != null)
+ miscLibrary.IsReplacementChoice(lastMoveChoice).Returns(lastMoveIsReplacement);
+
+ var hitData = Substitute.For();
+ move.GetHitData(target, 0).Returns(hitData);
+
+ return (script, move, target, targetVolatile, hitData);
+ }
+
+ ///
+ /// Bulbapedia: "Encore temporarily prevents the target from using any move except the last used
+ /// move." The target gains the volatile script.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetUsedMove_AddsEncoreEffectToTarget()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile, hitData) = CreateTestSetup("tackle");
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ hitData.DidNotReceive().Fail();
+ }
+
+ ///
+ /// Bulbapedia: "It also fails if the foe hasn't used a move yet." Without a last move choice on the
+ /// target, the hit fails and no encore 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: Encore fails if the target's last move was Struggle. A last move that is a replacement
+ /// choice (per ) cannot be encored.
+ ///
+ [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();
+ }
+
+ ///
+ /// Bulbapedia: Encore fails "if the opponent's last move was Transform, Mimic, Sketch, Mirror Move,
+ /// Encore itself, Struggle, or a move with no PP remaining." These moves carry the
+ /// flag in the move data, which makes Encore fail.
+ ///
+ [Test, Arguments("encore"), Arguments("transform"), Arguments("mimic"), Arguments("sketch"),
+ Arguments("mirror_move")]
+ public async Task OnSecondaryEffect_TargetLastUsedUnrepeatableMove_FailsHit(string lastUsedMove)
+ {
+ // Arrange
+ var (script, move, target, targetVolatile, hitData) = CreateTestSetup(lastUsedMove, lastMoveCantRepeat: true);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: Encore fails "if the opponent's last move was Transform, Mimic, Sketch, Mirror Move,
+ /// Encore itself" — integration check: the real Gen7 move data marks exactly these moves with the
+ /// flag.
+ ///
+ [Test, Arguments("encore"), Arguments("transform"), Arguments("mimic"), Arguments("sketch"),
+ Arguments("mirror_move")]
+ public async Task MoveData_UnrepeatableMove_HasCantRepeatFlag(string moveName)
+ {
+ // Arrange
+ var library = LibraryHelpers.LoadLibrary();
+
+ // Act
+ var found = library.StaticLibrary.Moves.TryGet(new StringKey(moveName), out var moveData);
+
+ // Assert
+ await Assert.That(found).IsTrue();
+ await Assert.That(moveData!.HasFlag(MoveFlags.CantRepeat)).IsTrue();
+ }
+
+ ///
+ /// Technical test: without battle data on the target (outside of battle) the effect does nothing and
+ /// does not throw.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_TargetHasNoBattleData_DoesNothing()
+ {
+ // Arrange
+ var (script, move, target, targetVolatile, hitData) = CreateTestSetup("tackle");
+ target.BattleData.Returns((IPokemonBattleData?)null);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.DidNotReceive().Fail();
+ await Assert.That(targetVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "Duration standardized to exactly 3 turns" (Generation V onward). After two
+ /// end-of-turn ticks the effect is still active.
+ ///
+ [Test]
+ public async Task OnEndTurn_TwoTurnsPassed_EffectStillActive()
+ {
+ // Arrange
+ var owner = Substitute.For();
+ owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet ownerVolatile = new ScriptSet(owner);
+ var effect = new EncoreEffect(owner, new StringKey("tackle"), 3);
+ ownerVolatile.Add(effect);
+
+ // Act
+ for (var i = 0; i < 2; i++)
+ effect.OnEndTurn(owner, Substitute.For());
+
+ // Assert
+ await Assert.That(ownerVolatile.Contains(ScriptUtils.ResolveName())).IsTrue();
+ }
+
+ ///
+ /// Bulbapedia: "Duration standardized to exactly 3 turns" (Generation V onward). After the third
+ /// end-of-turn tick the effect removes itself.
+ ///
+ [Test]
+ public async Task OnEndTurn_ThreeTurnsPassed_EffectRemovesItself()
+ {
+ // Arrange
+ var owner = Substitute.For();
+ owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet ownerVolatile = new ScriptSet(owner);
+ var effect = new EncoreEffect(owner, new StringKey("tackle"), 3);
+ ownerVolatile.Add(effect);
+
+ // Act
+ for (var i = 0; i < 3; i++)
+ effect.OnEndTurn(owner, Substitute.For());
+
+ // Assert
+ await Assert.That(ownerVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EndeavorTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EndeavorTests.cs
new file mode 100644
index 0000000..eca3931
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EndeavorTests.cs
@@ -0,0 +1,81 @@
+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: "Endeavor deals damage to the target equal to the target's remaining HP
+/// minus the user's remaining HP, making them both equal. It has no effect if the target's HP is already
+/// equal to or lower than the user's HP."
+///
+public class EndeavorTests
+{
+ private static (Endeavor script, IExecutingMove move, IPokemon target) CreateTestSetup(uint userHealth,
+ uint targetHealth)
+ {
+ var script = new Endeavor();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ user.CurrentHealth.Returns(userHealth);
+ move.User.Returns(user);
+ var target = Substitute.For();
+ target.CurrentHealth.Returns(targetHealth);
+ return (script, move, target);
+ }
+
+ ///
+ /// Bulbapedia: "Endeavor deals damage to the target equal to the target's remaining HP minus the
+ /// user's remaining HP, making them both equal."
+ ///
+ [Test, Arguments(1u, 100u, 99u), Arguments(50u, 100u, 50u), Arguments(99u, 100u, 1u), Arguments(10u, 250u, 240u)]
+ public async Task ChangeMoveDamage_UserHpBelowTarget_DamageEqualsHpDifference(uint userHealth, uint targetHealth,
+ uint expectedDamage)
+ {
+ // Arrange
+ var (script, move, target) = CreateTestSetup(userHealth, targetHealth);
+ var damage = 0u;
+
+ // Act
+ script.ChangeMoveDamage(move, target, 0, ref damage);
+
+ // Assert
+ await Assert.That(damage).IsEqualTo(expectedDamage);
+ }
+
+ ///
+ /// Bulbapedia: "It has no effect if the target's HP is already equal to or lower than the user's HP."
+ /// With equal HP the script does not set any damage.
+ ///
+ [Test]
+ public async Task ChangeMoveDamage_UserHpEqualToTarget_DamageUnchanged()
+ {
+ // Arrange
+ var (script, move, target) = CreateTestSetup(100, 100);
+ var damage = 0u;
+
+ // Act
+ script.ChangeMoveDamage(move, target, 0, ref damage);
+
+ // Assert
+ await Assert.That(damage).IsEqualTo(0u);
+ }
+
+ ///
+ /// Bulbapedia: "It has no effect if the target's HP is already equal to or lower than the user's HP."
+ /// With the target below the user, the damage is not modified (and does not underflow).
+ ///
+ [Test]
+ public async Task ChangeMoveDamage_UserHpAboveTarget_DamageUnchanged()
+ {
+ // Arrange
+ var (script, move, target) = CreateTestSetup(100, 50);
+ var damage = 0u;
+
+ // Act
+ script.ChangeMoveDamage(move, target, 0, ref damage);
+
+ // Assert
+ await Assert.That(damage).IsEqualTo(0u);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EndureTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EndureTests.cs
new file mode 100644
index 0000000..691033d
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EndureTests.cs
@@ -0,0 +1,208 @@
+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 and its .
+/// Gen VII Bulbapedia behavior: "For the rest of the turn, Endure allows the user to survive any attack
+/// that would cause it to faint, leaving the user with 1 HP instead." Like other protection moves, "if it
+/// or other protection moves are used consecutively, its success rate decreases."
+///
+public class EndureTests
+{
+ ///
+ /// Creates a fully mocked setup for driving 's inherited
+ /// . The target of the secondary effect is the
+ /// Pokémon using Endure itself, as the move is self-targeted.
+ ///
+ private static (Endure script, IExecutingMove move, IPokemon target, IHitData hitData, IScriptSet volatileSet)
+ CreateProtectSetup(bool userMovesLast, float randomRoll)
+ {
+ var script = new Endure();
+ 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);
+
+ target.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet volatileSet = new ScriptSet(target);
+ target.Volatile.Returns(volatileSet);
+
+ return (script, move, target, hitData, volatileSet);
+ }
+
+ ///
+ /// Bulbapedia: "For the rest of the turn, Endure allows the user to survive any attack that would
+ /// cause it to faint" — using the move attaches the to the user.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_FirstUse_AddsEndureEffect()
+ {
+ // Arrange
+ var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.5f);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName())).IsTrue();
+ hitData.DidNotReceive().Fail();
+ }
+
+ ///
+ /// Bulbapedia: Endure "always fails if the user is the last to act in the turn" — with no remaining
+ /// choices in the queue there is nothing left to protect against, and the move fails.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_UserMovesLast_FailsHit()
+ {
+ // 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.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "If it or other protection moves are used consecutively, its success rate decreases."
+ /// On the second consecutive use the success chance is 1/3, so a roll above that fails.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_SecondConsecutiveUseWithHighRoll_FailsHit()
+ {
+ // 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.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+
+ ///
+ /// Bulbapedia: "If it or other protection moves are used consecutively, its success rate decreases."
+ /// On the second consecutive use the success chance is 1/3, so a roll below that still succeeds.
+ ///
+ [Test]
+ public async Task OnSecondaryEffect_SecondConsecutiveUseWithLowRoll_AddsEndureEffect()
+ {
+ // Arrange
+ var (script, move, target, hitData, volatileSet) = CreateProtectSetup(false, 0.2f);
+ volatileSet.Add(new ProtectionFailureScript { ProtectTurns = 1 });
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ await Assert.That(volatileSet.Contains(ScriptUtils.ResolveName())).IsTrue();
+ hitData.DidNotReceive().Fail();
+ }
+
+ ///
+ /// Bulbapedia: "Endure allows the user to survive any attack that would cause it to faint, leaving
+ /// the user with 1 HP instead." Damage exceeding the user's current HP is reduced to leave 1 HP.
+ ///
+ [Test, Arguments(100u, 150u, 99u), Arguments(50u, 51u, 49u), Arguments(2u, 9999u, 1u)]
+ public async Task ChangeIncomingDamage_LethalDamage_LeavesUserWithOneHp(uint currentHealth, uint incomingDamage,
+ uint expectedDamage)
+ {
+ // Arrange
+ var effect = new EndureEffect();
+ var pokemon = Substitute.For();
+ pokemon.CurrentHealth.Returns(currentHealth);
+ var damage = incomingDamage;
+
+ // Act
+ effect.ChangeIncomingDamage(pokemon, DamageSource.MoveDamage, ref damage);
+
+ // Assert
+ await Assert.That(damage).IsEqualTo(expectedDamage);
+ }
+
+ ///
+ /// Bulbapedia: Endure lets the user "survive any attack that would cause it to faint" — a hit for
+ /// exactly the user's current HP would faint it, so it too must be reduced to leave 1 HP.
+ ///
+ [Test]
+ public async Task ChangeIncomingDamage_DamageEqualToCurrentHp_LeavesUserWithOneHp()
+ {
+ // Arrange
+ var effect = new EndureEffect();
+ var pokemon = Substitute.For();
+ pokemon.CurrentHealth.Returns(100u);
+ var damage = 100u;
+
+ // Act
+ effect.ChangeIncomingDamage(pokemon, DamageSource.MoveDamage, ref damage);
+
+ // Assert
+ await Assert.That(damage).IsEqualTo(99u);
+ }
+
+ ///
+ /// Bulbapedia: Endure only affects attacks "that would cause it to faint" — non-lethal damage passes
+ /// through unchanged.
+ ///
+ [Test]
+ public async Task ChangeIncomingDamage_NonLethalDamage_DamageUnchanged()
+ {
+ // Arrange
+ var effect = new EndureEffect();
+ var pokemon = Substitute.For();
+ pokemon.CurrentHealth.Returns(100u);
+ var damage = 50u;
+
+ // Act
+ effect.ChangeIncomingDamage(pokemon, DamageSource.MoveDamage, ref damage);
+
+ // Assert
+ await Assert.That(damage).IsEqualTo(50u);
+ }
+
+ ///
+ /// Bulbapedia: the protection lasts "for the rest of the turn" — the effect removes itself at the end
+ /// of the turn.
+ ///
+ [Test]
+ public async Task OnEndTurn_EffectRemovesItself()
+ {
+ // Arrange
+ var owner = Substitute.For();
+ owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>()));
+ IScriptSet ownerVolatile = new ScriptSet(owner);
+ var effect = new EndureEffect();
+ ownerVolatile.Add(effect);
+
+ // Act
+ effect.OnEndTurn(owner, Substitute.For());
+
+ // Assert
+ await Assert.That(ownerVolatile.Contains(ScriptUtils.ResolveName())).IsFalse();
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EntrainmentTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EntrainmentTests.cs
new file mode 100644
index 0000000..8c1ad55
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EntrainmentTests.cs
@@ -0,0 +1,154 @@
+using PkmnLib.Dynamic.Models;
+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: "Entrainment changes the target's Ability to match the user's." It fails
+/// if both Pokémon already share the same Ability, if the target's Ability cannot be changed (e.g.
+/// Truant, Multitype, Stance Change, Comatose, Disguise), or if the user's Ability cannot be copied
+/// (e.g. Trace, Illusion, Imposter, Power Construct).
+///
+public class EntrainmentTests
+{
+ ///
+ /// Creates a mocked ability that reports the given flags as set.
+ ///
+ private static IAbility CreateAbility(params string[] flags)
+ {
+ var ability = Substitute.For();
+ foreach (var flag in flags)
+ ability.HasFlag(new StringKey(flag)).Returns(true);
+ return ability;
+ }
+
+ private static (Entrainment script, IExecutingMove move, IPokemon target, IHitData hitData) CreateTestSetup(
+ IAbility? userAbility, IAbility? targetAbility)
+ {
+ var script = new Entrainment();
+ var move = Substitute.For();
+ var target = Substitute.For();
+ var hitData = Substitute.For();
+ move.GetHitData(target, 0).Returns(hitData);
+
+ var user = Substitute.For();
+ user.ActiveAbility.Returns(userAbility);
+ move.User.Returns(user);
+ target.ActiveAbility.Returns(targetAbility);
+
+ return (script, move, target, hitData);
+ }
+
+ ///
+ /// Bulbapedia: "Entrainment changes the target's Ability to match the user's."
+ ///
+ [Test]
+ public void OnSecondaryEffect_DifferentAbilities_ChangesTargetAbilityToUsers()
+ {
+ // Arrange
+ var userAbility = CreateAbility();
+ var (script, move, target, hitData) = CreateTestSetup(userAbility, CreateAbility());
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ target.Received(1).ChangeAbility(userAbility);
+ hitData.DidNotReceive().Fail();
+ }
+
+ ///
+ /// Bulbapedia: "The move fails if both Pokémon already share the same Ability."
+ ///
+ [Test]
+ public void OnSecondaryEffect_SameAbility_FailsHit()
+ {
+ // Arrange
+ var sharedAbility = CreateAbility();
+ var (script, move, target, hitData) = CreateTestSetup(sharedAbility, sharedAbility);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ target.DidNotReceiveWithAnyArgs().ChangeAbility(default!);
+ }
+
+ ///
+ /// Bulbapedia: "The move fails if the user has: Trace, Forecast, Flower Gift, Zen Mode, Illusion,
+ /// Imposter, [...]" — abilities that cannot be copied prevent Entrainment from working.
+ ///
+ [Test]
+ public void OnSecondaryEffect_UserAbilityCantBeCopied_FailsHit()
+ {
+ // Arrange
+ var userAbility = CreateAbility("cant_be_copied");
+ var (script, move, target, hitData) = CreateTestSetup(userAbility, CreateAbility());
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ target.DidNotReceiveWithAnyArgs().ChangeAbility(default!);
+ }
+
+ ///
+ /// Bulbapedia: "The move cannot affect Pokémon with these Abilities: Truant, Multitype, Stance
+ /// Change, [...]" — a target whose ability cannot be changed prevents Entrainment from working.
+ ///
+ [Test]
+ public void OnSecondaryEffect_TargetAbilityCantBeChanged_FailsHit()
+ {
+ // Arrange
+ var targetAbility = CreateAbility("cant_be_changed");
+ var (script, move, target, hitData) = CreateTestSetup(CreateAbility(), targetAbility);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ target.DidNotReceiveWithAnyArgs().ChangeAbility(default!);
+ }
+
+ ///
+ /// Bulbapedia: "Entrainment changes the target's Ability to match the user's" — without a user
+ /// ability there is nothing to copy, so the move fails.
+ ///
+ [Test]
+ public void OnSecondaryEffect_UserHasNoAbility_FailsHit()
+ {
+ // Arrange
+ var (script, move, target, hitData) = CreateTestSetup(null, CreateAbility());
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ target.DidNotReceiveWithAnyArgs().ChangeAbility(default!);
+ }
+
+ ///
+ /// Technical test: a target without an active ability cannot have it validated for replacement, so
+ /// the move fails rather than throwing.
+ ///
+ [Test]
+ public void OnSecondaryEffect_TargetHasNoAbility_FailsHit()
+ {
+ // Arrange
+ var (script, move, target, hitData) = CreateTestSetup(CreateAbility(), null);
+
+ // Act
+ script.OnSecondaryEffect(move, target, 0);
+
+ // Assert
+ hitData.Received(1).Fail();
+ target.DidNotReceiveWithAnyArgs().ChangeAbility(default!);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EruptionTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EruptionTests.cs
new file mode 100644
index 0000000..e6b3e6a
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EruptionTests.cs
@@ -0,0 +1,63 @@
+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: "Eruption deals damage. It has 150 base power at maximum HP, but its base
+/// power decreases linearly with the user's remaining HP", following ⌊150 × HPcurrent / HPmax⌋ with a
+/// minimum of 1.
+///
+public class EruptionTests
+{
+ private static (Eruption script, IExecutingMove move, IPokemon target) CreateTestSetup(uint currentHealth,
+ uint maxHealth)
+ {
+ var script = new Eruption();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ user.CurrentHealth.Returns(currentHealth);
+ user.BoostedStats.Returns(new StatisticSet(maxHealth, 1, 1, 1, 1, 1));
+ move.User.Returns(user);
+ var target = Substitute.For();
+ return (script, move, target);
+ }
+
+ ///
+ /// Bulbapedia: "It has 150 base power at maximum HP, but its base power decreases linearly with the
+ /// user's remaining HP" — power = ⌊150 × HPcurrent / HPmax⌋, with integer truncation.
+ ///
+ [Test, Arguments(100u, 100u, (ushort)150), Arguments(300u, 300u, (ushort)150), Arguments(50u, 100u, (ushort)75),
+ Arguments(75u, 100u, (ushort)112), Arguments(33u, 100u, (ushort)49), Arguments(150u, 300u, (ushort)75)]
+ public async Task ChangeBasePower_ScalesWithUserCurrentHp(uint currentHealth, uint maxHealth, ushort expectedPower)
+ {
+ // Arrange
+ var (script, move, target) = CreateTestSetup(currentHealth, maxHealth);
+ ushort basePower = 150;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo(expectedPower);
+ }
+
+ ///
+ /// Bulbapedia: "if this calculation yields a value below 1, the move's power becomes 1 instead."
+ ///
+ [Test, Arguments(1u, 300u), Arguments(1u, 151u)]
+ public async Task ChangeBasePower_UserAtVeryLowHp_PowerIsAtLeastOne(uint currentHealth, uint maxHealth)
+ {
+ // Arrange
+ var (script, move, target) = CreateTestSetup(currentHealth, maxHealth);
+ ushort basePower = 150;
+
+ // Act
+ script.ChangeBasePower(move, target, 0, ref basePower);
+
+ // Assert
+ await Assert.That(basePower).IsEqualTo((ushort)1);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ExplosionTests.cs b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ExplosionTests.cs
new file mode 100644
index 0000000..5bb82e8
--- /dev/null
+++ b/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ExplosionTests.cs
@@ -0,0 +1,69 @@
+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: "the user faints upon using this move", and from Generation VI onwards
+/// "Explosion returns to damaging the target before the user faints."
+///
+public class ExplosionTests
+{
+ private static (Explosion script, IExecutingMove move, IPokemon user) CreateTestSetup(uint currentHealth)
+ {
+ var script = new Explosion();
+ var move = Substitute.For();
+ var user = Substitute.For();
+ user.CurrentHealth.Returns(currentHealth);
+ move.User.Returns(user);
+ return (script, move, user);
+ }
+
+ ///
+ /// Helper to extract the damage amount from the user's received Damage calls.
+ ///
+ private static uint? GetDamageAmount(IPokemon user)
+ {
+ var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
+ return call != null ? (uint)call.GetArguments()[0]! : null;
+ }
+
+ ///
+ /// Bulbapedia: "the user faints upon using this move" — after the hits are dealt, the user takes at
+ /// least its full current HP in damage, guaranteeing it faints.
+ ///
+ [Test]
+ public async Task OnAfterHits_UserTakesAtLeastItsCurrentHealthInDamage()
+ {
+ // Arrange
+ var (script, move, user) = CreateTestSetup(100);
+
+ // Act
+ script.OnAfterHits(move, Substitute.For());
+
+ // Assert
+ var damage = GetDamageAmount(user);
+ await Assert.That(damage).IsNotNull();
+ await Assert.That(damage!.Value).IsGreaterThanOrEqualTo(100u);
+ }
+
+ ///
+ /// Bulbapedia: "Sturdy, Focus Band, and Focus Sash do not prevent user fainting." The self-inflicted
+ /// faint is indirect damage (), not move damage, so hold-on effects
+ /// that watch for move damage do not apply.
+ ///
+ [Test]
+ public async Task OnAfterHits_UsesMiscDamageSource()
+ {
+ // Arrange
+ var (script, move, user) = CreateTestSetup(100);
+
+ // Act
+ script.OnAfterHits(move, Substitute.For());
+
+ // Assert
+ var call = user.ReceivedCalls().First(c => c.GetMethodInfo().Name == "Damage");
+ await Assert.That((DamageSource)call.GetArguments()[1]!).IsEqualTo(DamageSource.Misc);
+ }
+}
\ No newline at end of file
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Common/MoveFlags.cs b/Plugins/PkmnLib.Plugin.Gen7/Common/MoveFlags.cs
index cd1c3f3..d5b78fa 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Common/MoveFlags.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Common/MoveFlags.cs
@@ -4,6 +4,7 @@ public static class MoveFlags
{
public static readonly StringKey Ballistics = "ballistics";
public static readonly StringKey Bite = "bite";
+ public static readonly StringKey CantRepeat = "cant_repeat";
public static readonly StringKey Charge = "charge";
public static readonly StringKey Contact = "contact";
public static readonly StringKey Dance = "dance";
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Data/Moves.jsonc b/Plugins/PkmnLib.Plugin.Gen7/Data/Moves.jsonc
index 8441cd8..22697e8 100755
--- a/Plugins/PkmnLib.Plugin.Gen7/Data/Moves.jsonc
+++ b/Plugins/PkmnLib.Plugin.Gen7/Data/Moves.jsonc
@@ -3196,7 +3196,8 @@
"mirror",
"ignore_substitute",
"mental",
- "limit_move_choice"
+ "limit_move_choice",
+ "cant_repeat"
],
"effect": {
"name": "encore"
@@ -6968,7 +6969,8 @@
"category": "status",
"flags": [
"protect",
- "ignore_substitute"
+ "ignore_substitute",
+ "cant_repeat"
],
"effect": {
"name": "mimic"
@@ -7054,7 +7056,9 @@
"priority": 0,
"target": "Any",
"category": "status",
- "flags": [],
+ "flags": [
+ "cant_repeat"
+ ],
"effect": {
"name": "mirror_move"
}
@@ -10033,7 +10037,8 @@
"target": "Any",
"category": "status",
"flags": [
- "ignore_substitute"
+ "ignore_substitute",
+ "cant_repeat"
],
"effect": {
"name": "sketch"
@@ -12100,7 +12105,9 @@
"priority": 0,
"target": "Any",
"category": "status",
- "flags": [],
+ "flags": [
+ "cant_repeat"
+ ],
"effect": {
"name": "transform"
}
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/EchoedVoice.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/EchoedVoice.cs
index 269947f..7f1b6d4 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/EchoedVoice.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/EchoedVoice.cs
@@ -3,10 +3,9 @@ using PkmnLib.Plugin.Gen7.Scripts.Side;
namespace PkmnLib.Plugin.Gen7.Scripts.Moves;
[Script(ScriptCategory.Move, "echoed_voice")]
-public class EchoedVoice : Script, IScriptOnSecondaryEffect, IScriptChangeDamageModifier
+public class EchoedVoice : Script, IScriptOnSecondaryEffect, IScriptChangeBasePower
{
- ///
- public void ChangeDamageModifier(IExecutingMove move, IPokemon target, byte hit, ref float modifier)
+ public void ChangeBasePower(IExecutingMove move, IPokemon target, byte hit, ref ushort basePower)
{
var battleData = move.User.BattleData;
if (battleData == null)
@@ -16,7 +15,8 @@ public class EchoedVoice : Script, IScriptOnSecondaryEffect, IScriptChangeDamage
if (echoedVoiceData == null)
return;
- modifier *= 2;
+ var newBasePower = basePower + (ushort)(echoedVoiceData.Stacks * 40);
+ basePower = (ushort)Math.Min(newBasePower, 200);
}
///
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/ElectroBall.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/ElectroBall.cs
index 050e5a8..037548b 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/ElectroBall.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/ElectroBall.cs
@@ -9,14 +9,19 @@ public class ElectroBall : Script, IScriptChangeBasePower
var user = move.User;
var targetSpeed = target.BoostedStats.Speed;
var userSpeed = user.BoostedStats.Speed;
+ if (targetSpeed == 0)
+ {
+ basePower = 40;
+ return;
+ }
var ratio = (float)userSpeed / targetSpeed;
basePower = ratio switch
{
- > 4 => 150,
- > 3 => 120,
- > 2 => 80,
- > 1 => 60,
+ >= 4 => 150,
+ >= 3 => 120,
+ >= 2 => 80,
+ >= 1 => 60,
_ => 40,
};
}
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Encore.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Encore.cs
index 57af690..7f559d5 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Encore.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Moves/Encore.cs
@@ -1,3 +1,4 @@
+using System.Collections.Frozen;
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
namespace PkmnLib.Plugin.Gen7.Scripts.Moves;
@@ -13,7 +14,8 @@ public class Encore : Script, IScriptOnSecondaryEffect
return;
var lastMove = target.BattleData?.LastMoveChoice;
- if (lastMove == null || battle.Library.MiscLibrary.IsReplacementChoice(lastMove))
+ if (lastMove == null || battle.Library.MiscLibrary.IsReplacementChoice(lastMove) ||
+ lastMove.ChosenMove.MoveData.HasFlag(MoveFlags.CantRepeat))
{
move.GetHitData(target, hit).Fail();
return;
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/EndureEffect.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/EndureEffect.cs
index c73e849..fc7a5f5 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/EndureEffect.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Pokemon/EndureEffect.cs
@@ -6,7 +6,7 @@ public class EndureEffect : Script, IScriptOnEndTurn, IScriptChangeIncomingDamag
///
public void ChangeIncomingDamage(IPokemon pokemon, DamageSource source, ref uint damage)
{
- if (damage > pokemon.CurrentHealth)
+ if (damage >= pokemon.CurrentHealth)
damage = pokemon.CurrentHealth - 1;
}
diff --git a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Side/EchoedVoiceData.cs b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Side/EchoedVoiceData.cs
index bdad904..b0afc66 100644
--- a/Plugins/PkmnLib.Plugin.Gen7/Scripts/Side/EchoedVoiceData.cs
+++ b/Plugins/PkmnLib.Plugin.Gen7/Scripts/Side/EchoedVoiceData.cs
@@ -4,11 +4,22 @@ namespace PkmnLib.Plugin.Gen7.Scripts.Side;
/// Just here to indicate that a Pokemon on this side has used Echoed Voice.
///
[Script(ScriptCategory.Side, "echoed_voice_data")]
-public class EchoedVoiceData : Script, IScriptOnEndTurn
+public class EchoedVoiceData : Script, IScriptOnBeforeMoveChoice, IScriptStack
{
- ///
- public void OnEndTurn(IScriptSource owner, IBattle battle)
+ private int _stacks = 1;
+
+ public int Stacks => _stacks;
+
+ public void OnBeforeMoveChoice(IMoveChoice moveChoice)
{
- RemoveSelf();
+ if (moveChoice.ChosenMove.MoveData.Name != "echoed_voice")
+ {
+ RemoveSelf();
+ }
+ }
+
+ public void Stack()
+ {
+ _stacks++;
}
}
\ No newline at end of file