From 94ddf638619c26c2ce70f77e9f0aa309532309c5 Mon Sep 17 00:00:00 2001 From: Deukhoofd Date: Sun, 5 Jul 2026 19:02:00 +0200 Subject: [PATCH] More unit tests, more fixes --- .../Scripts/Moves/DarkestLariatTests.cs | 52 ++++ .../Scripts/Moves/DefogTests.cs | 164 ++++++++++ .../Scripts/Moves/DestinyBondTests.cs | 173 +++++++++++ .../Scripts/Moves/DigTests.cs | 290 ++++++++++++++++++ .../Scripts/Moves/DisableTests.cs | 247 +++++++++++++++ .../Scripts/Moves/DiveTests.cs | 288 +++++++++++++++++ .../Scripts/Moves/DoomDesireTests.cs | 199 ++++++++++++ .../Scripts/Moves/DoubleHitMoveTests.cs | 31 ++ .../DoublePowerIfTargetDamagedInTurnTests.cs | 218 +++++++++++++ .../Scripts/Moves/DragonAscentTests.cs | 106 +++++++ .../Scripts/Moves/DreamEaterTests.cs | 198 ++++++++++++ .../Scripts/Moves/Defog.cs | 11 +- .../Scripts/Moves/Disable.cs | 12 + .../Scripts/Moves/DreamEater.cs | 7 +- .../Scripts/Pokemon/DigEffect.cs | 2 +- .../Scripts/Pokemon/DiveEffect.cs | 2 +- 16 files changed, 1994 insertions(+), 6 deletions(-) create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DarkestLariatTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DefogTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DestinyBondTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DigTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DisableTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DiveTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoomDesireTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoubleHitMoveTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DoublePowerIfTargetDamagedInTurnTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DragonAscentTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DreamEaterTests.cs 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; }