421 lines
16 KiB
C#
421 lines
16 KiB
C#
using PkmnLib.Dynamic.Models;
|
|
using PkmnLib.Dynamic.Models.Choices;
|
|
using PkmnLib.Dynamic.ScriptHandling;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Tests for the <see cref="FollowMe"/> script, and the <see cref="FollowMeEffect"/> it registers on its user.
|
|
/// Behavior is verified against the Bulbapedia page for Follow Me (Generation VII).
|
|
/// </summary>
|
|
public class FollowMeTests
|
|
{
|
|
/// <summary>The side the center of attention and its ally are on.</summary>
|
|
private const byte CenterSide = 0;
|
|
|
|
/// <summary>The side the attacking opponents are on.</summary>
|
|
private const byte OpposingSide = 1;
|
|
|
|
/// <summary>
|
|
/// A two-sided battle, each side carrying its own <see cref="IBattleSide.VolatileScripts"/>.
|
|
/// </summary>
|
|
private sealed class Arena
|
|
{
|
|
public IBattle Battle { get; } = Substitute.For<IBattle>();
|
|
public IBattleSide[] Sides { get; } = [CreateSide(), CreateSide()];
|
|
|
|
private static IBattleSide CreateSide()
|
|
{
|
|
var side = Substitute.For<IBattleSide>();
|
|
side.VolatileScripts.Returns(Substitute.For<IScriptSet>());
|
|
return side;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a mocked Pokémon standing on the given side of the given <see cref="Arena"/>.
|
|
/// </summary>
|
|
private static IPokemon CreatePokemon(Arena arena, byte sideIndex)
|
|
{
|
|
var pokemon = Substitute.For<IPokemon>();
|
|
pokemon.Volatile.Returns(Substitute.For<IScriptSet>());
|
|
|
|
var battleData = Substitute.For<IPokemonBattleData>();
|
|
battleData.SideIndex.Returns(sideIndex);
|
|
battleData.BattleSide.Returns(arena.Sides[sideIndex]);
|
|
battleData.Battle.Returns(arena.Battle);
|
|
pokemon.BattleData.Returns(battleData);
|
|
return pokemon;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a mocked move choice made by the given Pokémon, using the named move.
|
|
/// </summary>
|
|
private static IMoveChoice CreateMoveChoice(IPokemon user, string moveName = "tackle", bool noRedirection = false)
|
|
{
|
|
var moveData = Substitute.For<IMoveData>();
|
|
moveData.Name.Returns(new StringKey(moveName));
|
|
moveData.HasFlag(MoveFlags.NoRedirection).Returns(noRedirection);
|
|
var learnedMove = Substitute.For<ILearnedMove>();
|
|
learnedMove.MoveData.Returns(moveData);
|
|
|
|
var moveChoice = Substitute.For<IMoveChoice>();
|
|
moveChoice.User.Returns(user);
|
|
moveChoice.ChosenMove.Returns(learnedMove);
|
|
return moveChoice;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the script that was handed to <see cref="IScriptSet.Add"/> or
|
|
/// <see cref="IScriptSet.StackOrAdd"/> on the given set, or null if the set never received one.
|
|
/// </summary>
|
|
private static Script? RegisteredScript(IScriptSet set)
|
|
{
|
|
foreach (var call in set.ReceivedCalls())
|
|
{
|
|
var arguments = call.GetArguments();
|
|
switch (call.GetMethodInfo().Name)
|
|
{
|
|
case "Add" when arguments[0] is Script script:
|
|
return script;
|
|
case "StackOrAdd" when arguments[1] is Func<Script?> instantiation:
|
|
return instantiation();
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes <see cref="FollowMe"/> for the given user and returns the center-of-attention effect it
|
|
/// registered, regardless of which script set it chose to register it on.
|
|
/// </summary>
|
|
private static Script? MakeCenterOfAttention(IPokemon user)
|
|
{
|
|
var followMe = new FollowMe();
|
|
var move = Substitute.For<IExecutingMove>();
|
|
move.User.Returns(user);
|
|
|
|
followMe.OnSecondaryEffect(move, user, 0);
|
|
|
|
return RegisteredScript(user.Volatile) ?? RegisteredScript(user.BattleData!.BattleSide.VolatileScripts);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drives the registered effect through whichever target-redirection hook it implements, mirroring the two
|
|
/// hooks the engine runs while resolving a move's targets.
|
|
/// </summary>
|
|
private static void Redirect(Script effect, IMoveChoice moveChoice, ref IReadOnlyList<IPokemon?> targets)
|
|
{
|
|
switch (effect)
|
|
{
|
|
case IScriptChangeIncomingTargets incomingTargets:
|
|
incomingTargets.ChangeIncomingTargets(moveChoice, ref targets);
|
|
break;
|
|
case IScriptChangeTargets changeTargets:
|
|
changeTargets.ChangeTargets(moveChoice, ref targets);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "Follow Me ... makes the user the center of attention ... for the rest of the turn."
|
|
/// Opposing Pokémon act in their own, later move choices, so using Follow Me must leave a persistent effect
|
|
/// behind rather than only altering the targets of the Follow Me choice itself.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task OnSecondaryEffect_Used_MakesUserTheCenterOfAttention()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var user = CreatePokemon(arena, CenterSide);
|
|
|
|
// Act
|
|
var effect = MakeCenterOfAttention(user);
|
|
|
|
// Assert
|
|
await Assert.That(effect).IsNotNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "forcing opposing Pokémon to use their moves on the user rather than the intended target".
|
|
/// The engine resolves a move's targets by running <see cref="IScriptChangeTargets"/> over the attacker's
|
|
/// own script scope, and <see cref="IScriptChangeIncomingTargets"/> over each intended target's scope —
|
|
/// which covers that target's own scripts and its <see cref="IBattleSide.VolatileScripts"/>. A redirection
|
|
/// effect stored on the center of attention's <see cref="IPokemon.Volatile"/> therefore sits in neither
|
|
/// scope when an opponent attacks the center's ally. It must live on the center's side, the way
|
|
/// <see cref="PkmnLib.Plugin.Gen7.Scripts.Side.RagePowderEffect"/> does.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task OnSecondaryEffect_Used_RegistersEffectOnUsersSide()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var user = CreatePokemon(arena, CenterSide);
|
|
|
|
// Act
|
|
MakeCenterOfAttention(user);
|
|
|
|
// Assert
|
|
await Assert.That(RegisteredScript(arena.Sides[CenterSide].VolatileScripts)).IsNotNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "forcing opposing Pokémon to use their moves on the user rather than the intended target".
|
|
/// The effect changes the targets of a move aimed at somebody else, so it has to hook
|
|
/// <see cref="IScriptChangeIncomingTargets"/>, which the engine runs on each intended target.
|
|
/// <see cref="IScriptChangeTargets"/> only ever runs within the attacker's own script scope, so a defending
|
|
/// Pokémon's effect can never be reached through it.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CenterOfAttentionEffect_ImplementsIncomingTargetsHook()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var user = CreatePokemon(arena, CenterSide);
|
|
|
|
// Act
|
|
var effect = MakeCenterOfAttention(user);
|
|
|
|
// Assert
|
|
await Assert.That(effect).IsAssignableTo<IScriptChangeIncomingTargets>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "makes the user the center of attention, forcing opposing Pokémon to use their moves on the
|
|
/// user rather than the intended target (even if it was a friendly target ...) for the rest of the turn."
|
|
/// The core behavior: an opponent's single-target move aimed at the center's ally is drawn to the center.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task Redirect_OpposingMoveAimedAtAlly_RedirectsToCenterOfAttention()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var center = CreatePokemon(arena, CenterSide);
|
|
var ally = CreatePokemon(arena, CenterSide);
|
|
var opponent = CreatePokemon(arena, OpposingSide);
|
|
var effect = MakeCenterOfAttention(center)!;
|
|
var moveChoice = CreateMoveChoice(opponent);
|
|
IReadOnlyList<IPokemon?> targets = [ally];
|
|
|
|
// Act
|
|
Redirect(effect, moveChoice, ref targets);
|
|
|
|
// Assert
|
|
await Assert.That(targets.Count).IsEqualTo(1);
|
|
await Assert.That(targets[0]).IsEqualTo(center);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "Follow Me has no effect on moves which hit multiple Pokémon in a battle."
|
|
/// </summary>
|
|
[Test]
|
|
public async Task Redirect_MultiTargetMove_TargetsUnchanged()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var center = CreatePokemon(arena, CenterSide);
|
|
var ally = CreatePokemon(arena, CenterSide);
|
|
var opponent = CreatePokemon(arena, OpposingSide);
|
|
var effect = MakeCenterOfAttention(center)!;
|
|
var moveChoice = CreateMoveChoice(opponent);
|
|
IReadOnlyList<IPokemon?> targets = [center, ally];
|
|
|
|
// Act
|
|
Redirect(effect, moveChoice, ref targets);
|
|
|
|
// Assert
|
|
await Assert.That(targets.Count).IsEqualTo(2);
|
|
await Assert.That(targets[0]).IsEqualTo(center);
|
|
await Assert.That(targets[1]).IsEqualTo(ally);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "It does not affect allies."
|
|
/// A move used by the center of attention's own ally keeps its intended target.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task Redirect_AllyMove_TargetsUnchanged()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var center = CreatePokemon(arena, CenterSide);
|
|
var attackingAlly = CreatePokemon(arena, CenterSide);
|
|
var targetedAlly = CreatePokemon(arena, CenterSide);
|
|
var effect = MakeCenterOfAttention(center)!;
|
|
var moveChoice = CreateMoveChoice(attackingAlly);
|
|
IReadOnlyList<IPokemon?> targets = [targetedAlly];
|
|
|
|
// Act
|
|
Redirect(effect, moveChoice, ref targets);
|
|
|
|
// Assert
|
|
await Assert.That(targets.Count).IsEqualTo(1);
|
|
await Assert.That(targets[0]).IsEqualTo(targetedAlly);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "forcing opposing Pokémon to use their moves on the user rather than the intended target."
|
|
/// A move already aimed at the center of attention simply stays there.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task Redirect_MoveAimedAtCenterOfAttention_TargetsUnchanged()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var center = CreatePokemon(arena, CenterSide);
|
|
var opponent = CreatePokemon(arena, OpposingSide);
|
|
var effect = MakeCenterOfAttention(center)!;
|
|
var moveChoice = CreateMoveChoice(opponent);
|
|
IReadOnlyList<IPokemon?> targets = [center];
|
|
|
|
// Act
|
|
Redirect(effect, moveChoice, ref targets);
|
|
|
|
// Assert
|
|
await Assert.That(targets.Count).IsEqualTo(1);
|
|
await Assert.That(targets[0]).IsEqualTo(center);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a real <see cref="ScriptSet"/> hosting the given effect, so that
|
|
/// <see cref="Script.RemoveSelf"/> calls can be observed through <see cref="IScriptSet.Contains"/>.
|
|
/// </summary>
|
|
private static IScriptSet CreateHostedSet(Script effect)
|
|
{
|
|
var owner = Substitute.For<IPokemon>();
|
|
owner.GetScripts().Returns(_ => new ScriptIterator(Array.Empty<IEnumerable<ScriptContainer>>()));
|
|
IScriptSet set = new ScriptSet(owner);
|
|
set.Add(effect);
|
|
return set;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "If the center of attention faints or switches out, it no longer draws moves."
|
|
/// The effect removes itself from its host script set when the center of attention faints.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task OnFaint_CenterOfAttentionFaints_EffectRemovesItself()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var center = CreatePokemon(arena, CenterSide);
|
|
var effect = MakeCenterOfAttention(center)!;
|
|
var set = CreateHostedSet(effect);
|
|
|
|
// Act
|
|
((IScriptOnFaint)effect).OnFaint(center, DamageSource.MoveDamage);
|
|
|
|
// Assert
|
|
await Assert.That(set.Contains(effect.Name)).IsFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "If the center of attention faints or switches out, it no longer draws moves."
|
|
/// The effect removes itself from its host script set when the center of attention switches out.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task OnSwitchOut_CenterOfAttentionSwitchesOut_EffectRemovesItself()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var center = CreatePokemon(arena, CenterSide);
|
|
var effect = MakeCenterOfAttention(center)!;
|
|
var set = CreateHostedSet(effect);
|
|
|
|
// Act
|
|
((IScriptOnSwitchOut)effect).OnSwitchOut(center, 0);
|
|
|
|
// Assert
|
|
await Assert.That(set.Contains(effect.Name)).IsFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The effect only concerns its own user: another Pokémon fainting leaves the center of attention in place.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task OnFaint_OtherPokemonFaints_EffectStays()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var center = CreatePokemon(arena, CenterSide);
|
|
var ally = CreatePokemon(arena, CenterSide);
|
|
var effect = MakeCenterOfAttention(center)!;
|
|
var set = CreateHostedSet(effect);
|
|
|
|
// Act
|
|
((IScriptOnFaint)effect).OnFaint(ally, DamageSource.MoveDamage);
|
|
|
|
// Assert
|
|
await Assert.That(set.Contains(effect.Name)).IsTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia, Generation VI onwards: "Follow Me does not redirect Future Sight or Doom Desire."
|
|
/// Both moves carry the <see cref="MoveFlags.NoRedirection"/> flag in the move data, which the effect
|
|
/// checks to leave them alone.
|
|
/// </summary>
|
|
[Test, Arguments("future_sight"), Arguments("doom_desire")]
|
|
public async Task Redirect_FutureSightOrDoomDesire_TargetsUnchanged(string moveName)
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var center = CreatePokemon(arena, CenterSide);
|
|
var ally = CreatePokemon(arena, CenterSide);
|
|
var opponent = CreatePokemon(arena, OpposingSide);
|
|
var effect = MakeCenterOfAttention(center)!;
|
|
var moveChoice = CreateMoveChoice(opponent, moveName, true);
|
|
IReadOnlyList<IPokemon?> targets = [ally];
|
|
|
|
// Act
|
|
Redirect(effect, moveChoice, ref targets);
|
|
|
|
// Assert
|
|
await Assert.That(targets.Count).IsEqualTo(1);
|
|
await Assert.That(targets[0]).IsEqualTo(ally);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Technical test: an empty target list is left unchanged instead of throwing.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task Redirect_EmptyTargetList_TargetsUnchanged()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var center = CreatePokemon(arena, CenterSide);
|
|
var opponent = CreatePokemon(arena, OpposingSide);
|
|
var effect = MakeCenterOfAttention(center)!;
|
|
var moveChoice = CreateMoveChoice(opponent);
|
|
IReadOnlyList<IPokemon?> targets = [];
|
|
|
|
// Act
|
|
Redirect(effect, moveChoice, ref targets);
|
|
|
|
// Assert
|
|
await Assert.That(targets.Count).IsEqualTo(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bulbapedia: "makes the user the center of attention ... for the rest of the turn."
|
|
/// The effect has to clear itself once the turn ends, the way <see cref="PkmnLib.Plugin.Gen7.Scripts.Side.RagePowderEffect"/> does
|
|
/// through <see cref="IScriptOnEndTurn"/>; otherwise the user stays the center of attention forever.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CenterOfAttentionEffect_WearsOffAtEndOfTurn()
|
|
{
|
|
// Arrange
|
|
var arena = new Arena();
|
|
var user = CreatePokemon(arena, CenterSide);
|
|
|
|
// Act
|
|
var effect = MakeCenterOfAttention(user);
|
|
|
|
// Assert
|
|
await Assert.That(effect).IsAssignableTo<IScriptOnEndTurn>();
|
|
}
|
|
} |