From 2f51e278111049c9157728b450a492d2aec0c5c4 Mon Sep 17 00:00:00 2001 From: Deukhoofd Date: Sun, 5 Jul 2026 19:45:40 +0200 Subject: [PATCH] More unit tests, fixes --- .../Scripts/Moves/EchoedVoiceTests.cs | 219 +++++++++++++++++ .../Scripts/Moves/ElectricTerrainTests.cs | 66 +++++ .../Scripts/Moves/ElectrifyTests.cs | 150 ++++++++++++ .../Scripts/Moves/ElectroBallTests.cs | 88 +++++++ .../Scripts/Moves/EmbargoTests.cs | 126 ++++++++++ .../Scripts/Moves/EncoreTests.cs | 230 ++++++++++++++++++ .../Scripts/Moves/EndeavorTests.cs | 81 ++++++ .../Scripts/Moves/EndureTests.cs | 208 ++++++++++++++++ .../Scripts/Moves/EntrainmentTests.cs | 154 ++++++++++++ .../Scripts/Moves/EruptionTests.cs | 63 +++++ .../Scripts/Moves/ExplosionTests.cs | 69 ++++++ .../PkmnLib.Plugin.Gen7/Common/MoveFlags.cs | 1 + Plugins/PkmnLib.Plugin.Gen7/Data/Moves.jsonc | 17 +- .../Scripts/Moves/EchoedVoice.cs | 8 +- .../Scripts/Moves/ElectroBall.cs | 13 +- .../Scripts/Moves/Encore.cs | 4 +- .../Scripts/Pokemon/EndureEffect.cs | 2 +- .../Scripts/Side/EchoedVoiceData.cs | 19 +- 18 files changed, 1499 insertions(+), 19 deletions(-) create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EchoedVoiceTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectricTerrainTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectrifyTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ElectroBallTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EmbargoTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EncoreTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EndeavorTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EndureTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EntrainmentTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/EruptionTests.cs create mode 100644 Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/ExplosionTests.cs 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