Files
PkmnLib.NET/Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/FollowMeTests.cs

420 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 IBattlePokemon CreatePokemon(Arena arena, byte sideIndex)
{
var pokemon = Substitute.For<IBattlePokemon>();
pokemon.Volatile.Returns(Substitute.For<IScriptSet>());
pokemon.SideIndex.Returns(sideIndex);
pokemon.BattleSide.Returns(arena.Sides[sideIndex]);
pokemon.Battle.Returns(arena.Battle);
return pokemon;
}
/// <summary>
/// Creates a mocked move choice made by the given Pokémon, using the named move.
/// </summary>
private static IMoveChoice CreateMoveChoice(IBattlePokemon 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(IBattlePokemon 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.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<IBattlePokemon?> 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="IBattlePokemon.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<IBattlePokemon?> 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<IBattlePokemon?> 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<IBattlePokemon?> 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<IBattlePokemon?> 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<IBattlePokemon>();
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<IBattlePokemon?> 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<IBattlePokemon?> 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>();
}
}