using PkmnLib.Dynamic.Models; using PkmnLib.Dynamic.ScriptHandling; using PkmnLib.Dynamic.ScriptHandling.Registry; using PkmnLib.Plugin.Gen7.Scripts; using PkmnLib.Plugin.Gen7.Scripts.Moves; using PkmnLib.Plugin.Gen7.Scripts.Side; using PkmnLib.Static.Moves; using PkmnLib.Static.Utils; namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; /// /// Tests for the move script and its side effect script /// . /// Gen VII Bulbapedia behavior: Light Screen halves the damage the user's side takes from special moves /// for 5 turns; "When multiple Pokémon are present on the user's side, special damage reduces by one-third /// instead of one-half." and "If Light Clay is held when Light Screen is used, it will extend the duration /// of Light Screen from 5 to 8 turns." /// public class LightScreenTests { /// /// Test helper script that extends the Light Screen duration through the custom trigger, the same way /// the Light Clay item script does. /// [Script(ScriptCategory.Pokemon, "test_light_screen_duration_extender")] private class DurationExtender : Script, IScriptCustomTrigger { public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args) { if (eventName == CustomTriggers.LightScreenNumberOfTurns && args is CustomTriggers.LightScreenNumberOfTurnsArgs d) d.Duration = 8; } } private static (LightScreen script, IExecutingMove move, IScriptSet sideScripts) CreateMoveSetup() { var script = new LightScreen(); var move = Substitute.For(); move.GetScripts().Returns(_ => new ScriptIterator(new List>())); var sideScripts = Substitute.For(); // Mimic the real ScriptSet: invoke the factory and hand back a container holding the new script. sideScripts.StackOrAdd(Arg.Any(), Arg.Any>()) .Returns(ci => new ScriptContainer(ci.Arg>()()!)); var side = Substitute.For(); side.VolatileScripts.Returns(sideScripts); var battleData = Substitute.For(); battleData.BattleSide.Returns(side); var user = Substitute.For(); user.BattleData.Returns(battleData); move.User.Returns(user); return (script, move, sideScripts); } /// /// Helper to extract the effect script instance created through StackOrAdd on the side's volatile /// scripts. /// private static LightScreenEffect? GetAddedEffect(IScriptSet sideScripts) { var call = sideScripts.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "StackOrAdd"); if (call == null) return null; var factory = (Func)call.GetArguments()[1]!; return factory() as LightScreenEffect; } /// /// Hosts the given effect in a real and counts how many end-of-turn ticks it /// survives, up to the given maximum. /// private static int CountSurvivedEndTurns(LightScreenEffect effect, int maximum) { var owner = Substitute.For(); owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>())); IScriptSet set = new ScriptSet(owner); set.Add(effect); for (var turns = 0; turns < maximum; turns++) { effect.OnEndTurn(owner, Substitute.For()); if (!set.Contains(ScriptUtils.ResolveName())) return turns + 1; } return maximum + 1; } /// /// Creates a mocked incoming move of the given category for damage /// tests, with the defending target in a battle with the given amount of positions per side. /// private static (IExecutingMove move, IPokemon target) CreateIncomingMove(MoveCategory category, byte positionsPerSide, bool isCritical = false) { var move = Substitute.For(); var moveData = Substitute.For(); moveData.Category.Returns(category); move.UseMove.Returns(moveData); var target = Substitute.For(); var hitData = Substitute.For(); hitData.IsCritical.Returns(isCritical); move.GetHitData(target, 0).Returns(hitData); var battle = Substitute.For(); battle.PositionsPerSide.Returns(positionsPerSide); var battleData = Substitute.For(); battleData.Battle.Returns(battle); target.BattleData.Returns(battleData); return (move, target); } /// /// Using Light Screen places a on the user's side of the field. /// [Test] public async Task OnSecondaryEffect_UserInBattle_AddsLightScreenEffectToUserSide() { // Arrange var (script, move, sideScripts) = CreateMoveSetup(); // Act script.OnSecondaryEffect(move, Substitute.For(), 0); // Assert await Assert.That(GetAddedEffect(sideScripts)).IsNotNull(); } /// /// Bulbapedia (Generation II): Light Screen "remains in effect for 5 turns" — the effect placed on the /// side expires at the end of the fifth turn. /// [Test] public async Task OnSecondaryEffect_UserInBattle_EffectLastsFiveTurns() { // Arrange var (script, move, sideScripts) = CreateMoveSetup(); // Act script.OnSecondaryEffect(move, Substitute.For(), 0); // Assert var effect = GetAddedEffect(sideScripts); await Assert.That(CountSurvivedEndTurns(effect!, 10)).IsEqualTo(5); } /// /// Bulbapedia: "If Light Clay is held when Light Screen is used, it will extend the duration of Light /// Screen from 5 to 8 turns." The extension flows through the LightScreenNumberOfTurns custom trigger; /// a script that sets the duration to 8 results in an effect lasting 8 turns. /// [Test] public async Task OnSecondaryEffect_DurationExtendedByTrigger_EffectLastsEightTurns() { // Arrange var (script, move, sideScripts) = CreateMoveSetup(); move.GetScripts().Returns(_ => new ScriptIterator(new List> { new ScriptContainer(new DurationExtender()), })); // Act script.OnSecondaryEffect(move, Substitute.For(), 0); // Assert var effect = GetAddedEffect(sideScripts); await Assert.That(CountSurvivedEndTurns(effect!, 10)).IsEqualTo(8); } /// /// Technical test: outside of battle (no ) the hook does nothing. /// [Test] public async Task OnSecondaryEffect_NoBattleData_DoesNothing() { // Arrange var script = new LightScreen(); var move = Substitute.For(); var user = Substitute.For(); user.BattleData.Returns((IPokemonBattleData?)null); move.User.Returns(user); // Act script.OnSecondaryEffect(move, Substitute.For(), 0); // Assert - the script returns before running the duration trigger over the move's scripts await Assert.That(move.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "GetScripts")).IsFalse(); } /// /// Bulbapedia (Generation III): Light Screen "halves damage from special attacks" — in a single battle /// the damage of an incoming special move is halved, with integer truncation. /// [Test, Arguments(100u, 50u), Arguments(101u, 50u), Arguments(99u, 49u)] public async Task ChangeIncomingMoveDamage_SpecialMoveSingleBattle_DamageHalved(uint damage, uint expectedDamage) { // Arrange var effect = new LightScreenEffect(5); var (move, target) = CreateIncomingMove(MoveCategory.Special, 1); // Act effect.ChangeIncomingMoveDamage(move, target, 0, ref damage); // Assert await Assert.That(damage).IsEqualTo(expectedDamage); } /// /// Bulbapedia: "When multiple Pokémon are present on the user's side, special damage reduces by /// one-third instead of one-half." — with more than one position per side the damage is multiplied /// by 2/3. /// [Test, Arguments(90u, 60u), Arguments(100u, 66u)] public async Task ChangeIncomingMoveDamage_SpecialMoveMultiBattle_DamageReducedByOneThird(uint damage, uint expectedDamage) { // Arrange var effect = new LightScreenEffect(5); var (move, target) = CreateIncomingMove(MoveCategory.Special, 2); // Act effect.ChangeIncomingMoveDamage(move, target, 0, ref damage); // Assert await Assert.That(damage).IsEqualTo(expectedDamage); } /// /// Light Screen only reduces damage from special moves — physical move damage is unchanged. /// [Test] public async Task ChangeIncomingMoveDamage_PhysicalMove_DamageUnchanged() { // Arrange var effect = new LightScreenEffect(5); var (move, target) = CreateIncomingMove(MoveCategory.Physical, 1); var damage = 100u; // Act effect.ChangeIncomingMoveDamage(move, target, 0, ref damage); // Assert await Assert.That(damage).IsEqualTo(100u); } /// /// Critical hits ignore Light Screen — a critical special move deals full damage through the screen. /// [Test] public async Task ChangeIncomingMoveDamage_CriticalHit_DamageUnchanged() { // Arrange var effect = new LightScreenEffect(5); var (move, target) = CreateIncomingMove(MoveCategory.Special, 1, true); var damage = 100u; // Act effect.ChangeIncomingMoveDamage(move, target, 0, ref damage); // Assert await Assert.That(damage).IsEqualTo(100u); } }