This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
using PkmnLib.Dynamic.Models;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||
using PkmnLib.Static;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="Acupressure"/> move script.
|
||||
/// Gen VII Bulbapedia behavior: "Acupressure chooses one of the target's stats at random and raises it by
|
||||
/// two stages. It can raise either the target's Attack, Defense, Special Attack, Special Defense, Speed,
|
||||
/// accuracy, or evasion stat but will not attempt to raise a stat that is already maximized, meaning that
|
||||
/// the move will fail if all stats are maximized."
|
||||
/// </summary>
|
||||
public class AcupressureTests
|
||||
{
|
||||
private static (Acupressure script, IExecutingMove move, IPokemon target, IBattleRandom random, IHitData hitData)
|
||||
CreateTestSetup()
|
||||
{
|
||||
var script = new Acupressure();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var hitData = Substitute.For<IHitData>();
|
||||
move.GetHitData(target, 0).Returns(hitData);
|
||||
|
||||
var random = Substitute.For<IBattleRandom>();
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.Random.Returns(random);
|
||||
var battleData = Substitute.For<IPokemonBattleData>();
|
||||
battleData.Battle.Returns(battle);
|
||||
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.BattleData.Returns(battleData);
|
||||
move.User.Returns(user);
|
||||
|
||||
target.StatBoost.Returns(new StatBoostStatisticSet());
|
||||
|
||||
return (script, move, target, random, hitData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the stat argument of a ChangeStatBoost call received by the target.
|
||||
/// </summary>
|
||||
private static object?[]? GetStatBoostCallArgs(IPokemon target)
|
||||
{
|
||||
var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
|
||||
return call?.GetArguments();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Acupressure chooses one of the target's stats at random and raises it by two stages."
|
||||
/// When the random roll selects Attack, the target's Attack is raised by exactly two stages.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_RandomSelectsAttack_RaisesAttackByTwoStages()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, random, _) = CreateTestSetup();
|
||||
// Attack is the first entry in the pool of raisable stats (HP is excluded).
|
||||
random.GetInt(Arg.Any<int>(), Arg.Any<int>()).Returns(0);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
var args = GetStatBoostCallArgs(target);
|
||||
await Assert.That(args).IsNotNull();
|
||||
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.Attack);
|
||||
await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "It can raise either the target's Attack, Defense, Special Attack, Special Defense, Speed,
|
||||
/// accuracy, or evasion stat" — HP is not one of the raisable stats. When the random roll returns the lowest
|
||||
/// value of its range, the selected stat must be Attack, not HP.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_RandomMinimumRoll_SelectsAttackNotHp()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, random, _) = CreateTestSetup();
|
||||
// Return the inclusive lower bound of whatever range is requested.
|
||||
random.GetInt(Arg.Any<int>(), Arg.Any<int>()).Returns(ci => ci.ArgAt<int>(0));
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
var args = GetStatBoostCallArgs(target);
|
||||
await Assert.That(args).IsNotNull();
|
||||
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.Attack);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "It can raise either the target's Attack, Defense, Special Attack, Special Defense, Speed,
|
||||
/// accuracy, or evasion stat". Accuracy and evasion must be selectable: when the random roll returns the
|
||||
/// highest value of its range, the selected stat should be one of the stats beyond Speed in the
|
||||
/// <see cref="Statistic"/> enum (Evasion or Accuracy).
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_RandomMaximumRoll_CanSelectAccuracyOrEvasion()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, random, _) = CreateTestSetup();
|
||||
// Return the highest value of whatever range is requested (upper bound is exclusive).
|
||||
random.GetInt(Arg.Any<int>(), Arg.Any<int>()).Returns(ci => ci.ArgAt<int>(1) - 1);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - the highest selectable stat should be Evasion or Accuracy
|
||||
var args = GetStatBoostCallArgs(target);
|
||||
await Assert.That(args).IsNotNull();
|
||||
var stat = (Statistic)args![0]!;
|
||||
await Assert.That(stat is Statistic.Evasion or Statistic.Accuracy).IsTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "the move will fail if all stats are maximized." All raisable stats (Attack through accuracy;
|
||||
/// HP boosts cannot be raised by any move and stay at 0) are at +6, so the move must fail without attempting
|
||||
/// a stat change.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_AllRaisableStatsMaximized_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, _, hitData) = CreateTestSetup();
|
||||
var statBoost = new StatBoostStatisticSet(0, 6, 6, 6, 6, 6) { Evasion = 6, Accuracy = 6 };
|
||||
target.StatBoost.Returns(statBoost);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
hitData.Received(1).Fail();
|
||||
await Assert.That(target.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "ChangeStatBoost")).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "will not attempt to raise a stat that is already maximized". With the target's Attack at +6
|
||||
/// and other stats available, Attack must never be the raised stat, regardless of what the random roll
|
||||
/// returns. The rolls cover every index of the six-stat selection pool that remains.
|
||||
/// </summary>
|
||||
[Test, Arguments(0), Arguments(1), Arguments(2), Arguments(3), Arguments(4), Arguments(5)]
|
||||
public async Task OnSecondaryEffect_StatAlreadyMaximized_NeverRaisesThatStat(int roll)
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, random, _) = CreateTestSetup();
|
||||
var statBoost = new StatBoostStatisticSet(0, 6, 0, 0, 0, 0);
|
||||
target.StatBoost.Returns(statBoost);
|
||||
random.GetInt(Arg.Any<int>(), Arg.Any<int>()).Returns(roll);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - a stat is raised, but never the maximized Attack
|
||||
var args = GetStatBoostCallArgs(target);
|
||||
await Assert.That(args).IsNotNull();
|
||||
await Assert.That((Statistic)args![0]!).IsNotEqualTo(Statistic.Attack);
|
||||
}
|
||||
}
|
||||
140
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AfterYouTests.cs
Normal file
140
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AfterYouTests.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using PkmnLib.Dynamic.Models;
|
||||
using PkmnLib.Dynamic.Models.Choices;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="AfterYou"/> move script.
|
||||
/// Gen VII Bulbapedia behavior (Generations V to VII): "The target will move next on the current turn,
|
||||
/// ignoring priority. [...] It fails if the target has already moved on the same turn. [...] After You fails
|
||||
/// if the order remains the same after using After You."
|
||||
/// </summary>
|
||||
public class AfterYouTests
|
||||
{
|
||||
private static IMoveChoice CreateChoice(IPokemon user, uint speed)
|
||||
{
|
||||
var choice = Substitute.For<IMoveChoice>();
|
||||
choice.User.Returns(user);
|
||||
choice.Speed.Returns(speed);
|
||||
return choice;
|
||||
}
|
||||
|
||||
private static (AfterYou script, IExecutingMove move, IHitData hitData) CreateTestSetup(BattleChoiceQueue? queue)
|
||||
{
|
||||
var script = new AfterYou();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var hitData = Substitute.For<IHitData>();
|
||||
move.GetHitData(Arg.Any<IPokemon>(), Arg.Any<byte>()).Returns(hitData);
|
||||
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.ChoiceQueue.Returns(queue);
|
||||
var battleData = Substitute.For<IPokemonBattleData>();
|
||||
battleData.Battle.Returns(battle);
|
||||
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.BattleData.Returns(battleData);
|
||||
move.User.Returns(user);
|
||||
|
||||
return (script, move, hitData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "The target will move next on the current turn, ignoring priority."
|
||||
/// The target's choice is moved in front of a faster Pokémon's remaining choice, and the move does not fail.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TargetLaterInQueue_TargetMovesNext()
|
||||
{
|
||||
// Arrange
|
||||
var user = Substitute.For<IPokemon>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var other = Substitute.For<IPokemon>();
|
||||
// Sorted by speed: user (100), other (75), target (50)
|
||||
var queue = new BattleChoiceQueue([
|
||||
CreateChoice(user, 100), CreateChoice(other, 75), CreateChoice(target, 50),
|
||||
]);
|
||||
// The user's own choice is executing, so it has been dequeued already.
|
||||
queue.Dequeue();
|
||||
|
||||
var (script, move, hitData) = CreateTestSetup(queue);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - the target is now the next Pokémon to move, ahead of the faster one
|
||||
await Assert.That(queue.Peek()!.User).IsEqualTo(target);
|
||||
hitData.DidNotReceive().Fail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "It fails if the target has already moved on the same turn."
|
||||
/// The target's choice is no longer in the queue, so the move fails.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void OnSecondaryEffect_TargetAlreadyMoved_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var user = Substitute.For<IPokemon>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
// Sorted by speed: target (100), user (50)
|
||||
var queue = new BattleChoiceQueue([
|
||||
CreateChoice(target, 100), CreateChoice(user, 50),
|
||||
]);
|
||||
// The target already moved this turn, and the user's own choice is executing.
|
||||
queue.Dequeue();
|
||||
queue.Dequeue();
|
||||
|
||||
var (script, move, hitData) = CreateTestSetup(queue);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
hitData.Received(1).Fail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "After You fails if the order remains the same after using After You."
|
||||
/// The target was already the next Pokémon to move, so using After You changes nothing and the move
|
||||
/// must fail.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void OnSecondaryEffect_TargetAlreadyNext_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var user = Substitute.For<IPokemon>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
// Sorted by speed: user (100), target (50)
|
||||
var queue = new BattleChoiceQueue([
|
||||
CreateChoice(user, 100), CreateChoice(target, 50),
|
||||
]);
|
||||
// The user's own choice is executing; the target is already next.
|
||||
queue.Dequeue();
|
||||
|
||||
var (script, move, hitData) = CreateTestSetup(queue);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
hitData.Received(1).Fail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: when the battle has no active choice queue, the script returns without throwing
|
||||
/// and without failing the hit.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void OnSecondaryEffect_NoChoiceQueue_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, hitData) = CreateTestSetup(null);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||
|
||||
// Assert
|
||||
hitData.DidNotReceive().Fail();
|
||||
}
|
||||
}
|
||||
205
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AssistTests.cs
Normal file
205
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AssistTests.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using PkmnLib.Dynamic.Models;
|
||||
using PkmnLib.Dynamic.Models.Choices;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||
using PkmnLib.Static.Moves;
|
||||
using PkmnLib.Static.Utils;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="Assist"/> move script.
|
||||
/// Gen VII Bulbapedia behavior: "The Assist user executes a move that is randomly selected from all the
|
||||
/// eligible moves known by other Pokémon in the same party. If the same move is known by multiple Pokémon,
|
||||
/// each of them counts [...]. Assist can call a move that currently has no PP or is known by a fainted
|
||||
/// Pokémon. [...] If the other Pokémon in the user's party do not know any eligible moves, Assist fails."
|
||||
/// </summary>
|
||||
public class AssistTests
|
||||
{
|
||||
private static IPokemon CreatePokemonWithMoves(params string[] moveNames)
|
||||
{
|
||||
var pokemon = Substitute.For<IPokemon>();
|
||||
var moves = moveNames.Select(name =>
|
||||
{
|
||||
var learned = Substitute.For<ILearnedMove>();
|
||||
var data = Substitute.For<IMoveData>();
|
||||
data.Name.Returns(new StringKey(name));
|
||||
learned.MoveData.Returns(data);
|
||||
return (ILearnedMove?)learned;
|
||||
}).ToArray();
|
||||
pokemon.Moves.Returns(moves);
|
||||
return pokemon;
|
||||
}
|
||||
|
||||
private static (Assist script, IMoveChoice choice, IPokemon user, IBattleRandom random) CreateTestSetup(
|
||||
params IPokemon?[] otherPartyMembers)
|
||||
{
|
||||
var script = new Assist();
|
||||
var user = CreatePokemonWithMoves("tackle");
|
||||
|
||||
var members = new List<IPokemon?> { user };
|
||||
members.AddRange(otherPartyMembers);
|
||||
|
||||
var party = Substitute.For<IPokemonParty>();
|
||||
party.GetEnumerator().Returns(_ => members.GetEnumerator());
|
||||
var battleParty = Substitute.For<IBattleParty>();
|
||||
battleParty.Party.Returns(party);
|
||||
|
||||
var random = Substitute.For<IBattleRandom>();
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.Parties.Returns(new[] { battleParty });
|
||||
battle.Random.Returns(random);
|
||||
var battleData = Substitute.For<IPokemonBattleData>();
|
||||
battleData.Battle.Returns(battle);
|
||||
user.BattleData.Returns(battleData);
|
||||
|
||||
var choice = Substitute.For<IMoveChoice>();
|
||||
choice.User.Returns(user);
|
||||
|
||||
return (script, choice, user, random);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "The Assist user executes a move that is randomly selected from all the eligible moves known
|
||||
/// by other Pokémon in the same party."
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeMove_AllyKnowsEligibleMove_SelectsThatMove()
|
||||
{
|
||||
// Arrange
|
||||
var (script, choice, _, random) = CreateTestSetup(CreatePokemonWithMoves("growl"));
|
||||
random.GetInt(Arg.Any<int>()).Returns(0);
|
||||
StringKey moveName = "assist";
|
||||
|
||||
// Act
|
||||
script.ChangeMove(choice, ref moveName);
|
||||
|
||||
// Assert
|
||||
await Assert.That(moveName).IsEqualTo(new StringKey("growl"));
|
||||
choice.DidNotReceive().Fail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "If the other Pokémon in the user's party do not know any eligible moves, Assist fails."
|
||||
/// The user's own moves are not eligible: with no other party members, the move fails even though the
|
||||
/// user itself knows a copyable move.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeMove_NoOtherPartyMembers_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var (script, choice, _, _) = CreateTestSetup();
|
||||
StringKey moveName = "assist";
|
||||
|
||||
// Act
|
||||
script.ChangeMove(choice, ref moveName);
|
||||
|
||||
// Assert
|
||||
choice.Received(1).Fail();
|
||||
await Assert.That(moveName).IsEqualTo(new StringKey("assist"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia lists moves that Assist cannot call (e.g. Protect, Counter, moves with a semi-invulnerable
|
||||
/// turn). An ally that only knows ineligible moves provides no options, so Assist fails.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeMove_AllyOnlyKnowsIneligibleMoves_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var (script, choice, _, _) = CreateTestSetup(CreatePokemonWithMoves("protect", "counter", "dig"));
|
||||
StringKey moveName = "assist";
|
||||
|
||||
// Act
|
||||
script.ChangeMove(choice, ref moveName);
|
||||
|
||||
// Assert
|
||||
choice.Received(1).Fail();
|
||||
await Assert.That(moveName).IsEqualTo(new StringKey("assist"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "If the same move is known by multiple Pokémon, each of them counts and therefore the move
|
||||
/// is more likely to be called." Two allies knowing Tackle plus one knowing Growl yields three entries in
|
||||
/// the random selection pool.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ChangeMove_SameMoveKnownByMultipleAllies_EachCounts()
|
||||
{
|
||||
// Arrange
|
||||
var (script, choice, _, random) = CreateTestSetup(CreatePokemonWithMoves("pound"),
|
||||
CreatePokemonWithMoves("pound"), CreatePokemonWithMoves("growl"));
|
||||
random.GetInt(Arg.Any<int>()).Returns(0);
|
||||
StringKey moveName = "assist";
|
||||
|
||||
// Act
|
||||
script.ChangeMove(choice, ref moveName);
|
||||
|
||||
// Assert - the selection pool contains three moves (pound twice, growl once)
|
||||
random.Received(1).GetInt(3);
|
||||
choice.DidNotReceive().Fail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Assist can call a move that [...] is known by a fainted Pokémon."
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeMove_AllyIsFainted_MoveStillEligible()
|
||||
{
|
||||
// Arrange
|
||||
var faintedAlly = CreatePokemonWithMoves("growl");
|
||||
faintedAlly.IsUsable.Returns(false);
|
||||
var (script, choice, _, random) = CreateTestSetup(faintedAlly);
|
||||
random.GetInt(Arg.Any<int>()).Returns(0);
|
||||
StringKey moveName = "assist";
|
||||
|
||||
// Act
|
||||
script.ChangeMove(choice, ref moveName);
|
||||
|
||||
// Assert
|
||||
await Assert.That(moveName).IsEqualTo(new StringKey("growl"));
|
||||
choice.DidNotReceive().Fail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Assist can call a move that currently has no PP".
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeMove_AllyMoveHasNoPp_MoveStillEligible()
|
||||
{
|
||||
// Arrange
|
||||
var ally = CreatePokemonWithMoves("growl");
|
||||
ally.Moves[0]!.CurrentPp.Returns((byte)0);
|
||||
var (script, choice, _, random) = CreateTestSetup(ally);
|
||||
random.GetInt(Arg.Any<int>()).Returns(0);
|
||||
StringKey moveName = "assist";
|
||||
|
||||
// Act
|
||||
script.ChangeMove(choice, ref moveName);
|
||||
|
||||
// Assert
|
||||
await Assert.That(moveName).IsEqualTo(new StringKey("growl"));
|
||||
choice.DidNotReceive().Fail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: outside of battle (no battle data) the script returns without failing the choice.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task ChangeMove_NoBattleData_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var script = new Assist();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.BattleData.Returns((IPokemonBattleData?)null);
|
||||
var choice = Substitute.For<IMoveChoice>();
|
||||
choice.User.Returns(user);
|
||||
StringKey moveName = "assist";
|
||||
|
||||
// Act
|
||||
script.ChangeMove(choice, ref moveName);
|
||||
|
||||
// Assert
|
||||
choice.DidNotReceive().Fail();
|
||||
await Assert.That(moveName).IsEqualTo(new StringKey("assist"));
|
||||
}
|
||||
}
|
||||
108
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AttractTests.cs
Normal file
108
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/AttractTests.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using PkmnLib.Dynamic.Models;
|
||||
using PkmnLib.Dynamic.ScriptHandling;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
|
||||
using PkmnLib.Static.Species;
|
||||
using PkmnLib.Static.Utils;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="Attract"/> move script.
|
||||
/// Gen VII Bulbapedia behavior: "The target becomes infatuated with the user if they have opposite genders.
|
||||
/// [...] Attract has no effect if the user and target have the same gender, or if either of them is a
|
||||
/// gender-unknown Pokémon (such as Magnemite). It also fails when used on a Pokémon that is already infatuated."
|
||||
/// </summary>
|
||||
public class AttractTests
|
||||
{
|
||||
private static (Attract script, IExecutingMove move, IPokemon target, IScriptSet targetVolatile, IHitData hitData)
|
||||
CreateTestSetup(Gender userGender, Gender targetGender)
|
||||
{
|
||||
var script = new Attract();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var hitData = Substitute.For<IHitData>();
|
||||
move.GetHitData(target, 0).Returns(hitData);
|
||||
|
||||
var targetVolatile = Substitute.For<IScriptSet>();
|
||||
target.Volatile.Returns(targetVolatile);
|
||||
target.Gender.Returns(targetGender);
|
||||
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.Gender.Returns(userGender);
|
||||
move.User.Returns(user);
|
||||
|
||||
return (script, move, target, targetVolatile, hitData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "The target becomes infatuated with the user if they have opposite genders."
|
||||
/// </summary>
|
||||
[Test, Arguments(Gender.Male, Gender.Female), Arguments(Gender.Female, Gender.Male)]
|
||||
public void OnSecondaryEffect_OppositeGenders_TargetBecomesInfatuated(Gender userGender, Gender targetGender)
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(userGender, targetGender);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
targetVolatile.Received(1).Add(Arg.Any<Infatuated>());
|
||||
hitData.DidNotReceive().Fail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Attract has no effect if the user and target have the same gender".
|
||||
/// </summary>
|
||||
[Test, Arguments(Gender.Male, Gender.Male), Arguments(Gender.Female, Gender.Female)]
|
||||
public void OnSecondaryEffect_SameGender_Fails(Gender userGender, Gender targetGender)
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(userGender, targetGender);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
hitData.Received(1).Fail();
|
||||
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Attract has no effect [...] if either of them is a gender-unknown Pokémon (such as Magnemite)."
|
||||
/// Covers a genderless user, a genderless target, and both being genderless.
|
||||
/// </summary>
|
||||
[Test, Arguments(Gender.Genderless, Gender.Female), Arguments(Gender.Male, Gender.Genderless),
|
||||
Arguments(Gender.Genderless, Gender.Genderless)]
|
||||
public void OnSecondaryEffect_GenderlessPokemon_Fails(Gender userGender, Gender targetGender)
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(userGender, targetGender);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
hitData.Received(1).Fail();
|
||||
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "It also fails when used on a Pokémon that is already infatuated."
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void OnSecondaryEffect_TargetAlreadyInfatuated_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, targetVolatile, hitData) = CreateTestSetup(Gender.Male, Gender.Female);
|
||||
targetVolatile.Contains(new StringKey("infatuated")).Returns(true);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
hitData.Received(1).Fail();
|
||||
targetVolatile.DidNotReceive().Add(Arg.Any<Script>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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.Plugin.Gen7.Scripts.Weather;
|
||||
using PkmnLib.Static.Utils;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="AuroraVeil"/> move script.
|
||||
/// Gen VII Bulbapedia behavior: "Aurora Veil reduces the damage done to the user by physical and special
|
||||
/// moves for five turns [...]. The move cannot be activated unless hail [...] is present. [...] Holding
|
||||
/// Light Clay extends the duration from 5 to 8 turns."
|
||||
/// </summary>
|
||||
public class AuroraVeilTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Test helper script that extends the Aurora Veil duration through the custom trigger, the same way the
|
||||
/// Light Clay item script does.
|
||||
/// </summary>
|
||||
[Script(ScriptCategory.Pokemon, "test_aurora_veil_duration_extender")]
|
||||
private class DurationExtender : Script, IScriptCustomTrigger
|
||||
{
|
||||
public void CustomTrigger(StringKey eventName, ICustomTriggerArgs args)
|
||||
{
|
||||
if (eventName == CustomTriggers.AuroraVeilDuration && args is CustomTriggers.AuroraVeilDurationArgs d)
|
||||
d.Duration = 8;
|
||||
}
|
||||
}
|
||||
|
||||
private static (AuroraVeil script, IExecutingMove move, IPokemon target, IPokemon user, IScriptSet sideScripts,
|
||||
IHitData hitData) CreateTestSetup(StringKey? weather)
|
||||
{
|
||||
var script = new AuroraVeil();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var hitData = Substitute.For<IHitData>();
|
||||
move.GetHitData(target, 0).Returns(hitData);
|
||||
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.WeatherName.Returns(weather);
|
||||
|
||||
var sideScripts = Substitute.For<IScriptSet>();
|
||||
// Mimic the real ScriptSet: invoke the factory and hand back a container holding the new script.
|
||||
sideScripts.StackOrAdd(Arg.Any<StringKey>(), Arg.Any<Func<Script?>>())
|
||||
.Returns(ci => new ScriptContainer(ci.Arg<Func<Script?>>()()!));
|
||||
var side = Substitute.For<IBattleSide>();
|
||||
side.VolatileScripts.Returns(sideScripts);
|
||||
battle.Sides.Returns(new[] { side });
|
||||
|
||||
var battleData = Substitute.For<IPokemonBattleData>();
|
||||
battleData.Battle.Returns(battle);
|
||||
battleData.SideIndex.Returns((byte)0);
|
||||
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.BattleData.Returns(battleData);
|
||||
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>()));
|
||||
move.User.Returns(user);
|
||||
|
||||
return (script, move, target, user, sideScripts, hitData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the effect script instance created through StackOrAdd on the side's volatile scripts.
|
||||
/// </summary>
|
||||
private static AuroraVeilEffect? GetAddedEffect(IScriptSet sideScripts)
|
||||
{
|
||||
var call = sideScripts.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "StackOrAdd");
|
||||
if (call == null)
|
||||
return null;
|
||||
var factory = (Func<Script?>)call.GetArguments()[1]!;
|
||||
return factory() as AuroraVeilEffect;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "The move cannot be activated unless hail [...] is present."
|
||||
/// Without any weather, the move fails and no effect is added to the side.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_NoWeather_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, _, sideScripts, hitData) = CreateTestSetup(null);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
hitData.Received(1).Fail();
|
||||
await Assert.That(sideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "The move cannot be activated unless hail [...] is present."
|
||||
/// Under a different weather (e.g. rain), the move also fails.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_NonHailWeather_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, _, sideScripts, hitData) = CreateTestSetup(new StringKey("rain"));
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
hitData.Received(1).Fail();
|
||||
await Assert.That(sideScripts.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd")).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Aurora Veil reduces the damage done to the user by physical and special moves for five
|
||||
/// turns". During hail, the Aurora Veil effect is added to the user's side with a duration of 5 turns.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_Hail_AddsEffectWithFiveTurns()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, _, sideScripts, hitData) = CreateTestSetup(ScriptUtils.ResolveName<Hail>());
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
var effect = GetAddedEffect(sideScripts);
|
||||
await Assert.That(effect).IsNotNull();
|
||||
await Assert.That(effect!.NumberOfTurns).IsEqualTo(5);
|
||||
hitData.DidNotReceive().Fail();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "If a Light Clay is held when Aurora Veil is used, it will extend the duration of Aurora
|
||||
/// Veil from 5 to 8 turns." The duration extension flows through the AuroraVeilDuration custom trigger;
|
||||
/// a script on the user that sets the duration to 8 results in an effect lasting 8 turns.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_DurationExtendedByTrigger_AddsEffectWithEightTurns()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, target, user, sideScripts, _) = CreateTestSetup(ScriptUtils.ResolveName<Hail>());
|
||||
user.GetScripts().Returns(_ => new ScriptIterator(new List<IEnumerable<ScriptContainer>>
|
||||
{
|
||||
new ScriptContainer(new DurationExtender()),
|
||||
}));
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - the created effect is initialized with the extended duration
|
||||
var call = sideScripts.ReceivedCalls().First(c => c.GetMethodInfo().Name == "StackOrAdd");
|
||||
var effect = (AuroraVeilEffect)((Func<Script?>)call.GetArguments()[1]!)()!;
|
||||
await Assert.That(effect.NumberOfTurns).IsEqualTo(8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: outside of battle (no battle data) the script returns without throwing.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_NoBattleData_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var script = new AuroraVeil();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.BattleData.Returns((IPokemonBattleData?)null);
|
||||
move.User.Returns(user);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - no hit data was touched
|
||||
await Assert.That(move.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "GetHitData")).IsFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using PkmnLib.Dynamic.Events;
|
||||
using PkmnLib.Dynamic.Models;
|
||||
using PkmnLib.Dynamic.ScriptHandling;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Pokemon;
|
||||
using PkmnLib.Static;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="Autotomize"/> move script.
|
||||
/// Gen VII Bulbapedia behavior: "Autotomize raises the user's Speed stat by two stages and (if the user
|
||||
/// successfully changes its Speed) decreases its weight by 220 lbs. (100 kg). If the user successfully
|
||||
/// changes its weight, the message '<Pokémon> became nimble!' is displayed. Autotomize cannot decrease
|
||||
/// the user's weight below the minimum 0.2 lbs (0.1 kg); if the user's weight would drop below the minimum,
|
||||
/// it becomes the minimum instead. Weight loss from Autotomize stacks".
|
||||
/// </summary>
|
||||
public class AutotomizeTests
|
||||
{
|
||||
private static (Autotomize script, IExecutingMove move, IPokemon user, IScriptSet userVolatile, EventHook eventHook)
|
||||
CreateTestSetup(float weightInKg, bool speedRaiseSucceeds, AutotomizeEffect? existingEffect = null)
|
||||
{
|
||||
var script = new Autotomize();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
|
||||
var eventHook = new EventHook();
|
||||
var battle = Substitute.For<IBattle>();
|
||||
battle.EventHook.Returns(eventHook);
|
||||
var battleData = Substitute.For<IPokemonBattleData>();
|
||||
battleData.Battle.Returns(battle);
|
||||
|
||||
var userVolatile = Substitute.For<IScriptSet>();
|
||||
userVolatile.Get<AutotomizeEffect>().Returns(existingEffect);
|
||||
|
||||
var user = Substitute.For<IPokemon>();
|
||||
user.BattleData.Returns(battleData);
|
||||
user.Volatile.Returns(userVolatile);
|
||||
user.WeightInKg.Returns(weightInKg);
|
||||
user.ChangeStatBoost(Arg.Any<Statistic>(), Arg.Any<sbyte>(), Arg.Any<bool>(), Arg.Any<bool>())
|
||||
.Returns(speedRaiseSucceeds);
|
||||
move.User.Returns(user);
|
||||
|
||||
return (script, move, user, userVolatile, eventHook);
|
||||
}
|
||||
|
||||
private static bool ReceivedStackOrAdd(IScriptSet scriptSet) =>
|
||||
scriptSet.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "StackOrAdd");
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Autotomize raises the user's Speed stat by two stages". The stat change is self-inflicted.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void OnSecondaryEffect_RaisesUserSpeedByTwoStages()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, user, _, _) = CreateTestSetup(100f, true);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||
|
||||
// Assert
|
||||
user.Received(1).ChangeStatBoost(Statistic.Speed, 2, true, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "(if the user successfully changes its Speed) decreases its weight by 220 lbs. (100 kg).
|
||||
/// If the user successfully changes its weight, the message '<Pokémon> became nimble!' is displayed."
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_SpeedRaiseSucceeds_AddsWeightReductionAndShowsMessage()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, userVolatile, eventHook) = CreateTestSetup(100f, true);
|
||||
DialogEvent? capturedEvent = null;
|
||||
eventHook.Handler += (_, args) =>
|
||||
{
|
||||
if (args is DialogEvent dialogEvent)
|
||||
capturedEvent = dialogEvent;
|
||||
};
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsTrue();
|
||||
await Assert.That(capturedEvent).IsNotNull();
|
||||
await Assert.That(capturedEvent!.Message).IsEqualTo("pokemon_became_nimble");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: the weight decrease only happens "if the user successfully changes its Speed". When the
|
||||
/// Speed stat cannot be raised (already at +6), no weight reduction is applied and no message is shown.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_SpeedRaiseFails_NoWeightReductionOrMessage()
|
||||
{
|
||||
// Arrange
|
||||
var (script, move, _, userVolatile, eventHook) = CreateTestSetup(100f, false);
|
||||
var eventFired = false;
|
||||
eventHook.Handler += (_, _) => eventFired = true;
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsFalse();
|
||||
await Assert.That(eventFired).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Autotomize cannot decrease the user's weight below the minimum 0.2 lbs (0.1 kg); if the
|
||||
/// user's weight would drop below the minimum, it becomes the minimum instead." A first use on a Pokémon
|
||||
/// lighter than 100 kg still applies the weight reduction (the weight clamps to the minimum).
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_FirstUseOnLightPokemon_StillAppliesWeightReduction()
|
||||
{
|
||||
// Arrange - 50 kg Pokémon, no previous Autotomize use
|
||||
var (script, move, _, userVolatile, _) = CreateTestSetup(50f, true);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Weight loss from Autotomize stacks, so using it multiple times will continue to decrease
|
||||
/// the user's weight accordingly until it reaches the minimum weight" and "if the user's weight would drop
|
||||
/// below the minimum, it becomes the minimum instead." A Pokémon that still weighs more than the minimum
|
||||
/// after two stacks must receive another stack (its weight drops to the 0.1 kg minimum).
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_WeightWouldDropBelowMinimum_StillReducesToMinimum()
|
||||
{
|
||||
// Arrange - the user already has two stacks and currently weighs 50 kg
|
||||
var existingEffect = new AutotomizeEffect();
|
||||
existingEffect.Stack();
|
||||
var (script, move, _, userVolatile, _) = CreateTestSetup(50f, true, existingEffect);
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||
|
||||
// Assert - the weight should still be reduced (to the minimum)
|
||||
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Autotomize cannot decrease the user's weight below the minimum 0.2 lbs (0.1 kg)".
|
||||
/// A Pokémon already at the minimum weight cannot lose more weight, so no new stack is added and no
|
||||
/// message is shown; the Speed boost itself is still applied.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_AlreadyAtMinimumWeight_NoFurtherReductionButSpeedStillRaised()
|
||||
{
|
||||
// Arrange - the user already has a stack and is at the minimum weight
|
||||
var (script, move, user, userVolatile, eventHook) = CreateTestSetup(0.1f, true, new AutotomizeEffect());
|
||||
var eventFired = false;
|
||||
eventHook.Handler += (_, _) => eventFired = true;
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, Substitute.For<IPokemon>(), 0);
|
||||
|
||||
// Assert
|
||||
user.Received(1).ChangeStatBoost(Statistic.Speed, 2, true, false);
|
||||
await Assert.That(ReceivedStackOrAdd(userVolatile)).IsFalse();
|
||||
await Assert.That(eventFired).IsFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using PkmnLib.Dynamic.Models;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||
using PkmnLib.Static;
|
||||
using PkmnLib.Static.Utils;
|
||||
using TUnit.Assertions.AssertConditions.Throws;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="ChangeTargetSpecialDefense"/> script, which implements the secondary effect of Acid.
|
||||
/// Behavior is verified against the Bulbapedia page for Acid.
|
||||
/// Gen VII behavior: "Acid inflicts damage to all adjacent opponents" and "has a 10% chance of lowering the
|
||||
/// target's Special Defense stat by one stage". The 10% proc chance and the targeting are handled by the
|
||||
/// generic secondary effect data (chance: 10 in Moves.jsonc); this script is responsible for applying the
|
||||
/// stat change once the effect procs.
|
||||
/// </summary>
|
||||
public class ChangeTargetSpecialDefenseTests
|
||||
{
|
||||
private static ChangeTargetSpecialDefense CreateInitializedScript(int amount = -1)
|
||||
{
|
||||
var script = new ChangeTargetSpecialDefense();
|
||||
script.OnInitialize(new Dictionary<StringKey, object?> { { "amount", amount } });
|
||||
return script;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the arguments of the ChangeStatBoost call received by a substitute target.
|
||||
/// </summary>
|
||||
private static object?[]? GetStatBoostCallArgs(IPokemon target)
|
||||
{
|
||||
var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
|
||||
return call?.GetArguments();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Acid now has a 10% chance of lowering the target's Special Defense stat by one stage instead."
|
||||
/// (Generation IV onwards.) When the secondary effect procs, the target's Special Defense is lowered by one stage.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TargetIsOpponent_LowersSpecialDefenseByOneStage()
|
||||
{
|
||||
// Arrange
|
||||
var script = CreateInitializedScript();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
move.User.Returns(user);
|
||||
var target = Substitute.For<IPokemon>();
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
var args = GetStatBoostCallArgs(target);
|
||||
await Assert.That(args).IsNotNull();
|
||||
await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.SpecialDefense);
|
||||
await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)-1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia (Generation IV onwards): "lowering the target's Special Defense stat by one stage instead."
|
||||
/// The word "instead" marks the change from the Gen I/II Defense drop — the script must not touch
|
||||
/// the target's (physical) Defense stat.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TargetIsOpponent_DoesNotLowerDefense()
|
||||
{
|
||||
// Arrange
|
||||
var script = CreateInitializedScript();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
move.User.Returns(user);
|
||||
var target = Substitute.For<IPokemon>();
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - exactly one stat change, and it is not Defense
|
||||
var calls = target.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").ToList();
|
||||
await Assert.That(calls.Count).IsEqualTo(1);
|
||||
await Assert.That((Statistic)calls[0].GetArguments()[0]!).IsNotEqualTo(Statistic.Defense);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "lowering the target's Special Defense stat".
|
||||
/// The stat drop is inflicted on an opponent, so it must not be marked as self-inflicted
|
||||
/// (self-inflicted drops bypass effects that prevent stat lowering by opponents, e.g. Clear Body/Mist).
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TargetIsOpponent_NotSelfInflicted()
|
||||
{
|
||||
// Arrange
|
||||
var script = CreateInitializedScript();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
move.User.Returns(user);
|
||||
var target = Substitute.For<IPokemon>();
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
var args = GetStatBoostCallArgs(target);
|
||||
await Assert.That(args).IsNotNull();
|
||||
await Assert.That((bool)args![2]!).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "a 10% chance of lowering the target's Special Defense stat".
|
||||
/// The drop is an ordinary stat change, not a forced one — it must respect prevention effects,
|
||||
/// so the script may not pass force = true.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TargetIsOpponent_StatDropIsNotForced()
|
||||
{
|
||||
// Arrange
|
||||
var script = CreateInitializedScript();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
move.User.Returns(user);
|
||||
var target = Substitute.For<IPokemon>();
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
var args = GetStatBoostCallArgs(target);
|
||||
await Assert.That(args).IsNotNull();
|
||||
await Assert.That((bool)args![3]!).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: the shared ChangeTargetStats base marks the stat change as self-inflicted
|
||||
/// when the target of the secondary effect is the move's own user.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TargetIsUser_SelfInflicted()
|
||||
{
|
||||
// Arrange
|
||||
var script = CreateInitializedScript();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
move.User.Returns(user);
|
||||
|
||||
// Act - the user is hit by its own move (e.g. redirected)
|
||||
script.OnSecondaryEffect(move, user, 0);
|
||||
|
||||
// Assert
|
||||
var args = GetStatBoostCallArgs(user);
|
||||
await Assert.That(args).IsNotNull();
|
||||
await Assert.That((bool)args![2]!).IsTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: the 'amount' parameter from the move data (amount: -1 for Acid in Moves.jsonc)
|
||||
/// is parsed by OnInitialize and passed through to the stat change unmodified.
|
||||
/// </summary>
|
||||
[Test, Arguments(-1), Arguments(-2)]
|
||||
public async Task OnInitialize_AmountParameter_PassedToStatBoost(int amount)
|
||||
{
|
||||
// Arrange
|
||||
var script = CreateInitializedScript(amount);
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var user = Substitute.For<IPokemon>();
|
||||
move.User.Returns(user);
|
||||
var target = Substitute.For<IPokemon>();
|
||||
|
||||
// Act
|
||||
script.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
var args = GetStatBoostCallArgs(target);
|
||||
await Assert.That(args).IsNotNull();
|
||||
await Assert.That((sbyte)args![1]!).IsEqualTo((sbyte)amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: OnInitialize rejects a null parameter dictionary.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnInitialize_NullParameters_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var script = new ChangeTargetSpecialDefense();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.That(() => script.OnInitialize(null)).ThrowsExactly<ArgumentNullException>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: OnInitialize rejects parameters without the required 'amount' entry.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnInitialize_MissingAmount_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var script = new ChangeTargetSpecialDefense();
|
||||
|
||||
// Act & Assert
|
||||
await Assert.That(() => script.OnInitialize(new Dictionary<StringKey, object?>()))
|
||||
.ThrowsExactly<ArgumentException>();
|
||||
}
|
||||
}
|
||||
243
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DrainTests.cs
Normal file
243
Plugins/PkmnLib.Plugin.Gen7.Tests/Scripts/Moves/DrainTests.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
using PkmnLib.Dynamic.Models;
|
||||
using PkmnLib.Dynamic.ScriptHandling;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Abilities;
|
||||
using PkmnLib.Plugin.Gen7.Scripts.Moves;
|
||||
using PkmnLib.Static.Utils;
|
||||
|
||||
namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <see cref="Drain"/> script, which implements Absorb (and the other HP-draining moves).
|
||||
/// Behavior is verified against the Bulbapedia page for Absorb.
|
||||
/// </summary>
|
||||
public class DrainTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a fully mocked test setup for Drain tests.
|
||||
/// </summary>
|
||||
private static (Drain drain, IExecutingMove move, IPokemon target, IPokemon user) CreateTestSetup(uint damage,
|
||||
bool holdsBigRoot = false, Script[]? targetScripts = null)
|
||||
{
|
||||
var drain = new Drain();
|
||||
var move = Substitute.For<IExecutingMove>();
|
||||
var target = Substitute.For<IPokemon>();
|
||||
var hitData = Substitute.For<IHitData>();
|
||||
hitData.Damage.Returns(damage);
|
||||
move.GetHitData(target, 0).Returns(hitData);
|
||||
|
||||
// RunScriptHook iterates the target's scripts; give the mock a real iterator so the
|
||||
// hook pass runs (empty unless the test attaches scripts such as Liquid Ooze).
|
||||
var containers = (targetScripts ?? []).Select(IEnumerable<ScriptContainer> (s) => new ScriptContainer(s))
|
||||
.ToArray();
|
||||
target.GetScripts().Returns(_ => new ScriptIterator(containers));
|
||||
|
||||
var user = Substitute.For<IPokemon>();
|
||||
if (holdsBigRoot)
|
||||
user.HasHeldItem("big_root").Returns(true);
|
||||
move.User.Returns(user);
|
||||
|
||||
return (drain, move, target, user);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the heal amount from the user's received Heal calls.
|
||||
/// </summary>
|
||||
private static uint? GetHealAmount(IPokemon user)
|
||||
{
|
||||
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Heal");
|
||||
return call != null ? (uint)call.GetArguments()[0]! : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the damage amount from the user's received Damage calls.
|
||||
/// </summary>
|
||||
private static uint? GetDamageAmount(IPokemon user)
|
||||
{
|
||||
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
|
||||
return call != null ? (uint)call.GetArguments()[0]! : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to extract the damage source from the user's received Damage calls.
|
||||
/// </summary>
|
||||
private static DamageSource? GetDamageSource(IPokemon user)
|
||||
{
|
||||
var call = user.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "Damage");
|
||||
return call != null ? (DamageSource)call.GetArguments()[1]! : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "Absorb inflicts damage, and up to 50% of the damage dealt to the target is restored to the
|
||||
/// user as HP".
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_DamageDealt_UserHealsHalfOfDamage()
|
||||
{
|
||||
// Arrange
|
||||
var (drain, move, target, user) = CreateTestSetup(100);
|
||||
|
||||
// Act
|
||||
drain.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(GetHealAmount(user)).IsEqualTo(50u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "up to 50% of the damage dealt to the target is restored to the user as HP".
|
||||
/// Tests various damage values to ensure proper integer truncation of the halved amount.
|
||||
/// </summary>
|
||||
[Test, Arguments(200u, 100u), Arguments(51u, 25u), Arguments(99u, 49u), Arguments(3u, 1u)]
|
||||
public async Task OnSecondaryEffect_DamageDealt_HealCalculation(uint damage, uint expectedHeal)
|
||||
{
|
||||
// Arrange
|
||||
var (drain, move, target, user) = CreateTestSetup(damage);
|
||||
|
||||
// Act
|
||||
drain.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(GetHealAmount(user)).IsEqualTo(expectedHeal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "up to 50% of the damage dealt to the target is restored to the user as HP (but not less
|
||||
/// than 1 HP)".
|
||||
/// A move dealing 1 damage should still restore 1 HP.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_DamageOfOne_HealsAtLeastOneHp()
|
||||
{
|
||||
// Arrange
|
||||
var (drain, move, target, user) = CreateTestSetup(1);
|
||||
|
||||
// Act
|
||||
drain.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(GetHealAmount(user)).IsEqualTo(1u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "If the user is holding a Big Root, the HP restored is increased by 30% (making the restored
|
||||
/// HP 65% of the damage dealt)."
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_UserHoldingBigRoot_HealIncreasedByThirtyPercent()
|
||||
{
|
||||
// Arrange
|
||||
var (drain, move, target, user) = CreateTestSetup(100, true);
|
||||
|
||||
// Act
|
||||
drain.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - 65% of 100 damage
|
||||
await Assert.That(GetHealAmount(user)).IsEqualTo(65u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "When used on a Pokémon with the Liquid Ooze Ability, the user will lose the amount of HP it
|
||||
/// would have gained instead."
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TargetHasLiquidOoze_UserLosesHpInsteadOfHealing()
|
||||
{
|
||||
// Arrange
|
||||
var (drain, move, target, user) = CreateTestSetup(100, targetScripts: [new LiquidOoze()]);
|
||||
|
||||
// Act
|
||||
drain.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert - user takes the 50 HP it would have gained, and is not healed
|
||||
await Assert.That(GetDamageAmount(user)).IsEqualTo(50u);
|
||||
await Assert.That(user.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Heal")).IsFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "the user will lose the amount of HP it would have gained instead."
|
||||
/// The HP loss caused by Liquid Ooze is indirect damage, not move damage.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_TargetHasLiquidOoze_UsesMiscDamageSource()
|
||||
{
|
||||
// Arrange
|
||||
var (drain, move, target, user) = CreateTestSetup(100, targetScripts: [new LiquidOoze()]);
|
||||
|
||||
// Act
|
||||
drain.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(GetDamageSource(user)).IsEqualTo(DamageSource.Misc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "the user will lose the amount of HP it would have gained instead."
|
||||
/// With a Big Root held, the amount it would have gained is 65% of the damage, so that is what is lost.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_LiquidOozeAndBigRoot_UserLosesBoostedAmount()
|
||||
{
|
||||
// Arrange
|
||||
var (drain, move, target, user) = CreateTestSetup(100, true, [new LiquidOoze()]);
|
||||
|
||||
// Act
|
||||
drain.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(GetDamageAmount(user)).IsEqualTo(65u);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: without initialization parameters the drain modifier defaults to 50%, matching Absorb.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnInitialize_NullParameters_DrainModifierDefaultsToHalf()
|
||||
{
|
||||
// Arrange
|
||||
var drain = new Drain();
|
||||
|
||||
// Act
|
||||
drain.OnInitialize(null);
|
||||
|
||||
// Assert
|
||||
await Assert.That(drain.DrainModifier).IsEqualTo(0.5f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Technical test: a "drain_modifier" parameter overrides the default drain fraction.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnInitialize_DrainModifierParameter_OverridesDefault()
|
||||
{
|
||||
// Arrange
|
||||
var drain = new Drain();
|
||||
var parameters = new Dictionary<StringKey, object?> { { "drain_modifier", 0.75f } };
|
||||
|
||||
// Act
|
||||
drain.OnInitialize(parameters);
|
||||
|
||||
// Assert
|
||||
await Assert.That(drain.DrainModifier).IsEqualTo(0.75f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulbapedia: "up to 50% of the damage dealt to the target is restored to the user as HP".
|
||||
/// Integration check: initializing the script with Absorb's actual effect parameters from the Gen7 data
|
||||
/// results in a 50% drain.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task OnSecondaryEffect_InitializedWithAbsorbData_HealsHalfOfDamage()
|
||||
{
|
||||
// Arrange
|
||||
var library = LibraryHelpers.LoadLibrary();
|
||||
await Assert.That(library.StaticLibrary.Moves.TryGet("absorb", out var absorb)).IsTrue();
|
||||
var (drain, move, target, user) = CreateTestSetup(100);
|
||||
|
||||
// Act
|
||||
drain.OnInitialize(absorb!.SecondaryEffect!.Parameters);
|
||||
drain.OnSecondaryEffect(move, target, 0);
|
||||
|
||||
// Assert
|
||||
await Assert.That(GetHealAmount(user)).IsEqualTo(50u);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace PkmnLib.Plugin.Gen7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a test as known to be failing, so that it will be skipped when running all tests, while still allowing us
|
||||
/// to know that we should still fix it.
|
||||
/// </summary>
|
||||
public class TestFailingAttribute(string failureReason) : SkipAttribute("Test is currently failing: " + failureReason)
|
||||
{
|
||||
}
|
||||
@@ -14,15 +14,16 @@ public class Acupressure : Script, IScriptOnSecondaryEffect
|
||||
/// <inheritdoc />
|
||||
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
|
||||
{
|
||||
var possibleStats = target.StatBoost.Where(x => x.statistic != Statistic.Hp && x.value < 6).ToArray();
|
||||
// If the target has no stats to raise, the move fails
|
||||
if (target.StatBoost.All(s => s.value == 6))
|
||||
if (!possibleStats.Any())
|
||||
{
|
||||
move.GetHitData(target, hit).Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Choose a random stat to raise. 0 is HP, so we start at 1.
|
||||
var stat = (Statistic)move.User.BattleData!.Battle.Random.GetInt(1, (int)Statistic.Speed + 1);
|
||||
var index = move.User.BattleData!.Battle.Random.GetInt(0, possibleStats.Length);
|
||||
var stat = possibleStats[index].statistic;
|
||||
target.ChangeStatBoost(stat, 2, false, false);
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,10 @@ public class Autotomize : Script, IScriptOnSecondaryEffect
|
||||
public void OnSecondaryEffect(IExecutingMove move, IPokemon target, byte hit)
|
||||
{
|
||||
var user = move.User;
|
||||
if (!user.ChangeStatBoost(Statistic.Speed, 2, true, false))
|
||||
return;
|
||||
|
||||
var existingEffect = user.Volatile.Get<AutotomizeEffect>();
|
||||
var stacks = existingEffect?.Stacks ?? 0;
|
||||
|
||||
if (!user.ChangeStatBoost(Statistic.Speed, 2, true, false) || !(user.WeightInKg - 100f * stacks >= 0.1f))
|
||||
if (!(user.WeightInKg > 0.1f))
|
||||
return;
|
||||
|
||||
user.Volatile.StackOrAdd(ScriptUtils.ResolveName<AutotomizeEffect>(), () => new AutotomizeEffect());
|
||||
|
||||
@@ -27,6 +27,8 @@ public class Drain : Script, IScriptOnInitialize, IScriptOnSecondaryEffect
|
||||
target.RunScriptHook<IScriptCustomTrigger>(x => x.CustomTrigger(CustomTriggers.ModifyDrain, args));
|
||||
invert = args.Invert;
|
||||
healed = args.Healed;
|
||||
if (healed == 0)
|
||||
healed = 1;
|
||||
|
||||
if (invert)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user