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.Battle; using PkmnLib.Plugin.Gen7.Scripts.Moves; using PkmnLib.Plugin.Gen7.Scripts.Side; using PkmnLib.Static.Moves; namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves; /// /// Tests for the move script and its . /// Gen VII Bulbapedia behavior: "On the turn Future Sight is selected, this attack will do nothing other /// than say that the user has foreseen an attack. Two turns later, Future Sight will do damage against the /// target", where (from Generation V onwards) the damage calculation occurs "when it hits rather than when /// it is selected". /// public class FutureSightTests { /// /// Creates a fully mocked test setup for the script where the battle's /// volatile scripts are a real . /// private static (FutureSight script, IExecutingMove move, IBattlePokemon user, IScriptSet battleVolatile) CreateScriptSetup() { var script = new FutureSight(); var move = Substitute.For(); var battle = Substitute.For(); battle.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>())); IScriptSet battleVolatile = new ScriptSet(battle); battle.Volatile.Returns(battleVolatile); var user = Substitute.For(); move.User.Returns(user); user.Battle.Returns(battle); var moveChoice = Substitute.For(); move.MoveChoice.Returns(moveChoice); return (script, move, user, battleVolatile); } /// /// Creates a fully mocked test setup for the , with a target at side 1, /// position 0 and a damage calculator that computes the given damage when the delayed strike lands. /// private static (FutureSightEffect effect, IBattle battle, IBattlePokemon target, EventHook eventHook) CreateEffectSetup(uint damage = 100, bool targetUsable = true) { var moveData = Substitute.For(); var learnedMove = Substitute.For(); learnedMove.MoveData.Returns(moveData); var moveChoice = Substitute.For(); moveChoice.ChosenMove.Returns(learnedMove); moveChoice.TargetSide.Returns((byte)1); moveChoice.TargetPosition.Returns((byte)0); var battle = Substitute.For(); var eventHook = new EventHook(); battle.EventHook.Returns(eventHook); var target = Substitute.For(); target.IsUsable.Returns(targetUsable); battle.GetPokemon(1, 0).Returns(target); battle.Library.DamageCalculator.GetDamage(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(damage); var effect = new FutureSightEffect(moveChoice); return (effect, battle, target, eventHook); } /// /// Bulbapedia: "On the turn Future Sight is selected, this attack will do nothing other than say that /// the user has foreseen an attack." — the immediate execution of the move is stopped. /// [Test] public async Task StopBeforeMove_UserInBattle_StopsImmediateExecution() { // Arrange var (script, move, _, _) = CreateScriptSetup(); var stop = false; // Act script.StopBeforeMove(move, ref stop); // Assert await Assert.That(stop).IsTrue(); } /// /// Bulbapedia: "Two turns later, Future Sight will do damage against the target" — selecting the move /// queues a pending on the battle. /// [Test] public async Task StopBeforeMove_UserInBattle_AddsFutureSightEffectToBattleVolatile() { // Arrange var (script, move, _, battleVolatile) = CreateScriptSetup(); var stop = false; // Act script.StopBeforeMove(move, ref stop); // Assert await Assert.That(battleVolatile.Contains(ScriptUtils.ResolveName())).IsTrue(); } /// /// Bulbapedia: "Two turns later, Future Sight will do damage against the target." One end-of-turn tick /// after selection (the selection turn itself) plus one more is not enough for the strike to land. /// [Test] public async Task OnEndTurn_TwoTurnTicks_DoesNotDamageTargetYet() { // Arrange var (effect, battle, target, _) = CreateEffectSetup(); // Act - end of the selection turn and of the first turn after it effect.OnEndTurn(battle, battle); effect.OnEndTurn(battle, battle); // Assert await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); } /// /// Bulbapedia: "Two turns later, Future Sight will do damage against the target." At the end of the /// second turn after selection the delayed strike lands on the Pokémon in the targeted spot. /// [Test] public async Task OnEndTurn_ThreeTurnTicks_DamagesTarget() { // Arrange var (effect, battle, target, _) = CreateEffectSetup(123); // Act - selection turn plus the two turns after it effect.OnEndTurn(battle, battle); effect.OnEndTurn(battle, battle); effect.OnEndTurn(battle, 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); } /// /// Bulbapedia (Generation V onwards): the damage calculation "occurs when it hits rather than when it /// is selected" — the damage calculator is only consulted on the turn the strike lands, not before. /// [Test] public async Task OnEndTurn_BeforeStrikeLands_DamageCalculatorNotConsulted() { // Arrange var (effect, battle, _, _) = CreateEffectSetup(); // Act effect.OnEndTurn(battle, battle); effect.OnEndTurn(battle, battle); // Assert await Assert.That(battle.Library.DamageCalculator.ReceivedCalls() .Any(c => c.GetMethodInfo().Name == "GetDamage")).IsFalse(); } /// /// Bulbapedia: the delayed attack does damage "against the target" — when the targeted spot no longer /// holds a usable Pokémon, the move fails and no damage is dealt. /// [Test] public async Task OnEndTurn_TargetNotUsable_MoveFailsWithoutDamage() { // Arrange var (effect, battle, target, eventHook) = CreateEffectSetup(targetUsable: false); DialogEvent? capturedDialog = null; eventHook.Handler += (_, args) => { if (args is DialogEvent dialogEvent) capturedDialog = dialogEvent; }; // Act effect.OnEndTurn(battle, battle); effect.OnEndTurn(battle, battle); effect.OnEndTurn(battle, battle); // Assert await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsFalse(); await Assert.That(capturedDialog).IsNotNull(); await Assert.That(capturedDialog!.Message).IsEqualTo("move_failed"); } /// /// Creates a mocked battle whose volatile scripts are a real , for tests that /// exercise multiple Future Sight uses against the same battle. /// private static (IBattle battle, IScriptSet battleVolatile) CreateBattleWithRealVolatile() { var battle = Substitute.For(); battle.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>())); IScriptSet battleVolatile = new ScriptSet(battle); battle.Volatile.Returns(battleVolatile); return (battle, battleVolatile); } /// /// Creates a mocked executing Future Sight use in the given battle, whose move choice targets the given /// side and position. /// private static IExecutingMove CreateFutureSightUse(IBattle battle, byte targetSide, byte targetPosition) { var moveData = Substitute.For(); var learnedMove = Substitute.For(); learnedMove.MoveData.Returns(moveData); var moveChoice = Substitute.For(); moveChoice.ChosenMove.Returns(learnedMove); moveChoice.TargetSide.Returns(targetSide); moveChoice.TargetPosition.Returns(targetPosition); var user = Substitute.For(); user.Battle.Returns(battle); var move = Substitute.For(); move.User.Returns(user); move.MoveChoice.Returns(moveChoice); return move; } /// /// Bulbapedia: "Future Sight fails when used if the target is already set to be hit by Future Sight or /// Doom Desire." — a second Future Sight against a spot that already has one queued fails instead of /// being silently swallowed. /// [Test] public async Task StopBeforeMove_FutureSightAlreadyQueuedOnTarget_SecondUseFails() { // Arrange - a Future Sight is already queued against side 1, position 0 var script = new FutureSight(); var (battle, _) = CreateBattleWithRealVolatile(); var firstUse = CreateFutureSightUse(battle, 1, 0); var secondUse = CreateFutureSightUse(battle, 1, 0); var stop = false; script.StopBeforeMove(firstUse, ref stop); stop = false; // Act - a second Future Sight is used against the same spot script.StopBeforeMove(secondUse, ref stop); // Assert - the second use fails secondUse.MoveChoice.Received(1).Fail(); await Assert.That(secondUse.MoveChoice.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue(); } /// /// Bulbapedia: "Future Sight fails when used if the target is already set to be hit by Future Sight or /// Doom Desire." — a pending against the targeted spot also makes Future /// Sight fail. /// [Test] public async Task StopBeforeMove_DoomDesireAlreadyQueuedOnTarget_MoveFails() { // Arrange - a Doom Desire strike is pending against side 1, position 0 var script = new FutureSight(); var (battle, _) = CreateBattleWithRealVolatile(); var opposingSide = Substitute.For(); opposingSide.GetScripts().Returns(_ => new ScriptIterator(Array.Empty>())); IScriptSet sideScripts = new ScriptSet(opposingSide); opposingSide.VolatileScripts.Returns(sideScripts); var doomDesire = new DoomDesireEffect(opposingSide); doomDesire.AddTarget(0, 100); sideScripts.Add(doomDesire); battle.Sides.Returns(new[] { Substitute.For(), opposingSide }); var move = CreateFutureSightUse(battle, 1, 0); var stop = false; // Act script.StopBeforeMove(move, ref stop); // Assert - the move fails move.MoveChoice.Received(1).Fail(); await Assert.That(move.MoveChoice.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Fail")).IsTrue(); } /// /// Bulbapedia: "Two turns later, Future Sight will do damage against the target." — the failure clause /// only covers a target that is "already set to be hit", so two Future Sights queued by different users /// against different spots are both tracked and both delayed strikes land. /// [Test] public async Task StopBeforeMove_TwoUsersTargetDifferentSpots_BothDelayedStrikesLand() { // Arrange - two users queue Future Sight against different spots on side 1 var script = new FutureSight(); var (battle, battleVolatile) = CreateBattleWithRealVolatile(); battle.EventHook.Returns(new EventHook()); var targetA = Substitute.For(); targetA.IsUsable.Returns(true); battle.GetPokemon(1, 0).Returns(targetA); var targetB = Substitute.For(); targetB.IsUsable.Returns(true); battle.GetPokemon(1, 1).Returns(targetB); battle.Library.DamageCalculator.GetDamage(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(100u); var useA = CreateFutureSightUse(battle, 1, 0); var useB = CreateFutureSightUse(battle, 1, 1); var stop = false; script.StopBeforeMove(useA, ref stop); stop = false; script.StopBeforeMove(useB, ref stop); // Act - tick the end of the selection turn and of the two turns after it for all pending strikes for (var turn = 0; turn < 3; turn++) { foreach (var container in battleVolatile.ToList()) { if (container.Script is IScriptOnEndTurn onEndTurn) onEndTurn.OnEndTurn(battle, battle); } } // Assert - both delayed strikes land await Assert.That(targetA.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsTrue(); await Assert.That(targetB.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Damage")).IsTrue(); } }