360 lines
14 KiB
C#
360 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Tests for the <see cref="FutureSight"/> move script and its <see cref="FutureSightEffect"/>.
|
|
/// 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".
|
|
/// </summary>
|
|
public class FutureSightTests
|
|
{
|
|
/// <summary>
|
|
/// Creates a fully mocked test setup for the <see cref="FutureSight"/> script where the battle's
|
|
/// volatile scripts are a real <see cref="ScriptSet"/>.
|
|
/// </summary>
|
|
private static (FutureSight script, IExecutingMove move, IPokemon user, IScriptSet battleVolatile)
|
|
CreateScriptSetup()
|
|
{
|
|
var script = new FutureSight();
|
|
var move = Substitute.For<IExecutingMove>();
|
|
|
|
var battle = Substitute.For<IBattle>();
|
|
battle.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
|
|
IScriptSet battleVolatile = new ScriptSet(battle);
|
|
battle.Volatile.Returns(battleVolatile);
|
|
|
|
var battleData = Substitute.For<IPokemonBattleData>();
|
|
battleData.Battle.Returns(battle);
|
|
var user = Substitute.For<IPokemon>();
|
|
user.BattleData.Returns(battleData);
|
|
move.User.Returns(user);
|
|
|
|
var moveChoice = Substitute.For<IMoveChoice>();
|
|
move.MoveChoice.Returns(moveChoice);
|
|
|
|
return (script, move, user, battleVolatile);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a fully mocked test setup for the <see cref="FutureSightEffect"/>, with a target at side 1,
|
|
/// position 0 and a damage calculator that computes the given damage when the delayed strike lands.
|
|
/// </summary>
|
|
private static (FutureSightEffect effect, IBattle battle, IPokemon target, EventHook eventHook) CreateEffectSetup(
|
|
uint damage = 100, bool targetUsable = true)
|
|
{
|
|
var moveData = Substitute.For<IMoveData>();
|
|
var learnedMove = Substitute.For<ILearnedMove>();
|
|
learnedMove.MoveData.Returns(moveData);
|
|
var moveChoice = Substitute.For<IMoveChoice>();
|
|
moveChoice.ChosenMove.Returns(learnedMove);
|
|
moveChoice.TargetSide.Returns((byte)1);
|
|
moveChoice.TargetPosition.Returns((byte)0);
|
|
|
|
var battle = Substitute.For<IBattle>();
|
|
var eventHook = new EventHook();
|
|
battle.EventHook.Returns(eventHook);
|
|
|
|
var target = Substitute.For<IPokemon>();
|
|
target.IsUsable.Returns(targetUsable);
|
|
battle.GetPokemon(1, 0).Returns(target);
|
|
|
|
battle.Library.DamageCalculator.GetDamage(Arg.Any<IExecutingMove?>(), Arg.Any<MoveCategory>(),
|
|
Arg.Any<IPokemon>(), Arg.Any<IPokemon>(), Arg.Any<int>(), Arg.Any<byte>(), Arg.Any<IHitData>())
|
|
.Returns(damage);
|
|
|
|
var effect = new FutureSightEffect(moveChoice);
|
|
return (effect, battle, target, eventHook);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "Two turns later, Future Sight will do damage against the target" — selecting the move
|
|
/// queues a pending <see cref="FutureSightEffect"/> on the battle.
|
|
/// </summary>
|
|
[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<FutureSightEffect>())).IsTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Technical test: outside of battle (no battle data on the user) the hook does nothing and does not
|
|
/// stop the move.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StopBeforeMove_UserHasNoBattleData_DoesNotStopMove()
|
|
{
|
|
// Arrange
|
|
var (script, move, user, _) = CreateScriptSetup();
|
|
user.BattleData.Returns((IPokemonBattleData?)null);
|
|
var stop = false;
|
|
|
|
// Act
|
|
script.StopBeforeMove(move, ref stop);
|
|
|
|
// Assert
|
|
await Assert.That(stop).IsFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a mocked battle whose volatile scripts are a real <see cref="ScriptSet"/>, for tests that
|
|
/// exercise multiple Future Sight uses against the same battle.
|
|
/// </summary>
|
|
private static (IBattle battle, IScriptSet battleVolatile) CreateBattleWithRealVolatile()
|
|
{
|
|
var battle = Substitute.For<IBattle>();
|
|
battle.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
|
|
IScriptSet battleVolatile = new ScriptSet(battle);
|
|
battle.Volatile.Returns(battleVolatile);
|
|
return (battle, battleVolatile);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a mocked executing Future Sight use in the given battle, whose move choice targets the given
|
|
/// side and position.
|
|
/// </summary>
|
|
private static IExecutingMove CreateFutureSightUse(IBattle battle, byte targetSide, byte targetPosition)
|
|
{
|
|
var moveData = Substitute.For<IMoveData>();
|
|
var learnedMove = Substitute.For<ILearnedMove>();
|
|
learnedMove.MoveData.Returns(moveData);
|
|
var moveChoice = Substitute.For<IMoveChoice>();
|
|
moveChoice.ChosenMove.Returns(learnedMove);
|
|
moveChoice.TargetSide.Returns(targetSide);
|
|
moveChoice.TargetPosition.Returns(targetPosition);
|
|
|
|
var battleData = Substitute.For<IPokemonBattleData>();
|
|
battleData.Battle.Returns(battle);
|
|
var user = Substitute.For<IPokemon>();
|
|
user.BattleData.Returns(battleData);
|
|
|
|
var move = Substitute.For<IExecutingMove>();
|
|
move.User.Returns(user);
|
|
move.MoveChoice.Returns(moveChoice);
|
|
return move;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "Future Sight fails when used if the target is already set to be hit by Future Sight or
|
|
/// Doom Desire." — a pending <see cref="DoomDesireEffect"/> against the targeted spot also makes Future
|
|
/// Sight fail.
|
|
/// </summary>
|
|
[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<IBattleSide>();
|
|
opposingSide.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
|
|
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<IBattleSide>(), 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<IPokemon>();
|
|
targetA.IsUsable.Returns(true);
|
|
battle.GetPokemon(1, 0).Returns(targetA);
|
|
var targetB = Substitute.For<IPokemon>();
|
|
targetB.IsUsable.Returns(true);
|
|
battle.GetPokemon(1, 1).Returns(targetB);
|
|
|
|
battle.Library.DamageCalculator.GetDamage(Arg.Any<IExecutingMove?>(), Arg.Any<MoveCategory>(),
|
|
Arg.Any<IPokemon>(), Arg.Any<IPokemon>(), Arg.Any<int>(), Arg.Any<byte>(), Arg.Any<IHitData>())
|
|
.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();
|
|
}
|
|
} |